2 Incheckningar

Upphovsman SHA1 Meddelande Datum
  Harry Groves 408086acc7 Use new anonymous floor and bin labels 3 veckor sedan
  Harry Groves b6f41ecd3c EN support for scheduling page 3 veckor sedan
53 ändrade filer med 793 tillägg och 448 borttagningar
Delad Vy
  1. +3
    -2
      src/app/(main)/chart/delivery/page.tsx
  2. +67
    -56
      src/app/(main)/ps/PsRulesTab.tsx
  3. +117
    -104
      src/app/(main)/ps/page.tsx
  4. +4
    -3
      src/app/api/chart/client.ts
  5. +2
    -1
      src/app/api/do/actions.tsx
  6. +3
    -2
      src/app/api/doworkbench/actions.ts
  7. +2
    -1
      src/app/api/jo/actions.ts
  8. +2
    -1
      src/app/api/pickOrder/actions.ts
  9. +3
    -2
      src/app/api/settings/deliveryOrderFloor/constants.ts
  10. +3
    -2
      src/app/utils/formatUtil.ts
  11. +4
    -3
      src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx
  12. +17
    -16
      src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx
  13. +9
    -8
      src/components/DoDetail/DoDetail.tsx
  14. +10
    -9
      src/components/DoSearch/DoSearch.tsx
  15. +3
    -2
      src/components/DoSearchWorkbench/DoSearchWorkbench.tsx
  16. +2
    -1
      src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx
  17. +4
    -3
      src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx
  18. +24
    -23
      src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
  19. +4
    -3
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  20. +3
    -1
      src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
  21. +3
    -2
      src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx
  22. +4
    -3
      src/components/DoWorkbench/workbenchLanePanelPrefs.ts
  23. +7
    -6
      src/components/FinishedGoodSearch/FGPickOrderCard.tsx
  24. +3
    -2
      src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx
  25. +20
    -19
      src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx
  26. +27
    -26
      src/components/FinishedGoodSearch/FinishedGoodFloorLanePanel.tsx
  27. +4
    -3
      src/components/FinishedGoodSearch/FinishedGoodSearch.tsx
  28. +6
    -5
      src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
  29. +4
    -3
      src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx
  30. +2
    -1
      src/components/FinishedGoodSearch/TruckRoutingSummaryTab.tsx
  31. +6
    -4
      src/components/InventorySearch/LotLabelPrintModal.tsx
  32. +6
    -5
      src/components/JoWorkbench/JoPickOrderList.tsx
  33. +19
    -13
      src/components/JoWorkbench/newJobPickExecution.tsx
  34. +10
    -9
      src/components/Jodetail/JoPickOrderList.tsx
  35. +2
    -1
      src/components/Jodetail/JodetailSearch.tsx
  36. +22
    -21
      src/components/Jodetail/PlasticBoxCartonDashboardTab.tsx
  37. +4
    -3
      src/components/Jodetail/completeJobOrderRecord.tsx
  38. +16
    -8
      src/components/Jodetail/newJobPickExecution.tsx
  39. +5
    -4
      src/components/Jodetail/pickRecordHelpers.ts
  40. +9
    -8
      src/components/LotSuggestionRules/LotSuggestionRules.tsx
  41. +22
    -21
      src/components/Shop/RouteBoard.tsx
  42. +4
    -3
      src/components/Shop/Shop.tsx
  43. +3
    -2
      src/components/Shop/ShopDetail.tsx
  44. +6
    -5
      src/components/Shop/TruckLane.tsx
  45. +9
    -8
      src/components/Shop/TruckLaneDetail.tsx
  46. +6
    -5
      src/components/Shop/computeTruckLaneWarnings.ts
  47. +2
    -1
      src/components/Shop/routeBoardImportPreview.ts
  48. +4
    -3
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  49. +4
    -3
      src/components/StockTakeManagement/PickerCardList.tsx
  50. +19
    -0
      src/config/demoFloors.ts
  51. +7
    -6
      src/config/reportConfig.ts
  52. +121
    -1
      src/i18n/en/scheduling.json
  53. +121
    -1
      src/i18n/zh/scheduling.json

+ 3
- 2
src/app/(main)/chart/delivery/page.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import React, { useCallback, useMemo, useState } from "react";
import {
Box,
@@ -37,8 +38,8 @@ const STAFF_PERF_STORE_FILTER_OPTIONS: {
label: string;
}[] = [
{ value: "all", label: "全部" },
{ value: "2/F", label: "2/F" },
{ value: "4/F", label: "4/F" },
{ value: FLOOR_A, label: FLOOR_A },
{ value: FLOOR_B, label: FLOOR_B },
{ value: "null_only", label: "車線-X" },
];



+ 67
- 56
src/app/(main)/ps/PsRulesTab.tsx Visa fil

@@ -3,17 +3,18 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { CircularProgress } from "@mui/material";
import Clear from "@mui/icons-material/Clear";
import { useTranslation } from "react-i18next";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

const WEEKDAYS = [
{ id: "MON", label: "一" },
{ id: "TUE", label: "二" },
{ id: "WED", label: "三" },
{ id: "THU", label: "四" },
{ id: "FRI", label: "五" },
{ id: "SAT", label: "六" },
{ id: "SUN", label: "日" },
{ id: "MON", labelKey: "weekdayMon" },
{ id: "TUE", labelKey: "weekdayTue" },
{ id: "WED", labelKey: "weekdayWed" },
{ id: "THU", labelKey: "weekdayThu" },
{ id: "FRI", labelKey: "weekdayFri" },
{ id: "SAT", labelKey: "weekdaySat" },
{ id: "SUN", labelKey: "weekdaySun" },
] as const;

type WeekdayId = (typeof WEEKDAYS)[number]["id"];
@@ -74,6 +75,7 @@ function numText(value: unknown, fallback: string): string {
}

export default function PsRulesTab({ items }: { items: ItemOption[] }) {
const { t } = useTranslation("scheduling");
const [plant, setPlant] = useState<PlantRules>(emptyPlant);
const [overrides, setOverrides] = useState<Record<string, ItemOverride>>({});
const [loading, setLoading] = useState(true);
@@ -91,7 +93,7 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
);
if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
alert("讀取排期規則失敗");
alert(t("loadRulesFail"));
return;
}
const data = await response.json();
@@ -138,7 +140,7 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
setOverrides(next);
} catch (e) {
console.error("schedule-rules Error:", e);
alert("讀取排期規則失敗");
alert(t("loadRulesFail"));
} finally {
setLoading(false);
}
@@ -155,15 +157,15 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
const prepRed = Number(plant.prepareMoreRedLineDays);
const prepSafety = Number(plant.prepareMoreSafetyStockDays);
if (![red, safety, prepRed, prepSafety].every((n) => Number.isFinite(n) && n > 0)) {
alert("可用日必須是大於 0 的數字。");
alert(t("daysMustBePositive"));
return;
}
if (red > safety || prepRed > prepSafety) {
alert("觸發日不可大於補貨日。");
alert(t("triggerNotAboveReplenish"));
return;
}
if (!plant.coffeeDays.length || !plant.teaDays.length || !plant.lemonDays.length) {
alert("類別 A / B / C 至少要各選一天可生產。");
alert(t("categoryNeedsDay"));
return;
}
plantLock.current = true;
@@ -186,13 +188,13 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
const body = await response.json().catch(() => ({}));
alert(body.error || "儲存失敗");
alert(body.error || t("saveFail"));
return;
}
alert("已儲存。下次按「預測排期」會用新規則。");
alert(t("rulesSaved"));
} catch (e) {
console.error("save schedule-rules Error:", e);
alert("儲存失敗");
alert(t("saveFail"));
} finally {
setSavingPlant(false);
plantLock.current = false;
@@ -222,21 +224,21 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
return (
<div className="space-y-4 p-4">
<p className="text-sm text-slate-600 dark:text-slate-400">
可用日 = 存貨 ÷ 每日平均出貨量。低於觸發日就安排生產,並補到補貨日。留空的物料跟隨這裡的預設。已產生的排期要再按「預測排期」才會更新。
{t("rulesIntro")}
</p>

<section className="rounded-lg border border-slate-200 p-4 dark:border-slate-700">
<h3 className="mb-3 text-sm font-semibold text-slate-900 dark:text-white">預設規則</h3>
<h3 className="mb-3 text-sm font-semibold text-slate-900 dark:text-white">{t("defaultRules")}</h3>
<div className="grid gap-4 md:grid-cols-2">
<DayPair
title="一般日"
title={t("normalDays")}
red={plant.redLineDays}
safety={plant.safetyStockDays}
onRed={(value) => setPlant((prev) => ({ ...prev, redLineDays: value }))}
onSafety={(value) => setPlant((prev) => ({ ...prev, safetyStockDays: value }))}
/>
<DayPair
title="多備日"
title={t("extraDays")}
red={plant.prepareMoreRedLineDays}
safety={plant.prepareMoreSafetyStockDays}
onRed={(value) => setPlant((prev) => ({ ...prev, prepareMoreRedLineDays: value }))}
@@ -247,33 +249,33 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<DayChecks
label="多備星期"
label={t("extraWeekdays")}
selected={plant.prepareMoreDays}
onChange={(days) => setPlant((prev) => ({ ...prev, prepareMoreDays: days }))}
/>
<DayChecks
label="未分類物料不生產"
label={t("noProductionUncategorised")}
selected={plant.noProductionDays}
onChange={(days) => setPlant((prev) => ({ ...prev, noProductionDays: days }))}
/>
</div>
<div className="mt-4 space-y-3">
<CategoryDays
label="類別 A"
label={t("categoryA")}
days={plant.coffeeDays}
skip={plant.coffeeSkipPrepareMore}
onDays={(days) => setPlant((prev) => ({ ...prev, coffeeDays: days }))}
onSkip={(skip) => setPlant((prev) => ({ ...prev, coffeeSkipPrepareMore: skip }))}
/>
<CategoryDays
label="類別 B"
label={t("categoryB")}
days={plant.teaDays}
skip={plant.teaSkipPrepareMore}
onDays={(days) => setPlant((prev) => ({ ...prev, teaDays: days }))}
onSkip={(skip) => setPlant((prev) => ({ ...prev, teaSkipPrepareMore: skip }))}
/>
<CategoryDays
label="類別 C"
label={t("categoryC")}
days={plant.lemonDays}
skip={plant.lemonSkipPrepareMore}
onDays={(days) => setPlant((prev) => ({ ...prev, lemonDays: days }))}
@@ -288,18 +290,18 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 disabled:opacity-50"
>
{savingPlant && <CircularProgress size={14} sx={{ display: "block" }} />}
儲存預設
{t("saveDefaults")}
</button>
</div>
</section>

<section className="rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">個別物料</h3>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">{t("perItem")}</h3>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="搜尋物料編號或名稱"
placeholder={t("searchItem")}
className="w-56 rounded border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
<label className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
@@ -308,19 +310,19 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
checked={onlyOverrides}
onChange={(e) => setOnlyOverrides(e.target.checked)}
/>
只顯示已自訂
{t("customOnly")}
</label>
</div>
<div className="max-h-[42vh] overflow-auto">
<table className="w-full min-w-[860px] text-left text-sm">
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
<tr>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">觸發日</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">補貨日</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">可生產日</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">多備</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">{t("itemCode")}</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">{t("itemName")}</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">{t("triggerDays")}</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">{t("replenishDays")}</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">{t("productionDays")}</th>
<th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">{t("extraStock")}</th>
<th className="px-3 py-2" />
</tr>
</thead>
@@ -361,15 +363,21 @@ function DayPair({
onRed: (value: string) => void;
onSafety: (value: string) => void;
}) {
const { t } = useTranslation("scheduling");
return (
<div>
<div className="mb-2 text-sm font-medium text-slate-800 dark:text-slate-100">{title}</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<span>低於</span>
<NumInput value={red} onChange={onRed} />
<span>日生產,補至</span>
<NumInput value={safety} onChange={onSafety} />
<span>日</span>
<div className="space-y-2 text-sm text-slate-600 dark:text-slate-300">
<div className="flex items-center gap-2 whitespace-nowrap">
<span>{t("below")}</span>
<NumInput value={red} onChange={onRed} />
<span>{t("produceUpTo")}</span>
</div>
<div className="flex items-center gap-2 whitespace-nowrap">
<span>{t("topUpTo")}</span>
<NumInput value={safety} onChange={onSafety} />
<span>{t("days")}</span>
</div>
</div>
</div>
);
@@ -399,6 +407,7 @@ function DayChecks({
onChange: (days: WeekdayId[]) => void;
disabled?: boolean;
}) {
const { t } = useTranslation("scheduling");
return (
<div>
{label ? (
@@ -417,13 +426,13 @@ function DayChecks({
on ? selected.filter((id) => id !== day.id) : [...selected, day.id]
)
}
className={`h-8 w-8 rounded border text-sm ${
className={`h-8 min-w-8 rounded border px-1.5 text-xs ${
on
? "border-blue-500 bg-blue-500 text-white"
: "border-slate-300 bg-white text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300"
} disabled:opacity-40`}
>
{day.label}
{t(day.labelKey)}
</button>
);
})}
@@ -445,13 +454,14 @@ function CategoryDays({
onDays: (days: WeekdayId[]) => void;
onSkip: (skip: boolean) => void;
}) {
const { t } = useTranslation("scheduling");
return (
<div className="flex flex-wrap items-center gap-3">
<div className="w-16 text-sm font-medium text-slate-800 dark:text-slate-100">{label}</div>
<div className="w-24 shrink-0 text-sm font-medium text-slate-800 dark:text-slate-100">{label}</div>
<DayChecks label="" selected={days} onChange={onDays} />
<label className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<input type="checkbox" checked={skip} onChange={(e) => onSkip(e.target.checked)} />
不套用多備
{t("skipExtra")}
</label>
</div>
);
@@ -474,6 +484,7 @@ function ItemRuleRow({
produceDays: [],
skipPrepareMore: null,
});
const { t } = useTranslation("scheduling");
const [draft, setDraft] = useState<ItemOverride>(value ?? blank());
const [saving, setSaving] = useState(false);
const lock = useRef(false);
@@ -491,15 +502,15 @@ function ItemRuleRow({
const red = next.redLineDays.trim() === "" ? null : Number(next.redLineDays);
const safety = next.safetyStockDays.trim() === "" ? null : Number(next.safetyStockDays);
if ((red != null && (!Number.isFinite(red) || red <= 0)) || (safety != null && (!Number.isFinite(safety) || safety <= 0))) {
alert("可用日必須是大於 0 的數字,或留空跟隨預設。");
alert(t("daysBlankFollowDefault"));
return;
}
if (red != null && safety != null && red > safety) {
alert("觸發日不可大於補貨日。");
alert(t("triggerNotAboveReplenish"));
return;
}
if (next.useCustomProduceDays && next.produceDays.length === 0) {
alert("自訂可生產日至少要選一天。");
alert(t("customDaysRequired"));
return;
}
const empty =
@@ -522,13 +533,13 @@ function ItemRuleRow({
if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
const body = await response.json().catch(() => ({}));
alert(body.error || "儲存失敗");
alert(body.error || t("saveFail"));
return;
}
onSaved(empty ? null : next);
} catch (e) {
console.error("setItemPsRule Error:", e);
alert("儲存失敗");
alert(t("saveFail"));
} finally {
setSaving(false);
lock.current = false;
@@ -547,7 +558,7 @@ function ItemRuleRow({
type="number"
min={0}
step={0.1}
placeholder="預設"
placeholder={t("defaultPlaceholder")}
value={draft.redLineDays}
onChange={(e) => setDraft((prev) => ({ ...prev, redLineDays: e.target.value }))}
onBlur={() => void persist(draft)}
@@ -559,7 +570,7 @@ function ItemRuleRow({
type="number"
min={0}
step={0.1}
placeholder="預設"
placeholder={t("defaultPlaceholder")}
value={draft.safetyStockDays}
onChange={(e) => setDraft((prev) => ({ ...prev, safetyStockDays: e.target.value }))}
onBlur={() => void persist(draft)}
@@ -581,7 +592,7 @@ function ItemRuleRow({
if (!e.target.checked) void persist(next);
}}
/>
自訂
{t("custom")}
</label>
<DayChecks
label=""
@@ -606,9 +617,9 @@ function ItemRuleRow({
}}
className="rounded border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-700"
>
<option value="">跟隨預設</option>
<option value="apply">套用多備</option>
<option value="skip">不套用多備</option>
<option value="">{t("followDefault")}</option>
<option value="apply">{t("applyExtra")}</option>
<option value="skip">{t("skipExtra")}</option>
</select>
</td>
<td className="px-3 py-2">
@@ -617,7 +628,7 @@ function ItemRuleRow({
) : value ? (
<button
type="button"
title="清除此物料的自訂規則"
title={t("clearItemRule")}
onClick={() => {
const next = blank();
setDraft(next);


+ 117
- 104
src/app/(main)/ps/page.tsx Visa fil

@@ -11,6 +11,7 @@ import Settings from "@mui/icons-material/Settings";
import Clear from "@mui/icons-material/Clear";
import Refresh from "@mui/icons-material/Refresh";
import { CircularProgress } from "@mui/material";
import { useTranslation } from "react-i18next";
import PageTitleBar from "@/components/PageTitleBar";
import dayjs from "dayjs";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
@@ -33,6 +34,7 @@ type ItemDailyOutRow = {
};

export default function ProductionSchedulePage() {
const { t } = useTranslation("scheduling");
const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD"));
const [schedules, setSchedules] = useState<any[]>([]);
const [selectedLines, setSelectedLines] = useState<any[]>([]);
@@ -111,7 +113,7 @@ export default function ProductionSchedulePage() {

const handleConfirmForecast = async () => {
if (!forecastStartDate || forecastDays === "" || forecastDays < 1) {
alert("Please enter a valid start date and number of days (≥1).");
alert(t("forecastInvalid"));
return;
}
setLoading(true);
@@ -126,15 +128,15 @@ export default function ProductionSchedulePage() {
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
await handleSearch();
alert("成功計算排期!");
alert(t("forecastOk"));
} else {
const errorText = await response.text();
console.error("Forecast failed:", errorText);
alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`);
alert(t("forecastError", { status: response.status, detail: errorText.substring(0, 120) }));
}
} catch (e) {
console.error("Forecast Error:", e);
alert("發生不明狀況.");
alert(t("forecastUnknown"));
} finally {
setLoading(false);
}
@@ -143,11 +145,11 @@ export default function ProductionSchedulePage() {
const handleConfirmDoQtyExport = async () => {
if (doQtyExportInFlightRef.current) return;
if (!doQtyExportFromDate || !doQtyExportToDate) {
alert("請選擇開始及結束日期。");
alert(t("doQtyDatesRequired"));
return;
}
if (dayjs(doQtyExportFromDate).isAfter(dayjs(doQtyExportToDate))) {
alert("開始日期不可晚於結束日期。");
alert(t("doQtyDateOrder"));
return;
}
doQtyExportInFlightRef.current = true;
@@ -178,7 +180,7 @@ export default function ProductionSchedulePage() {
document.body.removeChild(a);
} catch (e) {
console.error("DO qty export Error:", e);
alert("匯出送貨單數量失敗。");
alert(t("doQtyExportFail"));
} finally {
setIsDoQtyExporting(false);
doQtyExportInFlightRef.current = false;
@@ -187,7 +189,7 @@ export default function ProductionSchedulePage() {

const handleConfirmExport = async () => {
if (!exportFromDate) {
alert("Please select a from date.");
alert(t("exportFromRequired"));
return;
}
setLoading(true);
@@ -211,7 +213,7 @@ export default function ProductionSchedulePage() {
document.body.removeChild(a);
} catch (e) {
console.error("Export Error:", e);
alert("Failed to export file.");
alert(t("exportFail"));
} finally {
setLoading(false);
}
@@ -219,7 +221,7 @@ export default function ProductionSchedulePage() {

const handleViewDetail = async (ps: any) => {
if (!ps?.id) {
alert("Cannot open details: missing schedule ID");
alert(t("detailMissingId"));
return;
}
setSelectedPs(ps);
@@ -230,7 +232,7 @@ export default function ProductionSchedulePage() {
if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
const errorText = await response.text().catch(() => "(no text)");
alert(`Server error ${response.status}: ${errorText}`);
alert(t("serverError", { status: response.status, detail: errorText }));
return;
}
const data = await response.json();
@@ -238,7 +240,7 @@ export default function ProductionSchedulePage() {
setIsDetailOpen(true);
} catch (err) {
console.error("Fetch failed:", err);
alert("Network or fetch error – check console");
alert(t("networkError"));
} finally {
setLoading(false);
}
@@ -258,10 +260,15 @@ export default function ProductionSchedulePage() {
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
const data = await response.json();
alert(data.message || "Operation completed.");
const created = Number(data?.code);
if (data?.type === "psJobOrders" && Number.isFinite(created)) {
alert(created > 0 ? t("jobsCreated", { count: created }) : t("jobsNone"));
} else {
alert(data.message || t("operationDone"));
}
setIsDetailOpen(false);
} else {
alert("Failed to generate jobs.");
alert(t("generateJobsFail"));
}
} catch (e) {
console.error("Release Error:", e);
@@ -347,14 +354,17 @@ export default function ProductionSchedulePage() {
if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
const msg = await response.text().catch(() => "");
alert(`刷新庫存失敗(${response.status})${msg ? `: ${msg.slice(0, 120)}` : ""}`);
alert(t("refreshStockFailStatus", {
status: response.status,
detail: msg ? `: ${msg.slice(0, 120)}` : "",
}));
return;
}
await response.json().catch(() => null);
await fetchItemDailyOut(true);
} catch (e) {
console.error("refreshInventoryOnHand Error:", e);
alert("刷新庫存失敗。");
alert(t("refreshStockFail"));
} finally {
setIsRefreshingOnHand(false);
refreshOnHandInFlightRef.current = false;
@@ -381,13 +391,13 @@ export default function ProductionSchedulePage() {
const workbook = XLSX.read(data, { type: "array" });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
alert("Excel 沒有工作表。");
alert(t("excelNoSheet"));
return;
}
const sheet = workbook.Sheets[sheetName];
const rows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: null });
if (!rows.length) {
alert("Excel 內容為空。");
alert(t("excelEmpty"));
return;
}

@@ -414,17 +424,18 @@ export default function ProductionSchedulePage() {
.filter((r): r is { itemCode: string; onHandQty: number | null } => r !== null);

if (!payload.length) {
alert("找不到任何有效的 itemCode。");
alert(t("excelNoItemCode"));
return;
}

// Warn user about itemCodes that are not in BOM scope (won't affect forecast)
if (invalidCodes.length) {
const preview = invalidCodes.slice(0, 10).join(", ");
alert(
`注意:以下物料編號不在排期 BOM 範圍內,預測不會受影響,只會寫入覆蓋表。\n\n` +
`${preview}${invalidCodes.length > 10 ? ` 等共 ${invalidCodes.length} 筆` : ""}`
);
const more =
invalidCodes.length > 10
? t("importOutsideBomMore", { count: invalidCodes.length })
: "";
alert(t("importOutsideBom", { preview: `${preview}${more}` }));
}

const resp = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/ps/importFakeOnHand`, {
@@ -434,17 +445,16 @@ export default function ProductionSchedulePage() {
});

if (resp.status === 401 || resp.status === 403) {
alert("登入已過期或沒有權限,請重新登入後再試。");
alert(t("sessionExpired"));
return;
}

if (!resp.ok) {
const msg = await resp.text().catch(() => "");
alert(
`匯入失敗(狀態碼 ${resp.status})。${
msg ? `\n伺服器訊息:${msg.slice(0, 120)}` : ""
}`
);
alert(t("importFailStatus", {
status: resp.status,
detail: msg ? t("serverMessage", { message: msg.slice(0, 120) }) : "",
}));
return;
}

@@ -453,10 +463,10 @@ export default function ProductionSchedulePage() {
// Backend clears item_fake_onhand then inserts payload rows,
// so after success the table should exactly match the uploaded Excel.
await fetchItemDailyOut(true);
alert(`已成功匯入並更新 ${result.count ?? payload.length} 筆排期庫存 (item_fake_onhand)。`);
alert(t("importOk", { count: result.count ?? payload.length }));
} catch (e) {
console.error("Import fake on hand error:", e);
alert("匯入失敗,請檢查檔案格式。");
alert(t("importFailFormat"));
} finally {
setIsImportingFakeOnHand(false);
}
@@ -481,18 +491,18 @@ export default function ProductionSchedulePage() {
)
);
} else {
alert("儲存失敗");
alert(t("saveFail"));
}
} catch (e) {
console.error("setDailyQtyOut Error:", e);
alert("儲存失敗");
alert(t("saveFail"));
} finally {
setDailyOutSavingCode(null);
}
};

const handleClearDailyQty = async (itemCode: string) => {
if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return;
if (!confirm(t("confirmClearDailyOut", { itemCode }))) return;
setDailyOutClearingCode(itemCode);
try {
const response = await clientAuthFetch(
@@ -511,11 +521,11 @@ export default function ProductionSchedulePage() {
)
);
} else {
alert("清除失敗");
alert(t("clearFail"));
}
} catch (e) {
console.error("clearDailyQtyOut Error:", e);
alert("清除失敗");
alert(t("clearFail"));
} finally {
setDailyOutClearingCode(null);
}
@@ -550,11 +560,11 @@ export default function ProductionSchedulePage() {
})
);
} else {
alert("設定失敗");
alert(t("updateFail"));
}
} catch (e) {
console.error("setCoffeeOrTea Error:", e);
alert("設定失敗");
alert(t("updateFail"));
} finally {
setCoffeeOrTeaUpdating(null);
}
@@ -579,18 +589,18 @@ export default function ProductionSchedulePage() {
)
);
} else {
alert("設定失敗");
alert(t("updateFail"));
}
} catch (e) {
console.error("setFakeOnHand Error:", e);
alert("設定失敗");
alert(t("updateFail"));
} finally {
setFakeOnHandSavingCode(null);
}
};

const handleClearFakeOnHand = async (itemCode: string) => {
if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return;
if (!confirm(t("confirmClearScheduleStock"))) return;
setFakeOnHandClearingCode(itemCode);
try {
const response = await clientAuthFetch(
@@ -609,11 +619,11 @@ export default function ProductionSchedulePage() {
)
);
} else {
alert("清除失敗");
alert(t("clearFail"));
}
} catch (e) {
console.error("clearFakeOnHand Error:", e);
alert("清除失敗");
alert(t("clearFail"));
} finally {
setFakeOnHandClearingCode(null);
}
@@ -622,7 +632,7 @@ export default function ProductionSchedulePage() {
return (
<div className="space-y-4">
<PageTitleBar
title="生產排程"
title={t("pageTitle")}
actions={
<>
<button
@@ -631,7 +641,7 @@ export default function ProductionSchedulePage() {
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<Settings sx={{ fontSize: 16 }} />
排期設定
{t("scheduleSettings")}
</button>
<button
type="button"
@@ -639,7 +649,7 @@ export default function ProductionSchedulePage() {
className="inline-flex items-center gap-2 rounded-lg border border-emerald-500/70 bg-white px-4 py-2 text-sm font-semibold text-emerald-600 shadow-sm transition hover:bg-emerald-50 dark:border-emerald-500/50 dark:bg-slate-800 dark:text-emerald-400 dark:hover:bg-emerald-500/10"
>
<Download sx={{ fontSize: 16 }} />
匯出計劃/物料需求Excel
{t("exportPlan")}
</button>
<button
type="button"
@@ -652,7 +662,7 @@ export default function ProductionSchedulePage() {
) : (
<Download sx={{ fontSize: 16 }} />
)}
匯出送貨單數量
{t("exportDoQty")}
</button>
<button
type="button"
@@ -665,7 +675,7 @@ export default function ProductionSchedulePage() {
) : (
<ShowChart sx={{ fontSize: 16 }} />
)}
預測排期
{t("forecast")}
</button>
</>
}
@@ -675,7 +685,7 @@ export default function ProductionSchedulePage() {
{/* Query Bar */}
<div className="app-search-criteria mb-4 flex flex-wrap items-center gap-2 p-4">
<label className="sr-only" htmlFor="ps-search-date">
生產日期
{t("productionDate")}
</label>
<input
id="ps-search-date"
@@ -690,7 +700,7 @@ export default function ProductionSchedulePage() {
className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600"
>
<Search sx={{ fontSize: 16 }} />
搜索
{t("search")}
</button>
</div>

@@ -701,16 +711,16 @@ export default function ProductionSchedulePage() {
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
<tr>
<th className="w-[100px] px-4 py-3 text-center font-bold text-slate-700 dark:text-slate-200">
詳細
{t("detail")}
</th>
<th className="px-4 py-3 font-bold text-slate-700 dark:text-slate-200">
生產日期
{t("productionDate")}
</th>
<th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
預計生產數
{t("estProdQty")}
</th>
<th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
成品款數
{t("fgCount")}
</th>
</tr>
</thead>
@@ -761,8 +771,10 @@ export default function ProductionSchedulePage() {
<div className="flex items-center gap-2 border-b border-slate-200 bg-blue-500 px-4 py-3 text-white dark:border-slate-700">
<FormatListNumbered sx={{ fontSize: 20, flexShrink: 0 }} />
<h2 id="detail-title" className="text-lg font-semibold">
排期詳細: {selectedPs?.id} (
{formatBackendDate(selectedPs?.produceAt)})
{t("detailTitle", {
id: selectedPs?.id,
date: formatBackendDate(selectedPs?.produceAt),
})}
</h2>
</div>
<div className="max-h-[65vh] overflow-auto">
@@ -770,34 +782,34 @@ export default function ProductionSchedulePage() {
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
<tr>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
工單號
{t("jobOrder")}
</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
物料編號
{t("itemCode")}
</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
物料名稱
{t("itemName")}
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
每日平均出貨量
{t("avgDailyOut")}
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
出貨前預計存貨量
{t("stockBeforeOut")}
</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
單位
{t("unit")}
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
可用日
{t("daysOfCover")}
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
生產量(批)
{t("batchQty")}
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
預計生產包數
{t("estPacks")}
</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">
優先度
{t("priority")}
</th>
</tr>
</thead>
@@ -856,7 +868,7 @@ export default function ProductionSchedulePage() {
) : (
<Hub sx={{ fontSize: 16 }} />
)}
自動生成工單
{t("generateJobs")}
</button>
<button
type="button"
@@ -864,7 +876,7 @@ export default function ProductionSchedulePage() {
disabled={isGenerating}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
>
關閉
{t("close")}
</button>
</div>
</div>
@@ -884,7 +896,7 @@ export default function ProductionSchedulePage() {
/>
<div className="relative z-10 w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800 sm:max-w-md">
<h3 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
準備生成預計排期
{t("forecastDialogTitle")}
</h3>
<div className="flex flex-col gap-4">
<div>
@@ -892,7 +904,7 @@ export default function ProductionSchedulePage() {
htmlFor="forecast-start"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
開始日期
{t("startDate")}
</label>
<input
id="forecast-start"
@@ -908,7 +920,7 @@ export default function ProductionSchedulePage() {
htmlFor="forecast-days"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
排期日數
{t("scheduleDays")}
</label>
<input
id="forecast-days"
@@ -936,7 +948,7 @@ export default function ProductionSchedulePage() {
onClick={() => setIsForecastDialogOpen(false)}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
>
取消
{t("cancel")}
</button>
<button
type="button"
@@ -951,7 +963,7 @@ export default function ProductionSchedulePage() {
) : (
<ShowChart sx={{ fontSize: 16 }} />
)}
計算預測排期
{t("calculateForecast")}
</button>
</div>
</div>
@@ -971,10 +983,10 @@ export default function ProductionSchedulePage() {
/>
<div className="relative z-10 w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800">
<h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white">
匯出送貨單數量
{t("exportDoQty")}
</h3>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
選擇日期範圍(僅含具 BOM 的物料,數量為庫存單位)
{t("exportDoQtyHint")}
</p>
<div className="flex flex-col gap-4">
<div>
@@ -982,7 +994,7 @@ export default function ProductionSchedulePage() {
htmlFor="do-qty-export-from"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
開始日期
{t("startDate")}
</label>
<input
id="do-qty-export-from"
@@ -997,7 +1009,7 @@ export default function ProductionSchedulePage() {
htmlFor="do-qty-export-to"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
結束日期
{t("endDate")}
</label>
<input
id="do-qty-export-to"
@@ -1015,7 +1027,7 @@ export default function ProductionSchedulePage() {
disabled={isDoQtyExporting}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
>
取消
{t("cancel")}
</button>
<button
type="button"
@@ -1032,7 +1044,7 @@ export default function ProductionSchedulePage() {
) : (
<Download sx={{ fontSize: 16 }} />
)}
匯出
{t("export")}
</button>
</div>
</div>
@@ -1052,16 +1064,16 @@ export default function ProductionSchedulePage() {
/>
<div className="relative z-10 w-full max-w-xs rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800">
<h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white">
匯出排期/物料用量預計
{t("exportPlanDialogTitle")}
</h3>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
選擇要匯出的起始日期
{t("exportPlanHint")}
</p>
<label
htmlFor="export-from"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
起始日期
{t("fromDate")}
</label>
<input
id="export-from"
@@ -1077,7 +1089,7 @@ export default function ProductionSchedulePage() {
onClick={() => setIsExportDialogOpen(false)}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
>
取消
{t("cancel")}
</button>
<button
type="button"
@@ -1090,7 +1102,7 @@ export default function ProductionSchedulePage() {
) : (
<Download sx={{ fontSize: 16 }} />
)}
匯出
{t("export")}
</button>
</div>
</div>
@@ -1115,7 +1127,7 @@ export default function ProductionSchedulePage() {
id="settings-panel-title"
className="text-lg font-semibold text-slate-900 dark:text-white"
>
排期設定
{t("scheduleSettings")}
</h2>
<div className="flex items-center gap-2">
{settingsTab === "items" && (
@@ -1131,18 +1143,18 @@ export default function ProductionSchedulePage() {
) : (
<Refresh sx={{ fontSize: 14 }} />
)}
刷新系統庫存
{t("refreshStock")}
</button>
<button
type="button"
onClick={handleExportFakeOnHand}
className="inline-flex items-center gap-1 rounded border border-amber-500/80 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700 shadow-sm hover:bg-amber-100 dark:border-amber-400/80 dark:bg-slate-800 dark:text-amber-300 dark:hover:bg-amber-500/20"
>
匯出排期庫存 Excel
{t("exportScheduleStock")}
</button>
<label className="inline-flex cursor-pointer items-center gap-1 rounded border border-blue-500/70 bg-white px-3 py-1 text-xs font-semibold text-blue-600 shadow-sm hover:bg-blue-50 dark:border-blue-500/50 dark:bg-slate-800 dark:text-blue-400 dark:hover:bg-blue-500/10">
<span className="inline-flex h-2 w-2 rounded-full bg-amber-500" />
<span className="text-amber-700 dark:text-amber-300">匯入排期庫存 Excel(覆蓋設定)</span>
<span className="text-amber-700 dark:text-amber-300">{t("importScheduleStock")}</span>
<input
type="file"
accept=".xlsx,.xls"
@@ -1163,7 +1175,7 @@ export default function ProductionSchedulePage() {
onClick={() => setIsDailyOutPanelOpen(false)}
className="rounded p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 dark:hover:bg-slate-600 dark:hover:text-slate-200"
>
關閉
{t("close")}
</button>
</div>
</div>
@@ -1177,7 +1189,7 @@ export default function ProductionSchedulePage() {
: "border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400"
}`}
>
物料設定
{t("tabItems")}
</button>
<button
type="button"
@@ -1188,12 +1200,12 @@ export default function ProductionSchedulePage() {
: "border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400"
}`}
>
排期規則
{t("tabRules")}
</button>
</div>
{settingsTab === "items" && (
<p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400">
預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。
{t("itemsHint")}
</p>
)}
{settingsTab === "rules" ? (
@@ -1215,16 +1227,16 @@ export default function ProductionSchedulePage() {
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
<tr>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">單位</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">庫存</th>
<th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期庫存</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">過去平均出貨量</th>
<th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期每天出貨量</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">類別 A</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">類別 B</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">類別 C</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">{t("itemCode")}</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">{t("itemName")}</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">{t("unit")}</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">{t("onHand")}</th>
<th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">{t("scheduleStock")}</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">{t("pastAvgOut")}</th>
<th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">{t("dailyOutOverride")}</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">{t("categoryA")}</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">{t("categoryB")}</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">{t("categoryC")}</th>
</tr>
</thead>
<tbody>
@@ -1284,6 +1296,7 @@ function DailyOutRow({
fakeOnHandClearing: boolean;
formatNum: (n: any) => string;
}) {
const { t } = useTranslation("scheduling");
const [editQty, setEditQty] = useState<string>(
row.dailyQty != null ? String(row.dailyQty) : ""
);
@@ -1335,7 +1348,7 @@ function DailyOutRow({
type="button"
disabled={fakeOnHandClearing}
onClick={() => onClearFakeOnHand(row.itemCode)}
title="清除設定排期庫存"
title={t("clearScheduleStock")}
className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200"
>
{fakeOnHandClearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
@@ -1365,7 +1378,7 @@ function DailyOutRow({
type="button"
disabled={clearing}
onClick={() => onClear(row.itemCode)}
title="清除設定排期每天出貨量"
title={t("clearDailyOut")}
className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200"
>
{clearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}


+ 4
- 3
src/app/api/chart/client.ts Visa fil

@@ -1,3 +1,4 @@
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

@@ -577,7 +578,7 @@ export async function fetchPlannedOutputByDateAndItem(
}

/** Warehouse / lane filter for staff delivery performance chart (delivery_order_pick_order.store_id). */
export type StaffDeliveryPerformanceStoreFilter = "all" | "2/F" | "4/F" | "null_only";
export type StaffDeliveryPerformanceStoreFilter = "all" | typeof FLOOR_A | typeof FLOOR_B | "null_only";

/** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */
export async function fetchStaffDeliveryPerformance(
@@ -591,8 +592,8 @@ export async function fetchStaffDeliveryPerformance(
if (endDate) p.set("endDate", endDate);
(staffNos ?? []).forEach((no) => p.append("staffNo", no));
if (storeFilter === "null_only") p.set("storeIdNull", "true");
else if (storeFilter === "2/F") p.set("storeId", "2/F");
else if (storeFilter === "4/F") p.set("storeId", "4/F");
else if (storeFilter === FLOOR_A) p.set("storeId", FLOOR_A);
else if (storeFilter === FLOOR_B) p.set("storeId", FLOOR_B);
const q = p.toString();
const res = await clientAuthFetch(
q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance`


+ 2
- 1
src/app/api/do/actions.tsx Visa fil

@@ -1,4 +1,5 @@
"use server";
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { BASE_API_URL } from "@/config/api";
// import { ServerFetchError, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { revalidateTag } from "next/cache";
@@ -85,7 +86,7 @@ export interface ReleaseDoResponse {


export interface AssignByStoreRequest {
storeId: string; // "2/F" or "4/F"
storeId: string; // FLOOR_A or FLOOR_B
assignTo: number;
}



+ 3
- 2
src/app/api/doworkbench/actions.ts Visa fil

@@ -1,5 +1,6 @@
"use server";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { revalidateTag } from "next/cache";
import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
@@ -244,7 +245,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelection(
storeId?: string,
truck?: string,
releaseType?: string,
/** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */
/** Optional `${FLOOR_A_KEY}`/`${FLOOR_B_KEY}`: Truck X split by DO supplier preferred floor (storeId stays null). */
floor?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();
@@ -266,7 +267,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
truck?: string,
requiredDeliveryDate?: string,
releaseType?: string,
/** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */
/** Optional `${FLOOR_A_KEY}`/`${FLOOR_B_KEY}`: Truck X split by DO supplier preferred floor (storeId stays null). */
floor?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();


+ 2
- 1
src/app/api/jo/actions.ts Visa fil

@@ -1,5 +1,6 @@
"use server";

import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import { cache } from 'react';
import { Pageable, ServerFetchError, serverFetchBlob, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { JobOrder, JoStatus, Machine, Operator } from ".";
@@ -133,7 +134,7 @@ export interface PrintPickRecordRequest{
pickOrderId: number;
printerId: number;
printQty: number;
floor?: "2F" | "3F" | "4F" | "ALL";
floor?: typeof FLOOR_A_KEY | typeof FLOOR_D_KEY | typeof FLOOR_B_KEY | "ALL";
plasticBoxCartonQty?: number;
plasticBoxCartonQty2f?: number;
plasticBoxCartonQty3f?: number;


+ 2
- 1
src/app/api/pickOrder/actions.ts Visa fil

@@ -1,4 +1,5 @@
"use server";
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { BASE_API_URL } from "@/config/api";
// import { ServerFetchError, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { revalidateTag } from "next/cache";
@@ -333,7 +334,7 @@ export interface DoPickOrderDetail {
}
export interface AutoAssignReleaseByStoreRequest {
userId: number;
storeId: string; // "2/F" | "4/F"
storeId: string; // FLOOR_A | FLOOR_B
}
export interface UpdateDoPickOrderHideStatusRequest {
id: number;


+ 3
- 2
src/app/api/settings/deliveryOrderFloor/constants.ts Visa fil

@@ -1,5 +1,6 @@
import { SETTING_SUPPLIERS_A, SETTING_SUPPLIERS_B } from "@/config/demoFloors";
/** `settings.name`:逗號分隔 supplier **code**(須與後端讀取邏輯一致)。 */
export const SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F";
export const SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F";
export const SETTING_DO_FLOOR_SUPPLIERS_2F = SETTING_SUPPLIERS_A;
export const SETTING_DO_FLOOR_SUPPLIERS_4F = SETTING_SUPPLIERS_B;

export const SETTING_DO_FLOOR_CATEGORY = "DO_FLOOR";

+ 3
- 2
src/app/utils/formatUtil.ts Visa fil

@@ -1,3 +1,4 @@
import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import dayjs, { ConfigType, Dayjs } from "dayjs";
import { Uom } from "../api/settings/uom";
import {
@@ -249,7 +250,7 @@ export const normalizeStoreId = (storeId: string | number | String | Number | nu
const storeIdStr = typeof storeId === 'string' || storeId instanceof String
? String(storeId)
: String(storeId);
if (storeIdStr === "2" || storeIdStr === "2F") return "2F";
if (storeIdStr === "4" || storeIdStr === "4F") return "4F";
if (storeIdStr === "2" || storeIdStr === FLOOR_A_KEY) return FLOOR_A_KEY;
if (storeIdStr === "4" || storeIdStr === FLOOR_B_KEY) return FLOOR_B_KEY;
return storeIdStr;
};

+ 4
- 3
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Box,
@@ -449,8 +450,8 @@ const TruckScheduleDashboard: React.FC = () => {
displayEmpty
>
<MenuItem value="">{t("All Stores")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="4/F">4/F</MenuItem>
<MenuItem value={FLOOR_A}>{FLOOR_A}</MenuItem>
<MenuItem value={FLOOR_B}>{FLOOR_B}</MenuItem>
</Select>
</FormControl>

@@ -535,7 +536,7 @@ const TruckScheduleDashboard: React.FC = () => {
<Chip
label={row.storeId || '-'}
size="small"
color={row.storeId === '2/F' ? 'primary' : 'secondary'}
color={row.storeId === FLOOR_A ? 'primary' : 'secondary'}
/>
</TableCell>
<TableCell>


+ 17
- 16
src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import EditOutlined from "@mui/icons-material/EditOutlined";
@@ -57,7 +58,7 @@ function formatBracketList(codesCsv: string): string {
return `[${n.split(",").join(", ")}]`;
}

type EditFloor = "2F" | "4F";
type EditFloor = typeof FLOOR_A_KEY | typeof FLOOR_B_KEY;

type FloorRow = { code: string; name: string };

@@ -97,7 +98,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
const [codes4F, setCodes4F] = useState("");

const [editOpen, setEditOpen] = useState(false);
const [editFloor, setEditFloor] = useState<EditFloor>("2F");
const [editFloor, setEditFloor] = useState<EditFloor>(FLOOR_A_KEY);
const [dialogSaving, setDialogSaving] = useState(false);
const [comboLoading, setComboLoading] = useState(false);
const [supplierCombo, setSupplierCombo] = useState<ShopComboRow[] | null>(null);
@@ -130,8 +131,8 @@ const DeliveryOrderFloorSettings: React.FC = () => {
const display2F = useMemo(() => formatBracketList(codes2F), [codes2F]);
const display4F = useMemo(() => formatBracketList(codes4F), [codes4F]);

const currentDraftRows = editFloor === "2F" ? draftRows2F : draftRows4F;
const setCurrentDraftRows = editFloor === "2F" ? setDraftRows2F : setDraftRows4F;
const currentDraftRows = editFloor === FLOOR_A_KEY ? draftRows2F : draftRows4F;
const setCurrentDraftRows = editFloor === FLOOR_A_KEY ? setDraftRows2F : setDraftRows4F;

const openEdit = async (floor: EditFloor) => {
setEditFloor(floor);
@@ -172,12 +173,12 @@ const DeliveryOrderFloorSettings: React.FC = () => {
setError(null);
setSuccess(null);
try {
const rows = editFloor === "2F" ? draftRows2F : draftRows4F;
const rows = editFloor === FLOOR_A_KEY ? draftRows2F : draftRows4F;
const normalized = normalizeCodesCsv(floorRowsToCsv(rows));
const key =
editFloor === "2F" ? SETTING_DO_FLOOR_SUPPLIERS_2F : SETTING_DO_FLOOR_SUPPLIERS_4F;
editFloor === FLOOR_A_KEY ? SETTING_DO_FLOOR_SUPPLIERS_2F : SETTING_DO_FLOOR_SUPPLIERS_4F;
await postSettingClient(key, normalized);
if (editFloor === "2F") setCodes2F(normalized);
if (editFloor === FLOOR_A_KEY) setCodes2F(normalized);
else setCodes4F(normalized);
setSuccess(t("Saved"));
setEditOpen(false);
@@ -237,7 +238,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
return;
}
const canonical = hit.code.trim();
const otherRows = editFloor === "2F" ? draftRows4F : draftRows2F;
const otherRows = editFloor === FLOOR_A_KEY ? draftRows4F : draftRows2F;
if (currentDraftRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) {
setAddError(t("Duplicate in floor"));
return;
@@ -286,7 +287,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
}}
>
<Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
{t("2F supplier")}
{t(FLOOR_A_KEY + " supplier")}
</Typography>
<Typography
component="span"
@@ -296,10 +297,10 @@ const DeliveryOrderFloorSettings: React.FC = () => {
{display2F}
</Typography>
<IconButton
aria-label={t("Edit 2F")}
aria-label={t("Edit " + FLOOR_A_KEY)}
color="primary"
edge="end"
onClick={() => void openEdit("2F")}
onClick={() => void openEdit(FLOOR_A_KEY)}
size="small"
>
<EditOutlined />
@@ -319,7 +320,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
}}
>
<Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
{t("4F supplier")}
{t(FLOOR_B_KEY + " supplier")}
</Typography>
<Typography
component="span"
@@ -329,10 +330,10 @@ const DeliveryOrderFloorSettings: React.FC = () => {
{display4F}
</Typography>
<IconButton
aria-label={t("Edit 4F")}
aria-label={t("Edit " + FLOOR_B_KEY)}
color="primary"
edge="end"
onClick={() => void openEdit("4F")}
onClick={() => void openEdit(FLOOR_B_KEY)}
size="small"
>
<EditOutlined />
@@ -361,8 +362,8 @@ const DeliveryOrderFloorSettings: React.FC = () => {
onChange={onFloorSelectChange}
disabled={dialogSaving || comboLoading}
>
<MenuItem value="2F">2/F</MenuItem>
<MenuItem value="4F">4/F</MenuItem>
<MenuItem value={FLOOR_A_KEY}>{FLOOR_A}</MenuItem>
<MenuItem value={FLOOR_B_KEY}>{FLOOR_B}</MenuItem>
</Select>
</FormControl>
<Button


+ 9
- 8
src/components/DoDetail/DoDetail.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import type { DoDetail as DoDetailType } from "@/app/api/do/actions";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
@@ -203,20 +204,20 @@ const DoDetail: React.FC<Props> = ({
<Button
variant="contained"
color="primary"
onClick={() => handleAssignByStore("2/F")}
onClick={() => handleAssignByStore(FLOOR_A)}
disabled={isAssigning}
sx={{ minWidth: 120 }}
>
{t("Assign 2/F")}
{t("Assign " + FLOOR_A)}
</Button>
<Button
variant="contained"
color="secondary"
onClick={() => handleAssignByStore("4/F")}
onClick={() => handleAssignByStore(FLOOR_B)}
disabled={isAssigning}
sx={{ minWidth: 120 }}
>
{t("Assign 4/F")}
{t("Assign " + FLOOR_B)}
</Button>
</Stack>
@@ -224,20 +225,20 @@ const DoDetail: React.FC<Props> = ({
<Button
variant="outlined"
color="primary"
onClick={() => handleReleaseByStore("2/F")}
onClick={() => handleReleaseByStore(FLOOR_A)}
disabled={isAssigning}
sx={{ minWidth: 120 }}
>
{t("Release 2/F")}
{t("Release " + FLOOR_A)}
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => handleReleaseByStore("4/F")}
onClick={() => handleReleaseByStore(FLOOR_B)}
disabled={isAssigning}
sx={{ minWidth: 120 }}
>
{t("Release 4/F")}
{t("Release " + FLOOR_B)}
</Button>
</Stack>
</Box>


+ 10
- 9
src/components/DoSearch/DoSearch.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { DoResult } from "@/app/api/do";
import {
DoSearchAll,
@@ -64,8 +65,8 @@ type Props = {
};
type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>;
type SearchParamNames = keyof SearchBoxInputs;
type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA" | "REPLENISH";
type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string };
type DoSearchTab = typeof FLOOR_A_KEY | typeof FLOOR_B_KEY | "TRUCK_X" | "ETRA" | "REPLENISH";
type TabFilter = { floor: typeof FLOOR_A_KEY | typeof FLOOR_B_KEY | null; isExtra: boolean; forceTruckKeyword?: string };

// put all this into a new component
// ConsoDoForm
@@ -103,7 +104,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isWorkbench, setIsWorkbench] = useState(false);
const [activeTab, setActiveTab] = useState<DoSearchTab>("2F");
const [activeTab, setActiveTab] = useState<DoSearchTab>(FLOOR_A_KEY);
const [searchBoxResetKey, setSearchBoxResetKey] = useState(0);
const [pagingController, setPagingController] = useState({
pageNum: 1,
@@ -326,10 +327,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea

const resolveTabFilter = useCallback((tab: DoSearchTab): TabFilter => {
switch (tab) {
case "2F":
return { floor: "2F", isExtra: false };
case "4F":
return { floor: "4F", isExtra: false };
case FLOOR_A_KEY:
return { floor: FLOOR_A_KEY, isExtra: false };
case FLOOR_B_KEY:
return { floor: FLOOR_B_KEY, isExtra: false };
case "TRUCK_X":
return { floor: null, isExtra: false, forceTruckKeyword: "x" };
case "ETRA":
@@ -802,8 +803,8 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
onChange={handleTabChange}
variant="scrollable"
>
<Tab value="2F" label="2/F" />
<Tab value="4F" label="4/F" />
<Tab value={FLOOR_A_KEY} label={FLOOR_A} />
<Tab value={FLOOR_B_KEY} label={FLOOR_B} />
<Tab value="TRUCK_X" label={t("Truck X")} />
<Tab value="ETRA" label={t("Etra")} />
<Tab value="REPLENISH" label={t("Replenishment")} />


+ 3
- 2
src/components/DoSearchWorkbench/DoSearchWorkbench.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { DoResult } from "@/app/api/do";
import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo } from "@/app/api/do/actions";
import {
@@ -150,8 +151,8 @@ const DoSearchWorkbench: React.FC<Props> = ({
paramName: "floor",
type: "select-labelled",
options: [
{ label: "2F", value: "2F" },
{ label: "4F", value: "4F" },
{ label: FLOOR_A_KEY, value: FLOOR_A_KEY },
{ label: FLOOR_B_KEY, value: FLOOR_B_KEY },
],
},
{


+ 2
- 1
src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { useEffect, useState } from "react";
import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
@@ -114,7 +115,7 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => {
<TextField
select
fullWidth
label={t("2/F or 4/F")}
label={t(FLOOR_A + " or " + FLOOR_B)}
value={storeId}
onChange={(e) => onStoreChange(e.target.value)}
>


+ 4
- 3
src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Box,
Button,
@@ -41,8 +42,8 @@ type Props = {

function floorLabel(storeId: string | null | undefined, t: (k: string) => string): string {
const s = (storeId ?? "").trim().replace(/\//g, "").toUpperCase();
if (s === "2F") return "2/F";
if (s === "4F") return "4/F";
if (s === FLOOR_A_KEY) return FLOOR_A;
if (s === FLOOR_B_KEY) return FLOOR_B;
if (!s) return t("Truck X");
return storeId ?? "";
}
@@ -58,7 +59,7 @@ function laneSecondary(c: WorkbenchMergeTicketCandidate, t: (k: string) => strin
const dep = (c.truckDepartureTime ?? "").trim();
const lane = (c.truckLanceCode ?? "").trim();
const floor = floorLabel(c.storeId, t);
const is4F = (c.storeId ?? "").trim().replace(/\//g, "").toUpperCase() === "4F";
const is4F = (c.storeId ?? "").trim().replace(/\//g, "").toUpperCase() === FLOOR_B_KEY;
const seq =
is4F && c.loadingSequence != null
? `${t("Loading sequence")} ${c.loadingSequence} · `


+ 24
- 23
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel } from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -53,7 +54,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const [selectedStore, setSelectedStore] = useState<string>("2/F");
const [selectedStore, setSelectedStore] = useState<string>(FLOOR_A);
const [selectedTruck, setSelectedTruck] = useState<string>("");
const [modalOpen, setModalOpen] = useState(false);
const [truckCounts2F, setTruckCounts2F] = useState<{ truck: string; count: number }[]>([]);
@@ -92,7 +93,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
[ticketFloor],
);
const defaultTruckCount =
(ticketFloor === "2/F" ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0;
(ticketFloor === FLOOR_A ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0;
const etraEnterInFlightRef = useRef(false);
const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false);

@@ -167,8 +168,8 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
else if (selectedDate === "tomorrow") dateParam = dayjs().add(1, "day").format("YYYY-MM-DD");
else if (selectedDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD");
const [s2, s4] = await Promise.all([
fetchWorkbenchStoreLaneSummary("2/F", dateParam, releaseType),
fetchWorkbenchStoreLaneSummary("4/F", dateParam, releaseType),
fetchWorkbenchStoreLaneSummary(FLOOR_A, dateParam, releaseType),
fetchWorkbenchStoreLaneSummary(FLOOR_B, dateParam, releaseType),
]);
setSummary2F(s2);
setSummary4F(s4);
@@ -217,8 +218,8 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
startFullTimer();
try {
const [list2F, list4F] = await Promise.all([
fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, "2/F"),
fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, "4/F"),
fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, FLOOR_A),
fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, FLOOR_B),
]);
const groupByTruck = (list: { truckLanceCode?: string | null }[]) => {
const map: Record<string, number> = {};
@@ -441,8 +442,8 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
<FormControl fullWidth size="small">
<InputLabel id="ticket-floor-select-label">{t("Floor ticket")}</InputLabel>
<Select labelId="ticket-floor-select-label" value={ticketFloor} label={t("Floor ticket")} onChange={(e) => patchLanePanelPrefs({ ticketFloor: e.target.value as WorkbenchLanePanelPrefs["ticketFloor"] })}>
<MenuItem value="2/F">{t("2F ticket")}</MenuItem>
<MenuItem value="4/F">{t("4F ticket")}</MenuItem>
<MenuItem value={FLOOR_A}>{t(FLOOR_A_KEY + " ticket")}</MenuItem>
<MenuItem value={FLOOR_B}>{t(FLOOR_B_KEY + " ticket")}</MenuItem>
</Select>
</FormControl>
</Box>
@@ -481,10 +482,10 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({

{!inEtraUi ? (
<Grid container spacing={2}>
{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>2/F</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>{FLOOR_A}</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{isLoadingSummary ? <Typography variant="caption">{t("Loading...")}</Typography> : !summary2F?.rows?.length ? renderNoEntry() : (
<Grid container spacing={1}>
@@ -494,7 +495,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: { sm: 60 } }}>{row.truckDepartureTime}</Typography>
<Stack direction="row" flexWrap="wrap" sx={{ gap: 1 }}>
{row.lanes.map((lane) => (
<Button key={`${row.truckDepartureTime}-${lane.truckLanceCode}`} variant="outlined" disabled={lane.unassigned === 0 || isAssigning} onClick={() => void handleLaneButtonClick("2/F", row.truckDepartureTime, lane.truckLanceCode, null, selectedDate, lane.unassigned, lane.total)}>
<Button key={`${row.truckDepartureTime}-${lane.truckLanceCode}`} variant="outlined" disabled={lane.unassigned === 0 || isAssigning} onClick={() => void handleLaneButtonClick(FLOOR_A, row.truckDepartureTime, lane.truckLanceCode, null, selectedDate, lane.unassigned, lane.total)}>
{`${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`}
</Button>
))}
@@ -509,10 +510,10 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
</Grid>
)}

{ticketFloor === "4/F" && (
{ticketFloor === FLOOR_B && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>4/F</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>{FLOOR_B}</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{isLoadingSummary ? <Typography variant="caption">{t("Loading...")}</Typography> : !truckGroups4F.length ? renderNoEntry() : (
<Grid container spacing={1}>
@@ -524,7 +525,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
{slots.map((slot) => {
const handlerName = (slot.lane.handlerName ?? "").trim();
return (
<Button key={`${truckLanceCode}-${slot.sequenceIndex}-${slot.truckDepartureTime}`} variant="outlined" disabled={slot.lane.unassigned === 0 || isAssigning} onClick={() => void handleLaneButtonClick("4/F", slot.truckDepartureTime, slot.lane.truckLanceCode, slot.lane.loadingSequence ?? null, selectedDate, slot.lane.unassigned, slot.lane.total)}>
<Button key={`${truckLanceCode}-${slot.sequenceIndex}-${slot.truckDepartureTime}`} variant="outlined" disabled={slot.lane.unassigned === 0 || isAssigning} onClick={() => void handleLaneButtonClick(FLOOR_B, slot.truckDepartureTime, slot.lane.truckLanceCode, slot.lane.loadingSequence ?? null, selectedDate, slot.lane.unassigned, slot.lane.total)}>
{`${t("Loading sequence n", { n: slot.lane.loadingSequence ?? slot.sequenceIndex })} (${slot.lane.unassigned}/${slot.lane.total})${handlerName ? ` ${handlerName}` : ""}`}
</Button>
);
@@ -585,21 +586,21 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
</Box>
</Grid>

{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>2/F</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>{FLOOR_A}</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{truckCounts2F.length === 0 ? renderNoEntry() : (
<Grid container spacing={1}>
{truckCounts2F.map(({ truck, count }) => (
<Grid item xs={6} sm={4} md={3} key={`2F-${truck}`} sx={{ display: "flex" }}>
<Grid item xs={6} sm={4} md={3} key={`${FLOOR_A_KEY}-${truck}`} sx={{ display: "flex" }}>
<Button
variant="outlined"
onClick={() => {
clearModalEtraContext();
setIsDefaultTruck(false);
setSelectedStore("2/F");
setSelectedStore(FLOOR_A);
setSelectedTruck(truck);
setModalOpen(true);
}}
@@ -616,21 +617,21 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
</Grid>
)}

{ticketFloor === "4/F" && (
{ticketFloor === FLOOR_B && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>4/F</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>{FLOOR_B}</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{truckCounts4F.length === 0 ? renderNoEntry() : (
<Grid container spacing={1}>
{truckCounts4F.map(({ truck, count }) => (
<Grid item xs={6} sm={4} md={3} key={`4F-${truck}`} sx={{ display: "flex" }}>
<Grid item xs={6} sm={4} md={3} key={`${FLOOR_B_KEY}-${truck}`} sx={{ display: "flex" }}>
<Button
variant="outlined"
onClick={() => {
clearModalEtraContext();
setIsDefaultTruck(false);
setSelectedStore("4/F");
setSelectedStore(FLOOR_B);
setSelectedTruck(truck);
setModalOpen(true);
}}
@@ -694,7 +695,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
(lane.truckLanceCode ?? "").trim() === "車線-X" ||
(lane.truckLanceCode ?? "").trim() === "Truck X";
const floorKey = sid.replace(/\//g, "").toUpperCase();
const is4F = floorKey === "4F";
const is4F = floorKey === FLOOR_B_KEY;
const labelCore =
isTruckX
? `${t("車線-X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})`


+ 4
- 3
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import {
Box,
Button,
@@ -742,8 +743,8 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
.toUpperCase()
.replace(/\s/g, "");
const floorKey = storeId.replace(/\//g, "");
if (floorKey === "2F") return "Label機 2F A+B";
if (floorKey === "4F") return "Label機 4F 乾貨 C, D";
if (floorKey === FLOOR_A_KEY) return LABEL_A;
if (floorKey === FLOOR_B_KEY) return LABEL_B;
return undefined;
}, [fgPickOrders]);

@@ -1037,7 +1038,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
.replace(/\//g, '')
.replace(/\s/g, '');
const pickOrderLinesForDisplay =
doFloorKey === '2F'
doFloorKey === FLOOR_A_KEY
? [...(mergedPickOrder.pickOrderLines || [])].sort((a: any, b: any) => {
const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999;
const bo = b.itemOrder != null ? Number(b.itemOrder) : 999999;


+ 3
- 1
src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx Visa fil

@@ -5,6 +5,7 @@
* 「掃碼提貨」 per listed lot row (parent calls `workbenchScanPick` with `inventoryLotLineId`).
*/

import { LOC_PREFIX } from "@/config/demoFloors";
import React, {
useCallback,
useEffect,
@@ -455,7 +456,8 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
return availableLots.filter((lot) => {
// 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號
if (lot._scanned) return true;
return String(lot.warehouseCode ?? "").startsWith(prefix);
const code = String(lot.warehouseCode ?? "");
return code.startsWith(prefix) || code.startsWith(LOC_PREFIX);
});
}, [availableLots, warehouseCodePrefixFilter]);



+ 3
- 2
src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
@@ -278,8 +279,8 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
displayEmpty
>
<MenuItem value="">{t("All Floors")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="4/F">4/F</MenuItem>
<MenuItem value={FLOOR_A}>{FLOOR_A}</MenuItem>
<MenuItem value={FLOOR_B}>{FLOOR_B}</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 150 }} size="small">


+ 4
- 3
src/components/DoWorkbench/workbenchLanePanelPrefs.ts Visa fil

@@ -1,5 +1,6 @@
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
export type WorkbenchLaneDateKey = "today" | "tomorrow" | "dayAfterTomorrow";
export type WorkbenchLaneFloor = "2/F" | "4/F";
export type WorkbenchLaneFloor = typeof FLOOR_A | typeof FLOOR_B;

/** Tab 0 lane assignment filters — lifted to DoWorkbenchTabs so they survive tab switches. */
export type WorkbenchLanePanelPrefs = {
@@ -10,7 +11,7 @@ export type WorkbenchLanePanelPrefs = {

export const DEFAULT_WORKBENCH_LANE_PANEL_PREFS: WorkbenchLanePanelPrefs = {
selectedDate: "today",
ticketFloor: "2/F",
ticketFloor: FLOOR_A,
releaseType: "batch",
};

@@ -19,5 +20,5 @@ export function isWorkbenchLaneDateKey(v: string): v is WorkbenchLaneDateKey {
}

export function isWorkbenchLaneFloor(v: string): v is WorkbenchLaneFloor {
return v === "2/F" || v === "4/F";
return v === FLOOR_A || v === FLOOR_B;
}

+ 7
- 6
src/components/FinishedGoodSearch/FGPickOrderCard.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel, Card, CardContent } from "@mui/material";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -28,8 +29,8 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned }) =>
setIsLoadingSummary(true);
try {
const [s2, s4] = await Promise.all([
fetchStoreLaneSummary("2/F"),
fetchStoreLaneSummary("4/F")
fetchStoreLaneSummary(FLOOR_A),
fetchStoreLaneSummary(FLOOR_B)
]);
setSummary2F(s2);
setSummary4F(s4);
@@ -169,7 +170,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned }) =>
color: '#1976d2'
}}
>
2/F
{FLOOR_A}
</Typography>
</Box>
@@ -232,7 +233,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned }) =>
variant="outlined"
size="medium"
disabled={item.lane.unassigned === 0 || isAssigning}
onClick={() => handleAssignByLane("2/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)}
onClick={() => handleAssignByLane(FLOOR_A, item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)}
sx={{
flex: 1,
fontSize: '1.1rem',
@@ -282,7 +283,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned }) =>
color: '#1976d2'
}}
>
4/F
{FLOOR_B}
</Typography>
</Box>
@@ -345,7 +346,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned }) =>
variant="outlined"
size="medium"
disabled={item.lane.unassigned === 0 || isAssigning}
onClick={() => handleAssignByLane("4/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)}
onClick={() => handleAssignByLane(FLOOR_B, item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)}
sx={{
flex: 1,
fontSize: '1.1rem',


+ 3
- 2
src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
/**
* 權限說明(與全站一致):
* - 登入後 JWT / session 帶有 `abilities: string[]`(見 config/authConfig、authorities.ts)。
@@ -303,8 +304,8 @@ const FGPickOrderTicketReleaseTable: React.FC = () => {
displayEmpty
>
<MenuItem value="">{t("All Floors")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="4/F">4/F</MenuItem>
<MenuItem value={FLOOR_A}>{FLOOR_A}</MenuItem>
<MenuItem value={FLOOR_B}>{FLOOR_B}</MenuItem>
</Select>
</FormControl>



+ 20
- 19
src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
@@ -29,7 +30,7 @@ import {
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { useTranslation } from "react-i18next";

type FloorFilter = "all" | "2/F" | "4/F";
type FloorFilter = "all" | typeof FLOOR_A | typeof FLOOR_B;
type BreakdownDimension = "lane" | "shop" | "floor";

type DailySummaryRow = {
@@ -361,10 +362,10 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
total: 0,
};

if (record.storeId === "2/F") {
if (record.storeId === FLOOR_A) {
current.floor2F += cartonQty;
}
if (record.storeId === "4/F") {
if (record.storeId === FLOOR_B) {
current.floor4F += cartonQty;
}
if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) {
@@ -390,18 +391,18 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
let truckX = 0;
filteredRecords.forEach((record) => {
const cartonQty = Number(record.numberOfCartons ?? 0);
if (record.storeId === "2/F") floor2F += cartonQty;
if (record.storeId === "4/F") floor4F += cartonQty;
if (record.storeId === FLOOR_A) floor2F += cartonQty;
if (record.storeId === FLOOR_B) floor4F += cartonQty;
if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) truckX += cartonQty;
});
return toSorted(
[
{ key: "2/F", label: "2/F", qty: floor2F },
{ key: "4/F", label: "4/F", qty: floor4F },
{ key: FLOOR_A, label: FLOOR_A, qty: floor2F },
{ key: FLOOR_B, label: FLOOR_B, qty: floor4F },
{ key: TRUCK_X_LANE, label: shortLaneLabel(TRUCK_X_LANE), qty: truckX },
].filter((row) => {
if (row.qty > 0) return true;
if (row.key === "2/F" || row.key === "4/F") return floor === row.key;
if (row.key === FLOOR_A || row.key === FLOOR_B) return floor === row.key;
return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE;
}),
);
@@ -490,7 +491,7 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
(row: BreakdownRow) => {
if (breakdownDimension === "lane") return lane === row.key;
if (breakdownDimension === "shop") return shop === row.key;
if (row.key === "2/F" || row.key === "4/F") return floor === row.key;
if (row.key === FLOOR_A || row.key === FLOOR_B) return floor === row.key;
return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE;
},
[breakdownDimension, floor, lane, shop],
@@ -550,8 +551,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
};
if (!current.shopName && shopName) current.shopName = shopName;

if (record.storeId === "2/F") current.floor2F += cartonQty;
if (record.storeId === "4/F") current.floor4F += cartonQty;
if (record.storeId === FLOOR_A) current.floor2F += cartonQty;
if (record.storeId === FLOOR_B) current.floor4F += cartonQty;
if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty;

current.total += cartonQty;
@@ -677,8 +678,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
t("Date"),
t("Shop code"),
t("Shop Name"),
t("2/F carton qty"),
t("4/F carton qty"),
t(FLOOR_A + " carton qty"),
t(FLOOR_B + " carton qty"),
t("Truck X carton qty"),
t("Total carton qty"),
],
@@ -693,8 +694,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
]),
[...blank],
[t("Summary"), ...blank.slice(1)],
[t("2/F carton qty"), "", "", reportSummary.floor2F, "", "", ""],
[t("4/F carton qty"), "", "", "", reportSummary.floor4F, "", ""],
[t(FLOOR_A + " carton qty"), "", "", reportSummary.floor2F, "", "", ""],
[t(FLOOR_B + " carton qty"), "", "", "", reportSummary.floor4F, "", ""],
[t("Truck X carton qty"), "", "", "", "", reportSummary.truckX, ""],
[t("Total carton qty"), "", "", "", "", "", reportSummary.total],
];
@@ -990,8 +991,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
onChange={(event) => setFloor(event.target.value as FloorFilter)}
>
<MenuItem value={ALL}>{t("All")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="4/F">4/F</MenuItem>
<MenuItem value={FLOOR_A}>{FLOOR_A}</MenuItem>
<MenuItem value={FLOOR_B}>{FLOOR_B}</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} sm={6} md={3}>
@@ -1041,8 +1042,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
<Paper sx={{ px: 1, py: 1 }}>
<Grid container>
{[
{ label: t("2/F carton qty"), value: summary.floor2F },
{ label: t("4/F carton qty"), value: summary.floor4F },
{ label: t(FLOOR_A + " carton qty"), value: summary.floor2F },
{ label: t(FLOOR_B + " carton qty"), value: summary.floor4F },
{ label: t("Truck X carton qty"), value: summary.truckX },
{ label: t("Total carton qty"), value: summary.total },
].map((kpi, index) => (


+ 27
- 26
src/components/FinishedGoodSearch/FinishedGoodFloorLanePanel.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel ,Tooltip} from "@mui/material";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -22,7 +23,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const [selectedStore, setSelectedStore] = useState<string>("2/F");
const [selectedStore, setSelectedStore] = useState<string>(FLOOR_A);
const [selectedTruck, setSelectedTruck] = useState<string>("");
const [selectedDefaultTruck, setSelectedDefaultTruck] = useState<string>("");
const [modalOpen, setModalOpen] = useState(false);
@@ -44,7 +45,7 @@ const [defaultDateScope, setDefaultDateScope] = useState<"today" | "before">("to

const [selectedDate, setSelectedDate] = useState<string>("today");
const [releaseType, setReleaseType] = useState<string>("batch");
const [ticketFloor, setTicketFloor] = useState<"2/F" | "4/F">("2/F");
const [ticketFloor, setTicketFloor] = useState<typeof FLOOR_A | typeof FLOOR_B>(FLOOR_A);
const startFullTimer = () => {
if (typeof window === "undefined") return;
const key = "__FG_FLOOR_FULL_TIMER_STARTED__" as const;
@@ -79,8 +80,8 @@ const [defaultDateScope, setDefaultDateScope] = useState<"today" | "before">("to
}
const [s2, s4] = await Promise.all([
fetchStoreLaneSummary("2/F", dateParam, releaseType),
fetchStoreLaneSummary("4/F", dateParam, releaseType)
fetchStoreLaneSummary(FLOOR_A, dateParam, releaseType),
fetchStoreLaneSummary(FLOOR_B, dateParam, releaseType)
]);
setSummary2F(s2);
setSummary4F(s4);
@@ -117,8 +118,8 @@ const [defaultDateScope, setDefaultDateScope] = useState<"today" | "before">("to
startFullTimer();
try {
const [list2F, list4F] = await Promise.all([
fetchReleasedDoPickOrdersForSelection(undefined, "2/F"),
fetchReleasedDoPickOrdersForSelection(undefined, "4/F"),
fetchReleasedDoPickOrdersForSelection(undefined, FLOOR_A),
fetchReleasedDoPickOrdersForSelection(undefined, FLOOR_B),
]);
const groupByTruck = (list: { truckLanceCode?: string | null }[]) => {
const map: Record<string, number> = {};
@@ -388,10 +389,10 @@ const getDateLabel = (offset: number) => {
id="ticket-floor-select"
value={ticketFloor}
label={t("Floor ticket")}
onChange={(e) => setTicketFloor(e.target.value as "2/F" | "4/F")}
onChange={(e) => setTicketFloor(e.target.value as typeof FLOOR_A | typeof FLOOR_B)}
>
<MenuItem value="2/F">{t("2F ticket")}</MenuItem>
<MenuItem value="4/F">{t("4F ticket")}</MenuItem>
<MenuItem value={FLOOR_A}>{t(FLOOR_A_KEY + " ticket")}</MenuItem>
<MenuItem value={FLOOR_B}>{t(FLOOR_B_KEY + " ticket")}</MenuItem>
</Select>
</FormControl>
</Box>
@@ -414,7 +415,7 @@ const getDateLabel = (offset: number) => {
{/* Grid containing both floors */}
<Grid container spacing={2}>
{/* 2/F 楼层面板 */}
{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
{/* Floor Label */}
@@ -426,7 +427,7 @@ const getDateLabel = (offset: number) => {
pt: 1
}}
>
2/F
{FLOOR_A}
</Typography>
{/* Content Box */}
@@ -495,7 +496,7 @@ const getDateLabel = (offset: number) => {
disabled={lane.unassigned === 0 || isAssigning}
onClick={() =>
handleLaneButtonClick(
"2/F",
FLOOR_A,
row.truckDepartureTime,
lane.truckLanceCode,
null,
@@ -530,7 +531,7 @@ const getDateLabel = (offset: number) => {
</Stack>
</Grid>
)}
{ticketFloor === "4/F" && (
{ticketFloor === FLOOR_B && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
{/* Floor Label */}
@@ -542,7 +543,7 @@ const getDateLabel = (offset: number) => {
pt: 1
}}
>
4/F
{FLOOR_B}
</Typography>
{/* Content Box */}
@@ -613,7 +614,7 @@ const getDateLabel = (offset: number) => {
disabled={slot.lane.unassigned === 0 || isAssigning}
onClick={() =>
handleLaneButtonClick(
"4/F",
FLOOR_B,
slot.truckDepartureTime,
slot.lane.truckLanceCode,
slot.lane.loadingSequence ?? null,
@@ -720,11 +721,11 @@ const getDateLabel = (offset: number) => {
</Box>
</Grid>
{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>
2/F
{FLOOR_A}
</Typography>
<Box
sx={{
@@ -751,7 +752,7 @@ const getDateLabel = (offset: number) => {
) : (
<Grid container spacing={1}>
{truckCounts2F.map(({ truck, count }) => (
<Grid item xs={6} sm={4} md={3} key={`2F-${truck}`} sx={{ display: "flex" }}>
<Grid item xs={6} sm={4} md={3} key={`${FLOOR_A_KEY}-${truck}`} sx={{ display: "flex" }}>

<Button
@@ -759,7 +760,7 @@ const getDateLabel = (offset: number) => {
size="medium"
onClick={() => {
setIsDefaultTruck(false);
setSelectedStore("2/F");
setSelectedStore(FLOOR_A);
setSelectedTruck(truck);
setModalOpen(true);
}}
@@ -788,11 +789,11 @@ const getDateLabel = (offset: number) => {
</Stack>
</Grid>
)}
{ticketFloor === "4/F" && (
{ticketFloor === FLOOR_B && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>
4/F
{FLOOR_B}
</Typography>
<Box
sx={{
@@ -819,7 +820,7 @@ const getDateLabel = (offset: number) => {
) : (
<Grid container spacing={1}>
{truckCounts4F.map(({ truck, count }) => (
<Grid item xs={6} sm={4} md={3} key={`4F-${truck}`} sx={{ display: "flex" }}>
<Grid item xs={6} sm={4} md={3} key={`${FLOOR_B_KEY}-${truck}`} sx={{ display: "flex" }}>
<Button
@@ -827,7 +828,7 @@ const getDateLabel = (offset: number) => {
size="medium"
onClick={() => {
setIsDefaultTruck(false);
setSelectedStore("4/F");
setSelectedStore(FLOOR_B);
setSelectedTruck(truck);
setModalOpen(true);
}}
@@ -877,7 +878,7 @@ const getDateLabel = (offset: number) => {
variant="outlined"
size="medium"
onClick={() => {
setSelectedStore("4/F"); // 或用專門標示 Truck X
setSelectedStore(FLOOR_B); // 或用專門標示 Truck X
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setDefaultDateScope("before"); // 類似上一輪說的 dateScope
@@ -902,8 +903,8 @@ const getDateLabel = (offset: number) => {
const loadCounts = async () => {
try {
const [list2F, list4F] = await Promise.all([
fetchReleasedDoPickOrdersForSelection(undefined, "2/F"),
fetchReleasedDoPickOrdersForSelection(undefined, "4/F"),
fetchReleasedDoPickOrdersForSelection(undefined, FLOOR_A),
fetchReleasedDoPickOrdersForSelection(undefined, FLOOR_B),
]);
const groupByTruck = (list: { truckLanceCode?: string | null }[]) => {
const map: Record<string, number> = {};


+ 4
- 3
src/components/FinishedGoodSearch/FinishedGoodSearch.tsx Visa fil

@@ -1,4 +1,5 @@
"use client";
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -134,8 +135,8 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo
setIsLoadingSummary(true);
try {
const [s2, s4] = await Promise.all([
fetchStoreLaneSummary("2/F"),
fetchStoreLaneSummary("4/F")
fetchStoreLaneSummary(FLOOR_A),
fetchStoreLaneSummary(FLOOR_B)
]);
setSummary2F(s2);
setSummary4F(s4);
@@ -300,7 +301,7 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo
Swal.fire({
position: "bottom-end",
icon: "warning",
text: "請先選擇 2/F 或 4/F、車線和日期",
text: "請先選擇 " + FLOOR_A + " 或 " + FLOOR_B + "、車線和日期",
showConfirmButton: false,
timer: 1800,
});


+ 6
- 5
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx Visa fil

@@ -1,6 +1,7 @@
/*
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import {
Box,
Button,
@@ -786,7 +787,7 @@ const PickExecution: React.FC<Props> = ({
.trim()
.toUpperCase()
.replace(/\s/g, "");
// e.g. "2/F" -> "2F-", "4/F" -> "4F-"
// e.g. FLOOR_A -> FLOOR_A_KEY + "-", FLOOR_B -> FLOOR_B_KEY + "-"
const floorKey = storeId.replace(/\//g, "");
return floorKey ? `${floorKey}-` : "";
}, [fgPickOrders]);
@@ -796,8 +797,8 @@ const PickExecution: React.FC<Props> = ({
.toUpperCase()
.replace(/\s/g, "");
const floorKey = storeId.replace(/\//g, "");
if (floorKey === "2F") return "Label機 2F A+B";
if (floorKey === "4F") return "Label機 4F 乾貨 C, D";
if (floorKey === FLOOR_A_KEY) return LABEL_A;
if (floorKey === FLOOR_B_KEY) return LABEL_B;
return undefined;
}, [fgPickOrders]);
// Add these missing state variables after line 352
@@ -1212,14 +1213,14 @@ const PickExecution: React.FC<Props> = ({
// 将层级数据转换为平铺格式(用于表格显示)
const flatLotData: any[] = [];

// 2/F 與後端 store_id 一致時需按 itemOrder;避免 API 未走 2F 分支時畫面仍亂序
// ${FLOOR_A} 與後端 store_id 一致時需按 itemOrder;避免 API 未走 ${FLOOR_A_KEY} 分支時畫面仍亂序
const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? "")
.trim()
.toUpperCase()
.replace(/\//g, "")
.replace(/\s/g, "");
const pickOrderLinesForDisplay =
doFloorKey === "2F"
doFloorKey === FLOOR_A_KEY
? [...(mergedPickOrder.pickOrderLines || [])].sort(
(a: any, b: any) => {
const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999;


+ 4
- 3
src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx Visa fil

@@ -1,6 +1,7 @@
// 新建檔案
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Modal,
Box,
@@ -37,7 +38,7 @@ export type ReleasedDoPickListBridge = {
storeId?: string,
truck?: string,
releaseType?: string,
/** Optional `2F`/`4F` for Truck X supplier-floor split */
/** Optional `${FLOOR_A_KEY}`/`${FLOOR_B_KEY}` for Truck X supplier-floor split */
floor?: string
) => Promise<ReleasedDoPickOrderListItem[]>;
/** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */
@@ -47,7 +48,7 @@ export type ReleasedDoPickListBridge = {
truck?: string,
requiredDeliveryDate?: string,
releaseType?: string,
/** Optional `2F`/`4F` for Truck X supplier-floor split */
/** Optional `${FLOOR_A_KEY}`/`${FLOOR_B_KEY}` for Truck X supplier-floor split */
floor?: string
) => Promise<ReleasedDoPickOrderListItem[]>;
assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>;
@@ -75,7 +76,7 @@ interface Props {
*/
filterRequiredDeliveryDate?: string;
/**
* Truck X only: `2F`/`4F` (or `2/F`/`4/F`) — filter by DO supplier preferred floor; storeId stays null.
* Truck X only: `${FLOOR_A_KEY}`/`${FLOOR_B_KEY}` (or `${FLOOR_A}`/`${FLOOR_B}`) — filter by DO supplier preferred floor; storeId stays null.
*/
truckXFloor?: string;
}


+ 2
- 1
src/components/FinishedGoodSearch/TruckRoutingSummaryTab.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { useEffect, useState } from "react";
import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
@@ -121,7 +122,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
<TextField
select
fullWidth
label="2/F 或 4/F"
label={FLOOR_A + " 或 " + FLOOR_B}
value={storeId}
onChange={(e) => onStoreChange(e.target.value)}
>


+ 6
- 4
src/components/InventorySearch/LotLabelPrintModal.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, LOC_PREFIX } from "@/config/demoFloors";
import React, {
useCallback,
useEffect,
@@ -77,7 +78,7 @@ export interface LotLabelPrintModalProps {
reminderText?: string;
/** 額外標題(顯示在最上方,reminderText 之下) */
statusTitleText?: string;
/** 只顯示特定倉位前綴(例如 "2F-") */
/** 只顯示特定倉位前綴(例如 FLOOR_A_KEY + "-") */
warehouseCodePrefixFilter?: string;
/** 不顯示觸發視窗的批號(analysis.scanned) */
hideTriggeredLot?: boolean;
@@ -346,9 +347,10 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
const filteredLots = useMemo(() => {
const prefix = String(warehouseCodePrefixFilter ?? "").trim();
if (!prefix) return availableLots;
return availableLots.filter((lot) =>
String(lot.warehouseCode ?? "").startsWith(prefix),
);
return availableLots.filter((lot) => {
const code = String(lot.warehouseCode ?? "");
return code.startsWith(prefix) || code.startsWith(LOC_PREFIX);
});
}, [availableLots, warehouseCodePrefixFilter]);

const selectedPrinter = useMemo(() => {


+ 6
- 5
src/components/JoWorkbench/JoPickOrderList.tsx Visa fil

@@ -1,4 +1,5 @@
"use client";
import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import React, { useCallback, useEffect, useState } from "react";
import {
Box,
@@ -45,7 +46,7 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({ planStart: today });

type FloorFilter = "ALL" | "2F" | "3F" | "4F" | "NO_LOT";
type FloorFilter = "ALL" | typeof FLOOR_A_KEY | typeof FLOOR_D_KEY | typeof FLOOR_B_KEY | "NO_LOT";

const searchCriteria: Criterion<any>[] = [
{ label: t("Job Order Code"), paramName: "jobOrderCode", type: "text" },
@@ -76,9 +77,9 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
paramName: "floor",
type: "select-labelled",
options: [
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" },
{ label: FLOOR_A_KEY, value: FLOOR_A_KEY },
{ label: FLOOR_D_KEY, value: FLOOR_D_KEY },
{ label: FLOOR_B_KEY, value: FLOOR_B_KEY },
{ label: t("No Lot"), value: "NO_LOT" },
],
},
@@ -86,7 +87,7 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {

const selectedFloor: FloorFilter = (() => {
const floor = String(searchQuery.floor || "ALL");
if (floor === "2F" || floor === "3F" || floor === "4F" || floor === "NO_LOT") {
if (floor === FLOOR_A_KEY || floor === FLOOR_D_KEY || floor === FLOOR_B_KEY || floor === "NO_LOT") {
return floor;
}
return "ALL";


+ 19
- 13
src/components/JoWorkbench/newJobPickExecution.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import {
Box,
Button,
@@ -1017,17 +1018,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
[],
);
const extractFloor = (lot: any): string => {
const raw = lot.routerRoute || lot.routerArea || lot.location || "";
const match = raw.match(/^(\d+F?)/i) || raw.split("-")[0];
return (match?.[1] || match || raw || "")
.toUpperCase()
.replace(/(\d)F?/i, "$1F");
const raw = String(lot.routerRoute || lot.routerArea || lot.location || "");
const head = raw.trim().toUpperCase().replace(/\//g, "").split("-")[0];
if (head === "2" || head === FLOOR_A_KEY) return FLOOR_A_KEY;
if (head === "4" || head === FLOOR_B_KEY) return FLOOR_B_KEY;
if (head === "3" || head === FLOOR_D_KEY) return FLOOR_D_KEY;
if (head === "1" || head === FLOOR_C_KEY) return FLOOR_C_KEY;
return head;
};

// 楼层排序权重:4F > 3F > 2F(数字越大越靠前)
// B/F, then D/F, then A/F.
const floorSortOrder = (floor: string): number => {
const n = parseInt(floor.replace(/\D/g, ""), 10);
return isNaN(n) ? 0 : n;
if (floor === FLOOR_B_KEY) return 4;
if (floor === FLOOR_D_KEY) return 3;
if (floor === FLOOR_A_KEY) return 2;
if (floor === FLOOR_C_KEY) return 1;
return 0;
};
const combinedLotData = useMemo(() => {
const lots = getAllLotsFromHierarchical(jobOrderData);
@@ -1156,8 +1162,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
.toUpperCase()
.replace(/\s/g, "");
const floorKey = storeId.replace(/\//g, "");
if (floorKey === "2F") return "Label機 2F A+B";
if (floorKey === "4F") return "Label機 4F 乾貨 C, D";
if (floorKey === FLOOR_A_KEY) return LABEL_A;
if (floorKey === FLOOR_B_KEY) return LABEL_B;
return undefined;
}, [workbenchStoreId]);

@@ -3905,7 +3911,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
variant="contained"
color="primary"
size="small"
onClick={() => handlePickRecord("2F")}
onClick={() => handlePickRecord(FLOOR_A_KEY)}
>
{t("Print Pick Record")} 2F
</Button>
@@ -3913,7 +3919,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
variant="contained"
color="primary"
size="small"
onClick={() => handlePickRecord("3F")}
onClick={() => handlePickRecord(FLOOR_D_KEY)}
>
{t("Print Pick Record")} 3F
</Button>
@@ -3921,7 +3927,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
variant="contained"
color="primary"
size="small"
onClick={() => handlePickRecord("4F")}
onClick={() => handlePickRecord(FLOOR_B_KEY)}
>
{t("Print Pick Record")} 4F
</Button>


+ 10
- 9
src/components/Jodetail/JoPickOrderList.tsx Visa fil

@@ -1,5 +1,6 @@
/*
"use client";
import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import React, { useCallback, useEffect, useState } from "react";
import {
Box,
@@ -28,7 +29,7 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{
const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined);
type PickOrderFilter = "all" | "drink" | "Powder_Mixture" | "other";
const [filter, setFilter] = useState<PickOrderFilter>("all");
type FloorFilter = "ALL" | "2F" | "3F" | "4F" | "NO_LOT";
type FloorFilter = "ALL" | typeof FLOOR_A_KEY | typeof FLOOR_D_KEY | typeof FLOOR_B_KEY | "NO_LOT";
const [floorFilter, setFloorFilter] = useState<FloorFilter>("ALL");
const fetchPickOrders = useCallback(async () => {
setLoading(true);
@@ -124,25 +125,25 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{
{t("Select All")}
</Button>
<Button
variant={floorFilter === "2F" ? "contained" : "outlined"}
variant={floorFilter === FLOOR_A_KEY ? "contained" : "outlined"}
size="small"
onClick={() => setFloorFilter("2F")}
onClick={() => setFloorFilter(FLOOR_A_KEY)}
>
2F
{FLOOR_A_KEY}
</Button>
<Button
variant={floorFilter === "3F" ? "contained" : "outlined"}
variant={floorFilter === FLOOR_D_KEY ? "contained" : "outlined"}
size="small"
onClick={() => setFloorFilter("3F")}
onClick={() => setFloorFilter(FLOOR_D_KEY)}
>
3F
</Button>
<Button
variant={floorFilter === "4F" ? "contained" : "outlined"}
variant={floorFilter === FLOOR_B_KEY ? "contained" : "outlined"}
size="small"
onClick={() => setFloorFilter("4F")}
onClick={() => setFloorFilter(FLOOR_B_KEY)}
>
4F
{FLOOR_B_KEY}
</Button>
<Button
variant={floorFilter === "NO_LOT" ? "contained" : "outlined"}


+ 2
- 1
src/components/Jodetail/JodetailSearch.tsx Visa fil

@@ -1,4 +1,5 @@
"use client";
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
@@ -165,7 +166,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
}
}, [tabIndex]);

const handleAssignByStore = async (storeId: "2/F" | "4/F") => {
const handleAssignByStore = async (storeId: typeof FLOOR_A | typeof FLOOR_B) => {
if (!currentUserId) {
console.error("Missing user id in session");
return;


+ 22
- 21
src/components/Jodetail/PlasticBoxCartonDashboardTab.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A, FLOOR_B, FLOOR_D } from "@/config/demoFloors";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
@@ -31,7 +32,7 @@ import SafeApexCharts from "@/components/charts/SafeApexCharts";
/** Day.js has no typed `dayjs.invalid()` factory; Invalid Date parses to an invalid Dayjs instance. */
const DAYJS_INVALID = dayjs(new Date(Number.NaN));

type FloorFilter = "all" | "2/F" | "3/F" | "4/F";
type FloorFilter = "all" | typeof FLOOR_A | typeof FLOOR_D | typeof FLOOR_B;

type DailySummaryRow = {
date: string;
@@ -81,21 +82,21 @@ const addRecordQty = (
const q3 = Number(record.plasticBoxCartonQty3f ?? 0);
const q4 = Number(record.plasticBoxCartonQty4f ?? 0);

if (selectedFloor === "all" || selectedFloor === "2/F") {
if (selectedFloor === "all" || selectedFloor === FLOOR_A) {
current.floor2F += q2;
}
if (selectedFloor === "all" || selectedFloor === "3/F") {
if (selectedFloor === "all" || selectedFloor === FLOOR_D) {
current.floor3F += q3;
}
if (selectedFloor === "all" || selectedFloor === "4/F") {
if (selectedFloor === "all" || selectedFloor === FLOOR_B) {
current.floor4F += q4;
}

if (selectedFloor === "all") {
current.total += q2 + q3 + q4;
} else if (selectedFloor === "2/F") {
} else if (selectedFloor === FLOOR_A) {
current.total += q2;
} else if (selectedFloor === "3/F") {
} else if (selectedFloor === FLOOR_D) {
current.total += q3;
} else {
current.total += q4;
@@ -198,9 +199,9 @@ const PlasticBoxCartonDashboardTab: React.FC = () => {

const chartSeries = useMemo(
() => [
{ name: "2/F", data: rows.map((row) => row.floor2F) },
{ name: "3/F", data: rows.map((row) => row.floor3F) },
{ name: "4/F", data: rows.map((row) => row.floor4F) },
{ name: FLOOR_A, data: rows.map((row) => row.floor2F) },
{ name: FLOOR_D, data: rows.map((row) => row.floor3F) },
{ name: FLOOR_B, data: rows.map((row) => row.floor4F) },
{ name: t("Total plastic box carton qty"), data: rows.map((row) => row.total) },
],
[rows, t],
@@ -351,17 +352,17 @@ const PlasticBoxCartonDashboardTab: React.FC = () => {
["", "", "", "", ""],
[
t("Date"),
t("2/F plastic box carton qty count"),
t("3/F plastic box carton qty count"),
t("4/F plastic box carton qty count"),
t(FLOOR_A + " plastic box carton qty count"),
t(FLOOR_D + " plastic box carton qty count"),
t(FLOOR_B + " plastic box carton qty count"),
t("Total plastic box carton qty"),
],
...dailyRows.map((row) => [row.date, row.floor2F, row.floor3F, row.floor4F, row.total]),
["", "", "", "", ""],
[t("Summary"), "", "", "", ""],
[t("2/F plastic box carton qty count"), reportSummary.floor2F, "", "", ""],
[t("3/F plastic box carton qty count"), reportSummary.floor3F, "", "", ""],
[t("4/F plastic box carton qty count"), reportSummary.floor4F, "", "", ""],
[t(FLOOR_A + " plastic box carton qty count"), reportSummary.floor2F, "", "", ""],
[t(FLOOR_D + " plastic box carton qty count"), reportSummary.floor3F, "", "", ""],
[t(FLOOR_B + " plastic box carton qty count"), reportSummary.floor4F, "", "", ""],
[t("Total plastic box carton qty"), reportSummary.total, "", "", ""],
];

@@ -469,9 +470,9 @@ const PlasticBoxCartonDashboardTab: React.FC = () => {
onChange={(event) => setFloor(event.target.value as FloorFilter)}
>
<MenuItem value="all">{t("All")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="3/F">3/F</MenuItem>
<MenuItem value="4/F">4/F</MenuItem>
<MenuItem value={FLOOR_A}>{FLOOR_A}</MenuItem>
<MenuItem value={FLOOR_D}>{FLOOR_D}</MenuItem>
<MenuItem value={FLOOR_B}>{FLOOR_B}</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} md={6}>
@@ -492,19 +493,19 @@ const PlasticBoxCartonDashboardTab: React.FC = () => {
<Table size="small">
<TableBody>
<TableRow>
<TableCell>{t("2/F plastic box carton qty count")}</TableCell>
<TableCell>{t(FLOOR_A + " plastic box carton qty count")}</TableCell>
<TableCell align="right">
{summary.floor2F.toLocaleString("zh-HK")}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t("3/F plastic box carton qty count")}</TableCell>
<TableCell>{t(FLOOR_D + " plastic box carton qty count")}</TableCell>
<TableCell align="right">
{summary.floor3F.toLocaleString("zh-HK")}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t("4/F plastic box carton qty count")}</TableCell>
<TableCell>{t(FLOOR_B + " plastic box carton qty count")}</TableCell>
<TableCell align="right">
{summary.floor4F.toLocaleString("zh-HK")}
</TableCell>


+ 4
- 3
src/components/Jodetail/completeJobOrderRecord.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import {
Box,
Button,
@@ -774,21 +775,21 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
<Button
variant="contained"
color="primary"
onClick={() => handlePickRecord(jobOrderPickOrder, "2F")}
onClick={() => handlePickRecord(jobOrderPickOrder, FLOOR_A_KEY)}
>
{t("Print Pick Record")} 2F
</Button>
<Button
variant="contained"
color="primary"
onClick={() => handlePickRecord(jobOrderPickOrder, "3F")}
onClick={() => handlePickRecord(jobOrderPickOrder, FLOOR_D_KEY)}
>
{t("Print Pick Record")} 3F
</Button>
<Button
variant="contained"
color="primary"
onClick={() => handlePickRecord(jobOrderPickOrder, "4F")}
onClick={() => handlePickRecord(jobOrderPickOrder, FLOOR_B_KEY)}
>
{t("Print Pick Record")} 4F
</Button>


+ 16
- 8
src/components/Jodetail/newJobPickExecution.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import {
Box,
Button,
@@ -684,15 +685,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
return allLots;
}, []);
const extractFloor = (lot: any): string => {
const raw = lot.routerRoute || lot.routerArea || lot.location || '';
const match = raw.match(/^(\d+F?)/i) || raw.split('-')[0];
return (match?.[1] || match || raw || '').toUpperCase().replace(/(\d)F?/i, '$1F');
const raw = String(lot.routerRoute || lot.routerArea || lot.location || "");
const head = raw.trim().toUpperCase().replace(/\//g, "").split("-")[0];
if (head === "2" || head === FLOOR_A_KEY) return FLOOR_A_KEY;
if (head === "4" || head === FLOOR_B_KEY) return FLOOR_B_KEY;
if (head === "3" || head === FLOOR_D_KEY) return FLOOR_D_KEY;
if (head === "1" || head === FLOOR_C_KEY) return FLOOR_C_KEY;
return head;
};
// 楼层排序权重:4F > 3F > 2F(数字越大越靠前)
// B/F, then D/F, then A/F.
const floorSortOrder = (floor: string): number => {
const n = parseInt(floor.replace(/\D/g, ''), 10);
return isNaN(n) ? 0 : n;
if (floor === FLOOR_B_KEY) return 4;
if (floor === FLOOR_D_KEY) return 3;
if (floor === FLOOR_A_KEY) return 2;
if (floor === FLOOR_C_KEY) return 1;
return 0;
};
const combinedLotData = useMemo(() => {
const lots = getAllLotsFromHierarchical(jobOrderData);
@@ -2613,7 +2621,7 @@ const sortedData = [...sourceData].sort((a, b) => {
const floorB = extractFloor(b);
const orderA = floorSortOrder(floorA);
const orderB = floorSortOrder(floorB);
if (orderA !== orderB) return orderB - orderA; // 4F, 3F, 2F
if (orderA !== orderB) return orderB - orderA; // ${FLOOR_B_KEY}, ${FLOOR_D_KEY}, ${FLOOR_A_KEY}
// 同楼层再按 routerIndex、pickOrderCode、lotNo
const aIndex = a.routerIndex ?? 0;
const bIndex = b.routerIndex ?? 0;


+ 5
- 4
src/components/Jodetail/pickRecordHelpers.ts Visa fil

@@ -1,10 +1,11 @@
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY, FLOOR_D, FLOOR_D_KEY } from "@/config/demoFloors";
import Swal from "sweetalert2";
import type { TFunction } from "i18next";
import { fetchPickRecordPlasticBoxCartonQty } from "@/app/api/jo/actions";

export type PickRecordFloor = "2F" | "3F" | "4F" | "ALL";
export type PickRecordFloor = typeof FLOOR_A_KEY | typeof FLOOR_D_KEY | typeof FLOOR_B_KEY | "ALL";

export interface AllFloorsPlasticBoxCartonQtyResult {
qty2f: number;
@@ -115,9 +116,9 @@ export async function promptAllFloorsPlasticBoxCartonQty(
title: t("Enter all floors plastic box carton qty"),
html: `
<div style="display:flex;flex-direction:column;gap:10px;text-align:left;">
${buildFloorQtyInputRow("swal-qty-2f", t("2/F plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty2f)}
${buildFloorQtyInputRow("swal-qty-3f", t("3/F plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty3f)}
${buildFloorQtyInputRow("swal-qty-4f", t("4/F plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty4f)}
${buildFloorQtyInputRow("swal-qty-2f", t(FLOOR_A + " plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty2f)}
${buildFloorQtyInputRow("swal-qty-3f", t(FLOOR_D + " plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty3f)}
${buildFloorQtyInputRow("swal-qty-4f", t(FLOOR_B + " plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty4f)}
</div>
`,
showCancelButton: true,


+ 9
- 8
src/components/LotSuggestionRules/LotSuggestionRules.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY, LOC_HOLD, LOC_WIP } from "@/config/demoFloors";
import React, { useState } from "react";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import {
@@ -33,17 +34,17 @@ const sections: { title: string; lines: string[] }[] = [
{
title: "送貨揀貨的樓層",
lines: [
"票上已指定 2F 或 4F:只建議該樓層的批次。",
"沒有指定樓層:供應商在「4F 供應商」清單就用 4F,否則用 2F。這兩份清單可以在「送貨單樓層(供應商)」修改。",
"放單時若供應商在 2F 或 4F 清單內,只建議該樓層;1F 的批次仍然可以建議。不在清單內的供應商不限制 2F / 4F。",
"送貨單不建議 3F。",
"票上已指定 " + FLOOR_A_KEY + " 或 " + FLOOR_B_KEY + ":只建議該樓層的批次。",
"沒有指定樓層:供應商在「" + FLOOR_B_KEY + " 供應商」清單就用 " + FLOOR_B_KEY + ",否則用 " + FLOOR_A_KEY + "。這兩份清單可以在「送貨單樓層(供應商)」修改。",
"放單時若供應商在 " + FLOOR_A_KEY + " 或 " + FLOOR_B_KEY + " 清單內,只建議該樓層;" + FLOOR_C_KEY + " 的批次仍然可以建議。不在清單內的供應商不限制 " + FLOOR_A_KEY + " / " + FLOOR_B_KEY + "。",
"送貨單不建議 " + FLOOR_D_KEY + "。",
],
},
{
title: "目前不建議的倉位",
lines: [
"2F-W202-01-00",
"2F-W200-#A-00",
LOC_HOLD,
LOC_WIP,
"這兩個倉位寫在程式裡,畫面還不能改。",
],
},
@@ -51,8 +52,8 @@ const sections: { title: string; lines: string[] }[] = [
title: "工單提料(和送貨揀貨不同)",
lines: [
"同樣是先到期先出。",
"不建議 2F-W202-01-00。半成品會先試 2F-W200-#A-00。",
"工單可以使用 3F。",
"不建議 " + LOC_HOLD + "。半成品會先試 " + LOC_WIP + "。",
"工單可以使用 " + FLOOR_D_KEY + "。",
],
},
];


+ 22
- 21
src/components/Shop/RouteBoard.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import React, {
useCallback,
useEffect,
@@ -260,7 +261,7 @@ function summarizeLogisticsColumnStats(lanes: Lane[]): {
let count4F = 0;
for (const l of lanes) {
shopCount += l.shops.length;
if (normalizeStoreId(l.storeId) === "4F") count4F++;
if (normalizeStoreId(l.storeId) === FLOOR_B_KEY) count4F++;
else count2F++;
}
return {
@@ -516,7 +517,7 @@ function buildLaneFromPendingNewLane(p: PendingNewLane): Lane {
const code = String(payload.truckLanceCode || "").trim();
const storeNorm = normalizeStoreId(payload.store_id);
const remarkRaw =
storeNorm === "4F" &&
storeNorm === FLOOR_B_KEY &&
payload.remark != null &&
String(payload.remark).trim() !== ""
? String(payload.remark).trim()
@@ -690,7 +691,7 @@ function buildLaneFromTruckRows(
phone: lanePhone,
plate: lanePlate,
startTime: startTime || "00:00:00",
storeId: storeId || "2F",
storeId: storeId || FLOOR_A_KEY,
remark,
shops,
};
@@ -853,7 +854,7 @@ const RouteBoard: React.FC = () => {
"board",
);
const [laneFilter, setLaneFilter] = useState<{
floor: "all" | "2F" | "4F";
floor: "all" | typeof FLOOR_A_KEY | typeof FLOOR_B_KEY;
query: string;
}>({
floor: "all",
@@ -1100,7 +1101,7 @@ const RouteBoard: React.FC = () => {
/** 物流主檔 id;null = 未指定(至物流商管理指派) */
logisticId: number | null;
startTime: string;
storeId: "2F" | "4F";
storeId: typeof FLOOR_A_KEY | typeof FLOOR_B_KEY;
remark: string;
};

@@ -1108,7 +1109,7 @@ const RouteBoard: React.FC = () => {
truckLanceCode: "",
logisticId: null,
startTime: "07:30",
storeId: "2F",
storeId: FLOOR_A_KEY,
remark: "",
});

@@ -1201,7 +1202,7 @@ const RouteBoard: React.FC = () => {
const code = String(form.truckLanceCode || "").trim();
const storeNorm = normalizeStoreId(form.storeId);
const remarkRaw =
storeNorm === "4F" && String(form.remark || "").trim() !== ""
storeNorm === FLOOR_B_KEY && String(form.remark || "").trim() !== ""
? String(form.remark).trim()
: null;
const dep = parseTimeForBackend(form.startTime || "") || "00:00:00";
@@ -1244,7 +1245,7 @@ const RouteBoard: React.FC = () => {
const code = String(form.truckLanceCode || "").trim();
const storeNorm = normalizeStoreId(form.storeId);
const remarkRaw =
storeNorm === "4F" && String(form.remark || "").trim() !== ""
storeNorm === FLOOR_B_KEY && String(form.remark || "").trim() !== ""
? String(form.remark).trim()
: null;
return {
@@ -2296,7 +2297,7 @@ const RouteBoard: React.FC = () => {
const code = String(newRouteForm.truckLanceCode || "").trim();
const storeNorm = normalizeStoreId(newRouteForm.storeId);
const remarkRaw =
storeNorm === "4F" && String(newRouteForm.remark || "").trim() !== ""
storeNorm === FLOOR_B_KEY && String(newRouteForm.remark || "").trim() !== ""
? String(newRouteForm.remark).trim()
: null;
const laneKey = encodeLaneId(code, remarkRaw);
@@ -2370,7 +2371,7 @@ const RouteBoard: React.FC = () => {
const seq = Number(newSeq) || 0;
const storeId = normalizeStoreId(lane.storeId);
const remark =
storeId === "4F" &&
storeId === FLOOR_B_KEY &&
lane.remark != null &&
String(lane.remark).trim() !== ""
? String(lane.remark).trim()
@@ -2678,7 +2679,7 @@ const RouteBoard: React.FC = () => {
departureTime: to.startTime,
storeId: to.storeId,
remark:
normalizeStoreId(to.storeId) === "4F"
normalizeStoreId(to.storeId) === FLOOR_B_KEY
? to.remark != null && String(to.remark).trim() !== ""
? String(to.remark).trim()
: null
@@ -2713,7 +2714,7 @@ const RouteBoard: React.FC = () => {
to.shops = inserted;

// IMPORTANT: Do NOT renumber all loadingSequence.
// loadingSequence can be duplicated intentionally (e.g. 4F grouping).
// loadingSequence can be duplicated intentionally (e.g. ${FLOOR_B_KEY} grouping).
// On drag/drop, only update the moved shop's loadingSequence so it joins the target group.
const newSeq = computeMovedLoadingSequence(
inserted,
@@ -3356,7 +3357,7 @@ const RouteBoard: React.FC = () => {
if (!lane) continue;
const sid = normalizeStoreId(lane.storeId);
const remark =
sid === "4F" &&
sid === FLOOR_B_KEY &&
lane.remark != null &&
String(lane.remark).trim() !== ""
? String(lane.remark).trim()
@@ -6085,15 +6086,15 @@ const RouteBoard: React.FC = () => {
onChange={(e) =>
setNewRouteForm((p) => ({
...p,
storeId: e.target.value as "2F" | "4F",
storeId: e.target.value as typeof FLOOR_A_KEY | typeof FLOOR_B_KEY,
}))
}
>
<MenuItem value="2F">2F</MenuItem>
<MenuItem value="4F">4F</MenuItem>
<MenuItem value={FLOOR_A_KEY}>{FLOOR_A_KEY}</MenuItem>
<MenuItem value={FLOOR_B_KEY}>{FLOOR_B_KEY}</MenuItem>
</Select>
</FormControl>
{newRouteForm.storeId === "4F" && (
{newRouteForm.storeId === FLOOR_B_KEY && (
<TextField
size="small"
fullWidth
@@ -6683,8 +6684,8 @@ const RouteBoard: React.FC = () => {
}
>
<MenuItem value="all">{t("floor_all")}</MenuItem>
<MenuItem value="2F">2F</MenuItem>
<MenuItem value="4F">4F</MenuItem>
<MenuItem value={FLOOR_A_KEY}>{FLOOR_A_KEY}</MenuItem>
<MenuItem value={FLOOR_B_KEY}>{FLOOR_B_KEY}</MenuItem>
</Select>
</FormControl>
<Stack
@@ -7263,7 +7264,7 @@ const RouteBoard: React.FC = () => {
/>
<Chip
size="small"
label={`2F · ${colStats.count2F}`}
label={`${FLOOR_A_KEY} · ${colStats.count2F}`}
sx={{
height: 24,
fontWeight: 700,
@@ -7272,7 +7273,7 @@ const RouteBoard: React.FC = () => {
/>
<Chip
size="small"
label={`4F · ${colStats.count4F}`}
label={`${FLOOR_B_KEY} · ${colStats.count4F}`}
sx={{
height: 24,
fontWeight: 700,


+ 4
- 3
src/components/Shop/Shop.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Box,
Button,
@@ -134,14 +135,14 @@ const Shop: React.FC = () => {
const loadingSeqNum = loadingSeq != null && loadingSeq !== undefined ? Number(loadingSeq) : null;
const hasLoadingSequence = loadingSeqNum !== null && !isNaN(loadingSeqNum) && loadingSeqNum !== 0;
// Check storeId: must exist and not be 0 (can be string "2F"/"4F" or number)
// Check storeId: must exist and not be 0 (can be string FLOOR_A_KEY/FLOOR_B_KEY or number)
// Actual field name in JSON is store_id (underscore, lowercase)
const storeId = (truck as any).store_id || (truck as any).storeId || (truck as any).Store_id;
let storeIdValid = false;
if (storeId != null && storeId !== undefined && storeId !== "") {
const storeIdStr = String(storeId).trim();
// If it's "2F" or "4F", it's valid (not 0)
if (storeIdStr === "2F" || storeIdStr === "4F") {
// If it's FLOOR_A_KEY or FLOOR_B_KEY, it's valid (not 0)
if (storeIdStr === FLOOR_A_KEY || storeIdStr === FLOOR_B_KEY) {
storeIdValid = true;
} else {
const storeIdNum = Number(storeId);


+ 3
- 2
src/components/Shop/ShopDetail.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Box,
Card,
@@ -274,9 +275,9 @@ const ShopDetail: React.FC = () => {
}

const lane = selectedTruckLane!;
const storeIdStr = normalizeStoreId(lane.storeId) || "2F";
const storeIdStr = normalizeStoreId(lane.storeId) || FLOOR_A_KEY;
const remarkValue =
storeIdStr === "4F" && lane.remark != null && String(lane.remark).trim() !== ""
storeIdStr === FLOOR_B_KEY && lane.remark != null && String(lane.remark).trim() !== ""
? String(lane.remark).trim()
: null;



+ 6
- 5
src/components/Shop/TruckLane.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Box,
Card,
@@ -56,7 +57,7 @@ const TruckLane: React.FC = () => {
const [newTruck, setNewTruck] = useState({
truckLanceCode: "",
departureTime: "",
storeId: "2F",
storeId: FLOOR_A_KEY,
});
const [saving, setSaving] = useState<boolean>(false);
const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false);
@@ -192,7 +193,7 @@ const TruckLane: React.FC = () => {
setNewTruck({
truckLanceCode: "",
departureTime: "",
storeId: "2F",
storeId: FLOOR_A_KEY,
});
setAddDialogOpen(true);
setError(null);
@@ -203,7 +204,7 @@ const TruckLane: React.FC = () => {
setNewTruck({
truckLanceCode: "",
departureTime: "",
storeId: "2F",
storeId: FLOOR_A_KEY,
});
};

@@ -373,8 +374,8 @@ const TruckLane: React.FC = () => {
}}
disabled={saving}
>
<MenuItem value="2F">2F</MenuItem>
<MenuItem value="4F">4F</MenuItem>
<MenuItem value={FLOOR_A_KEY}>{FLOOR_A_KEY}</MenuItem>
<MenuItem value={FLOOR_B_KEY}>{FLOOR_B_KEY}</MenuItem>
</Select>
</FormControl>
</Grid>


+ 9
- 8
src/components/Shop/TruckLaneDetail.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Box,
Card,
@@ -291,9 +292,9 @@ const TruckLaneDetail: React.FC = () => {

await Promise.all(
withIds.map((row) => {
const storeIdStr = normalizeStoreId(row.storeId) || "2F";
const storeIdStr = normalizeStoreId(row.storeId) || FLOOR_A_KEY;
let remarkValue: string | null = null;
if (storeIdStr === "4F" && row.remark != null && String(row.remark).trim() !== "") {
if (storeIdStr === FLOOR_B_KEY && row.remark != null && String(row.remark).trim() !== "") {
remarkValue = String(row.remark).trim();
}
return updateTruckLaneClient({
@@ -579,8 +580,8 @@ const TruckLaneDetail: React.FC = () => {
try {
const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId)
: String(truckData.storeId)) : "2F";
const displayStoreId = normalizeStoreId(storeIdValue) || "2F";
: String(truckData.storeId)) : FLOOR_A_KEY;
const displayStoreId = normalizeStoreId(storeIdValue) || FLOOR_A_KEY;

let departureTimeStr = "";
if (truckData.departureTime) {
@@ -597,8 +598,8 @@ const TruckLaneDetail: React.FC = () => {
);
const shopIdValue = foundShop ? foundShop.id : null;

// Get remark - only if storeId is "4F"
const remarkValue = displayStoreId === "4F" ? (newShop.remark?.trim() || null) : null;
// Get remark - only if storeId is FLOOR_B_KEY
const remarkValue = displayStoreId === FLOOR_B_KEY ? (newShop.remark?.trim() || null) : null;

// Check if there's an "Unassign" row for this truck lane that should be replaced
let unassignTruck: Truck | null = null;
@@ -909,7 +910,7 @@ const TruckLaneDetail: React.FC = () => {
{(editingRowIndex === index || massEditing) ? (
(() => {
const storeIdValue = truckData.storeId ? String(truckData.storeId) : null;
const isEditable = normalizeStoreId(storeIdValue) === "4F";
const isEditable = normalizeStoreId(storeIdValue) === FLOOR_B_KEY;
return (
<Autocomplete
freeSolo
@@ -1096,7 +1097,7 @@ const TruckLaneDetail: React.FC = () => {
const storeIdValue = truckData?.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId)
: String(truckData.storeId)) : null;
const isEditable = normalizeStoreId(storeIdValue) === "4F";
const isEditable = normalizeStoreId(storeIdValue) === FLOOR_B_KEY;
return isEditable ? (
<Grid item xs={12}>
<Autocomplete


+ 6
- 5
src/components/Shop/computeTruckLaneWarnings.ts Visa fil

@@ -2,6 +2,7 @@
* 車線看板警示:Rule1(4F 同店跨線 weekday)、Rule2(非 4F 同店跨線發車時分)。
* 規則細節見 `README-ROUTE-BOARD-WARNINGS.md`。
*/
import { FLOOR_B_KEY } from "@/config/demoFloors";
import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";

const LANE_KEY_SEP = "|";
@@ -168,7 +169,7 @@ function rowToLaneRef(row: TruckLaneWarningInputRow): TruckLaneWarningLaneRef {
const storeNorm = normalizeStoreId(row.storeId);
const depDisp = departureTimeToHHmmKey(row.departureTime);
const weekday =
storeNorm === "4F"
storeNorm === FLOOR_B_KEY
? parseWeekdayFromTruckLanceCode(row.truckLanceCode)
: null;
return {
@@ -227,7 +228,7 @@ export function computeTruckLaneWarnings(
>();
for (const row of active) {
const store = normalizeStoreId(row.storeId);
if (store !== "4F") continue;
if (store !== FLOOR_B_KEY) continue;
const wd = parseWeekdayFromTruckLanceCode(row.truckLanceCode);
if (!wd) {
weekdayParseFailures.push({
@@ -258,7 +259,7 @@ export function computeTruckLaneWarnings(
const sampleRow = active.find(
(r) =>
shopKOf(r) === shopK &&
normalizeStoreId(r.storeId) === "4F" &&
normalizeStoreId(r.storeId) === FLOOR_B_KEY &&
parseWeekdayFromTruckLanceCode(r.truckLanceCode) === wd,
);
const laneKeyList: string[] = Array.from(laneKeys);
@@ -278,7 +279,7 @@ export function computeTruckLaneWarnings(
const r2Map = new Map<string, Map<string, Set<string>>>();
for (const row of active) {
const store = normalizeStoreId(row.storeId);
if (store === "4F") continue;
if (store === FLOOR_B_KEY) continue;
const hhmm = departureTimeToHHmmKey(row.departureTime);
if (!hhmm) continue;
const shopK = shopKOf(row);
@@ -302,7 +303,7 @@ export function computeTruckLaneWarnings(
const sampleRow = active.find(
(r) =>
shopKOf(r) === shopK &&
normalizeStoreId(r.storeId) !== "4F" &&
normalizeStoreId(r.storeId) !== FLOOR_B_KEY &&
departureTimeToHHmmKey(r.departureTime) === hhmm,
);
const laneKeyList: string[] = Array.from(laneKeys);


+ 2
- 1
src/components/Shop/routeBoardImportPreview.ts Visa fil

@@ -1,3 +1,4 @@
import { FLOOR_A_KEY } from "@/config/demoFloors";
import type { RouteLaneImportPreviewRow } from "@/app/api/shop/client";
import type { Truck } from "@/app/api/shop/actions";
import { normalizeStoreId } from "@/app/utils/formatUtil";
@@ -118,7 +119,7 @@ export function mergeImportPreviewIntoLanes<
String(previewRows[0].remark).trim() !== ""
? String(previewRows[0].remark).trim()
: null;
const storeId = normalizeStoreId(previewRows[0]?.storeId ?? "2F");
const storeId = normalizeStoreId(previewRows[0]?.storeId ?? FLOOR_A_KEY);
const startTime = parseTimeForPreview(previewRows[0]?.departureTime ?? "");

const existing = laneMap.get(laneId);


+ 4
- 3
src/components/StockTakeManagement/ApproverStockTakeAll.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Box,
Button,
@@ -252,7 +253,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc");
const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]);
const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]);
const [storeIdOptions, setStoreIdOptions] = useState<string[]>([FLOOR_A_KEY, FLOOR_B_KEY]);
const [searchSectionDescription, setSearchSectionDescription] = useState<string>("All");
const [searchStockTakeSession, setSearchStockTakeSession] = useState<string>("");
const [searchItemKeyword, setSearchItemKeyword] = useState<string>("");
@@ -373,7 +374,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
.then((sections) => {
const descSet = new Set<string>();
const sectionSet = new Set<string>();
const storeSet = new Set<string>(["2F", "4F"]);
const storeSet = new Set<string>([FLOOR_A_KEY, FLOOR_B_KEY]);
sections.forEach((s) => {
const section = s.stockTakeSection?.trim();
if (section) sectionSet.add(section);
@@ -1375,7 +1376,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
return Array.from(sectionSet).sort((a, b) => a.localeCompare(b));
});
setStoreIdOptions((prev) => {
const storeSet = new Set<string>([...prev, "2F", "4F"]);
const storeSet = new Set<string>([...prev, FLOOR_A_KEY, FLOOR_B_KEY]);
inventoryLotDetails.forEach((item) => {
const storeId = item.storeId?.trim();
if (storeId) storeSet.add(storeId);


+ 4
- 3
src/components/StockTakeManagement/PickerCardList.tsx Visa fil

@@ -1,5 +1,6 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import {
Box,
Button,
@@ -173,7 +174,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
const [marqueePreviewIds, setMarqueePreviewIds] = useState<Set<string>>(() => new Set());
const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]);
const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]);
const [storeIdOptions, setStoreIdOptions] = useState<string[]>([FLOOR_A_KEY, FLOOR_B_KEY]);

const statusOptions = ["pending", "stockTaking", "approving", "completed"];

@@ -605,7 +606,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
setStockTakeSectionRows(Array.isArray(sections) ? sections : []);
const descSet = new Set<string>();
const sectionSet = new Set<string>();
const storeIdSet = new Set<string>(["2F", "4F"]);
const storeIdSet = new Set<string>([FLOOR_A_KEY, FLOOR_B_KEY]);
sections.forEach((s) => {
const section = s.stockTakeSection?.trim();
if (section) sectionSet.add(section);
@@ -637,7 +638,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
}, [stockTakeSessions]);
useEffect(() => {
setStoreIdOptions((prev) => {
const storeIdSet = new Set<string>([...prev, "2F", "4F"]);
const storeIdSet = new Set<string>([...prev, FLOOR_A_KEY, FLOOR_B_KEY]);
stockTakeSessions.forEach((item) => {
const storeId = item.storeId?.trim();
if (storeId) storeIdSet.add(storeId);


+ 19
- 0
src/config/demoFloors.ts Visa fil

@@ -0,0 +1,19 @@
/** Floor and bin labels stored in the anonymized demo database. */
export const FLOOR_A = "A/F";
export const FLOOR_A_KEY = "AF";
export const FLOOR_B = "B/F";
export const FLOOR_B_KEY = "BF";
export const FLOOR_C = "C/F";
export const FLOOR_C_KEY = "CF";
export const FLOOR_D = "D/F";
export const FLOOR_D_KEY = "DF";

export const LOC_HOLD = "LOC-HOLD";
export const LOC_WIP = "LOC-WIP";
export const LOC_PREFIX = "LOC-";

export const LABEL_A = "LABEL-A";
export const LABEL_B = "LABEL-B";

export const SETTING_SUPPLIERS_A = "DO.floor.suppliers.AF";
export const SETTING_SUPPLIERS_B = "DO.floor.suppliers.BF";

+ 7
- 6
src/config/reportConfig.ts Visa fil

@@ -1,8 +1,9 @@
/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */
export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox';

import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox';

export interface ReportField {
label: string;
name: string;
@@ -62,13 +63,13 @@ const asyncItemCodeField = (

const floorOptionsAll: ReportField["options"] = [
{ label: "全部", value: "All" },
{ label: "2/F", value: "2/F" },
{ label: "4/F", value: "4/F" },
{ label: FLOOR_A, value: FLOOR_A },
{ label: FLOOR_B, value: FLOOR_B },
];

const floorOptions: ReportField["options"] = [
{ label: "2/F", value: "2/F" },
{ label: "4/F", value: "4/F" },
{ label: FLOOR_A, value: FLOOR_A },
{ label: FLOOR_B, value: FLOOR_B },
];

const pasteItemCodeField = (): ReportField => ({


+ 121
- 1
src/i18n/en/scheduling.json Visa fil

@@ -1,5 +1,125 @@
{
"title": "Scheduling",
"Scheduling": "Scheduling",
"ps": "Scheduling"
"ps": "Scheduling",
"pageTitle": "Production schedule",
"scheduleSettings": "Schedule settings",
"exportPlan": "Export plan / material demand",
"exportDoQty": "Export delivery qty",
"forecast": "Forecast schedule",
"productionDate": "Production date",
"search": "Search",
"detail": "Detail",
"estProdQty": "Planned qty",
"fgCount": "Finished goods",
"detailTitle": "Schedule {{id}} ({{date}})",
"jobOrder": "Job order",
"itemCode": "Item code",
"itemName": "Item name",
"avgDailyOut": "Avg daily outbound",
"stockBeforeOut": "Stock before outbound",
"unit": "Unit",
"daysOfCover": "Days of cover",
"batchQty": "Batches",
"estPacks": "Planned packs",
"priority": "Priority",
"generateJobs": "Generate job orders",
"close": "Close",
"forecastDialogTitle": "Generate forecast",
"startDate": "Start date",
"scheduleDays": "Days to schedule",
"cancel": "Cancel",
"calculateForecast": "Calculate forecast",
"exportDoQtyHint": "Choose a date range. Only items with a BOM are included, and quantity is in the stock unit.",
"endDate": "End date",
"export": "Export",
"exportPlanDialogTitle": "Export schedule and material usage",
"exportPlanHint": "Choose the start date to export.",
"fromDate": "From date",
"refreshStock": "Refresh stock",
"exportScheduleStock": "Export stock",
"importScheduleStock": "Import stock (overwrite)",
"tabItems": "Items",
"tabRules": "Rules",
"itemsHint": "Defaults to the past 30 days, including today. Schedule stock and daily outbound can be edited and are saved per row.",
"onHand": "On hand",
"scheduleStock": "Schedule stock",
"pastAvgOut": "Past avg outbound",
"dailyOutOverride": "Daily outbound override",
"categoryA": "Category A",
"categoryB": "Category B",
"categoryC": "Category C",
"clearScheduleStock": "Clear schedule stock",
"clearDailyOut": "Clear daily outbound override",
"forecastInvalid": "Enter a start date and a number of days (1 or more).",
"forecastOk": "Forecast calculated.",
"forecastError": "Calculation failed: {{status}} - {{detail}}",
"forecastUnknown": "Something went wrong.",
"doQtyDatesRequired": "Select a start date and an end date.",
"doQtyDateOrder": "The start date cannot be after the end date.",
"doQtyExportFail": "Could not export delivery quantities.",
"exportFromRequired": "Select a from date.",
"exportFail": "Could not export the file.",
"detailMissingId": "Cannot open this schedule because the schedule ID is missing.",
"serverError": "Server error {{status}}: {{detail}}",
"networkError": "Could not load the schedule. Check the connection and try again.",
"operationDone": "Done.",
"jobsCreated": "Created {{count}} job orders.",
"jobsNone": "No new job orders were created. These finished goods may already have a job in progress.",
"generateJobsFail": "Could not generate job orders.",
"refreshStockFailStatus": "Could not refresh stock ({{status}}){{detail}}",
"refreshStockFail": "Could not refresh stock.",
"excelNoSheet": "The Excel file has no worksheet.",
"excelEmpty": "The Excel file is empty.",
"excelNoItemCode": "No valid item codes were found.",
"importOutsideBom": "These item codes are outside the schedule BOM. The forecast will not use them; they will only be saved as stock overrides.\n\n{{preview}}",
"importOutsideBomMore": " ({{count}} in total)",
"sessionExpired": "Your session has expired, or you do not have permission. Sign in and try again.",
"importFailStatus": "Import failed (status {{status}}).{{detail}}",
"serverMessage": "\nServer message: {{message}}",
"importOk": "Imported {{count}} schedule stock rows.",
"importFailFormat": "Import failed. Check the file format.",
"saveFail": "Could not save.",
"confirmClearDailyOut": "Clear the daily outbound override for {{itemCode}}?",
"clearFail": "Could not clear.",
"updateFail": "Could not update.",
"confirmClearScheduleStock": "Clear the schedule stock override for this item?",
"loadRulesFail": "Could not load the schedule rules.",
"daysMustBePositive": "Days of cover must be a number greater than 0.",
"triggerNotAboveReplenish": "Trigger days cannot be greater than replenish days.",
"categoryNeedsDay": "Categories A, B, and C each need at least one production day.",
"rulesSaved": "Saved. Run Forecast schedule again to apply these rules.",
"rulesIntro": "Days of cover = stock ÷ average daily outbound. Production is scheduled when cover falls below the trigger, and stock is topped up to the replenish days. Items left blank follow these defaults. Schedules already created change only after you run Forecast schedule again.",
"defaultRules": "Default rules",
"normalDays": "Normal days",
"extraDays": "Extra-stock days",
"below": "Below",
"produceUpTo": "days",
"topUpTo": "Top up to",
"days": "days",
"extraWeekdays": "Extra-stock weekdays",
"noProductionUncategorised": "No production for unclassified items",
"skipExtra": "Skip extra stock",
"saveDefaults": "Save defaults",
"perItem": "Per item",
"searchItem": "Search item code or name",
"customOnly": "Custom rules only",
"triggerDays": "Trigger days",
"replenishDays": "Replenish days",
"productionDays": "Production days",
"extraStock": "Extra stock",
"daysBlankFollowDefault": "Days of cover must be greater than 0, or leave the field blank to follow the default.",
"customDaysRequired": "Choose at least one custom production day.",
"defaultPlaceholder": "Default",
"custom": "Custom",
"followDefault": "Follow default",
"applyExtra": "Apply extra stock",
"clearItemRule": "Clear this item's custom rule",
"weekdayMon": "Mon",
"weekdayTue": "Tue",
"weekdayWed": "Wed",
"weekdayThu": "Thu",
"weekdayFri": "Fri",
"weekdaySat": "Sat",
"weekdaySun": "Sun"
}

+ 121
- 1
src/i18n/zh/scheduling.json Visa fil

@@ -1,5 +1,125 @@
{
"title": "排程",
"Scheduling": "排程",
"ps": "排程"
"ps": "排程",
"pageTitle": "生產排程",
"scheduleSettings": "排期設定",
"exportPlan": "匯出計劃/物料需求Excel",
"exportDoQty": "匯出送貨單數量",
"forecast": "預測排期",
"productionDate": "生產日期",
"search": "搜索",
"detail": "詳細",
"estProdQty": "預計生產數",
"fgCount": "成品款數",
"detailTitle": "排期詳細: {{id}} ({{date}})",
"jobOrder": "工單號",
"itemCode": "物料編號",
"itemName": "物料名稱",
"avgDailyOut": "每日平均出貨量",
"stockBeforeOut": "出貨前預計存貨量",
"unit": "單位",
"daysOfCover": "可用日",
"batchQty": "生產量(批)",
"estPacks": "預計生產包數",
"priority": "優先度",
"generateJobs": "自動生成工單",
"close": "關閉",
"forecastDialogTitle": "準備生成預計排期",
"startDate": "開始日期",
"scheduleDays": "排期日數",
"cancel": "取消",
"calculateForecast": "計算預測排期",
"exportDoQtyHint": "選擇日期範圍(僅含具 BOM 的物料,數量為庫存單位)",
"endDate": "結束日期",
"export": "匯出",
"exportPlanDialogTitle": "匯出排期/物料用量預計",
"exportPlanHint": "選擇要匯出的起始日期",
"fromDate": "起始日期",
"refreshStock": "刷新系統庫存",
"exportScheduleStock": "匯出排期庫存 Excel",
"importScheduleStock": "匯入排期庫存 Excel(覆蓋設定)",
"tabItems": "物料設定",
"tabRules": "排期規則",
"itemsHint": "預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。",
"onHand": "庫存",
"scheduleStock": "設定排期庫存",
"pastAvgOut": "過去平均出貨量",
"dailyOutOverride": "設定排期每天出貨量",
"categoryA": "類別 A",
"categoryB": "類別 B",
"categoryC": "類別 C",
"clearScheduleStock": "清除設定排期庫存",
"clearDailyOut": "清除設定排期每天出貨量",
"forecastInvalid": "請輸入有效的開始日期及日數(1 或以上)。",
"forecastOk": "成功計算排期!",
"forecastError": "計算錯誤: {{status}} - {{detail}}",
"forecastUnknown": "發生不明狀況.",
"doQtyDatesRequired": "請選擇開始及結束日期。",
"doQtyDateOrder": "開始日期不可晚於結束日期。",
"doQtyExportFail": "匯出送貨單數量失敗。",
"exportFromRequired": "請選擇起始日期。",
"exportFail": "匯出失敗。",
"detailMissingId": "無法開啟詳細資料:缺少排期編號。",
"serverError": "伺服器錯誤 {{status}}: {{detail}}",
"networkError": "無法載入排期,請檢查連線後再試。",
"operationDone": "操作完成。",
"jobsCreated": "成功建立 {{count}} 張工單!",
"jobsNone": "沒有建立新工單(可能因為成品已有生產中的工單).",
"generateJobsFail": "生成工單失敗。",
"refreshStockFailStatus": "刷新庫存失敗({{status}}){{detail}}",
"refreshStockFail": "刷新庫存失敗。",
"excelNoSheet": "Excel 沒有工作表。",
"excelEmpty": "Excel 內容為空。",
"excelNoItemCode": "找不到任何有效的物料編號。",
"importOutsideBom": "注意:以下物料編號不在排期 BOM 範圍內,預測不會受影響,只會寫入覆蓋表。\n\n{{preview}}",
"importOutsideBomMore": " 等共 {{count}} 筆",
"sessionExpired": "登入已過期或沒有權限,請重新登入後再試。",
"importFailStatus": "匯入失敗(狀態碼 {{status}})。{{detail}}",
"serverMessage": "\n伺服器訊息:{{message}}",
"importOk": "已成功匯入並更新 {{count}} 筆排期庫存。",
"importFailFormat": "匯入失敗,請檢查檔案格式。",
"saveFail": "儲存失敗",
"confirmClearDailyOut": "確定要清除{{itemCode}}的設定排期每天出貨量嗎?",
"clearFail": "清除失敗",
"updateFail": "設定失敗",
"confirmClearScheduleStock": "確定要清除此物料的設定排期庫存嗎?",
"loadRulesFail": "讀取排期規則失敗",
"daysMustBePositive": "可用日必須是大於 0 的數字。",
"triggerNotAboveReplenish": "觸發日不可大於補貨日。",
"categoryNeedsDay": "類別 A / B / C 至少要各選一天可生產。",
"rulesSaved": "已儲存。下次按「預測排期」會用新規則。",
"rulesIntro": "可用日 = 存貨 ÷ 每日平均出貨量。低於觸發日就安排生產,並補到補貨日。留空的物料跟隨這裡的預設。已產生的排期要再按「預測排期」才會更新。",
"defaultRules": "預設規則",
"normalDays": "一般日",
"extraDays": "多備日",
"below": "低於",
"produceUpTo": "日生產",
"topUpTo": "補至",
"days": "日",
"extraWeekdays": "多備星期",
"noProductionUncategorised": "未分類物料不生產",
"skipExtra": "不套用多備",
"saveDefaults": "儲存預設",
"perItem": "個別物料",
"searchItem": "搜尋物料編號或名稱",
"customOnly": "只顯示已自訂",
"triggerDays": "觸發日",
"replenishDays": "補貨日",
"productionDays": "可生產日",
"extraStock": "多備",
"daysBlankFollowDefault": "可用日必須是大於 0 的數字,或留空跟隨預設。",
"customDaysRequired": "自訂可生產日至少要選一天。",
"defaultPlaceholder": "預設",
"custom": "自訂",
"followDefault": "跟隨預設",
"applyExtra": "套用多備",
"clearItemRule": "清除此物料的自訂規則",
"weekdayMon": "一",
"weekdayTue": "二",
"weekdayWed": "三",
"weekdayThu": "四",
"weekdayFri": "五",
"weekdaySat": "六",
"weekdaySun": "日"
}

Laddar…
Avbryt
Spara