2 Commitit

Tekijä SHA1 Viesti Päivämäärä
  Harry Groves 408086acc7 Use new anonymous floor and bin labels 3 viikkoa sitten
  Harry Groves b6f41ecd3c EN support for scheduling page 3 viikkoa sitten
53 muutettua tiedostoa jossa 793 lisäystä ja 448 poistoa
  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 Näytä tiedosto

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


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




+ 67
- 56
src/app/(main)/ps/PsRulesTab.tsx Näytä tiedosto

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


const WEEKDAYS = [ 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; ] as const;


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


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


<section className="rounded-lg border border-slate-200 p-4 dark:border-slate-700"> <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"> <div className="grid gap-4 md:grid-cols-2">
<DayPair <DayPair
title="一般日"
title={t("normalDays")}
red={plant.redLineDays} red={plant.redLineDays}
safety={plant.safetyStockDays} safety={plant.safetyStockDays}
onRed={(value) => setPlant((prev) => ({ ...prev, redLineDays: value }))} onRed={(value) => setPlant((prev) => ({ ...prev, redLineDays: value }))}
onSafety={(value) => setPlant((prev) => ({ ...prev, safetyStockDays: value }))} onSafety={(value) => setPlant((prev) => ({ ...prev, safetyStockDays: value }))}
/> />
<DayPair <DayPair
title="多備日"
title={t("extraDays")}
red={plant.prepareMoreRedLineDays} red={plant.prepareMoreRedLineDays}
safety={plant.prepareMoreSafetyStockDays} safety={plant.prepareMoreSafetyStockDays}
onRed={(value) => setPlant((prev) => ({ ...prev, prepareMoreRedLineDays: value }))} onRed={(value) => setPlant((prev) => ({ ...prev, prepareMoreRedLineDays: value }))}
@@ -247,33 +249,33 @@ export default function PsRulesTab({ items }: { items: ItemOption[] }) {
</div> </div>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <div className="mt-4 grid gap-4 md:grid-cols-2">
<DayChecks <DayChecks
label="多備星期"
label={t("extraWeekdays")}
selected={plant.prepareMoreDays} selected={plant.prepareMoreDays}
onChange={(days) => setPlant((prev) => ({ ...prev, prepareMoreDays: days }))} onChange={(days) => setPlant((prev) => ({ ...prev, prepareMoreDays: days }))}
/> />
<DayChecks <DayChecks
label="未分類物料不生產"
label={t("noProductionUncategorised")}
selected={plant.noProductionDays} selected={plant.noProductionDays}
onChange={(days) => setPlant((prev) => ({ ...prev, noProductionDays: days }))} onChange={(days) => setPlant((prev) => ({ ...prev, noProductionDays: days }))}
/> />
</div> </div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<CategoryDays <CategoryDays
label="類別 A"
label={t("categoryA")}
days={plant.coffeeDays} days={plant.coffeeDays}
skip={plant.coffeeSkipPrepareMore} skip={plant.coffeeSkipPrepareMore}
onDays={(days) => setPlant((prev) => ({ ...prev, coffeeDays: days }))} onDays={(days) => setPlant((prev) => ({ ...prev, coffeeDays: days }))}
onSkip={(skip) => setPlant((prev) => ({ ...prev, coffeeSkipPrepareMore: skip }))} onSkip={(skip) => setPlant((prev) => ({ ...prev, coffeeSkipPrepareMore: skip }))}
/> />
<CategoryDays <CategoryDays
label="類別 B"
label={t("categoryB")}
days={plant.teaDays} days={plant.teaDays}
skip={plant.teaSkipPrepareMore} skip={plant.teaSkipPrepareMore}
onDays={(days) => setPlant((prev) => ({ ...prev, teaDays: days }))} onDays={(days) => setPlant((prev) => ({ ...prev, teaDays: days }))}
onSkip={(skip) => setPlant((prev) => ({ ...prev, teaSkipPrepareMore: skip }))} onSkip={(skip) => setPlant((prev) => ({ ...prev, teaSkipPrepareMore: skip }))}
/> />
<CategoryDays <CategoryDays
label="類別 C"
label={t("categoryC")}
days={plant.lemonDays} days={plant.lemonDays}
skip={plant.lemonSkipPrepareMore} skip={plant.lemonSkipPrepareMore}
onDays={(days) => setPlant((prev) => ({ ...prev, lemonDays: days }))} 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" 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" }} />} {savingPlant && <CircularProgress size={14} sx={{ display: "block" }} />}
儲存預設
{t("saveDefaults")}
</button> </button>
</div> </div>
</section> </section>


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


+ 117
- 104
src/app/(main)/ps/page.tsx Näytä tiedosto

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


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


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


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


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


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


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


// Warn user about itemCodes that are not in BOM scope (won't affect forecast) // Warn user about itemCodes that are not in BOM scope (won't affect forecast)
if (invalidCodes.length) { if (invalidCodes.length) {
const preview = invalidCodes.slice(0, 10).join(", "); 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`, { 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) { if (resp.status === 401 || resp.status === 403) {
alert("登入已過期或沒有權限,請重新登入後再試。");
alert(t("sessionExpired"));
return; return;
} }


if (!resp.ok) { if (!resp.ok) {
const msg = await resp.text().catch(() => ""); 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; return;
} }


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


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


const handleClearFakeOnHand = async (itemCode: string) => { const handleClearFakeOnHand = async (itemCode: string) => {
if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return;
if (!confirm(t("confirmClearScheduleStock"))) return;
setFakeOnHandClearingCode(itemCode); setFakeOnHandClearingCode(itemCode);
try { try {
const response = await clientAuthFetch( const response = await clientAuthFetch(
@@ -609,11 +619,11 @@ export default function ProductionSchedulePage() {
) )
); );
} else { } else {
alert("清除失敗");
alert(t("clearFail"));
} }
} catch (e) { } catch (e) {
console.error("clearFakeOnHand Error:", e); console.error("clearFakeOnHand Error:", e);
alert("清除失敗");
alert(t("clearFail"));
} finally { } finally {
setFakeOnHandClearingCode(null); setFakeOnHandClearingCode(null);
} }
@@ -622,7 +632,7 @@ export default function ProductionSchedulePage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageTitleBar <PageTitleBar
title="生產排程"
title={t("pageTitle")}
actions={ actions={
<> <>
<button <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" 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 }} /> <Settings sx={{ fontSize: 16 }} />
排期設定
{t("scheduleSettings")}
</button> </button>
<button <button
type="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" 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 }} /> <Download sx={{ fontSize: 16 }} />
匯出計劃/物料需求Excel
{t("exportPlan")}
</button> </button>
<button <button
type="button" type="button"
@@ -652,7 +662,7 @@ export default function ProductionSchedulePage() {
) : ( ) : (
<Download sx={{ fontSize: 16 }} /> <Download sx={{ fontSize: 16 }} />
)} )}
匯出送貨單數量
{t("exportDoQty")}
</button> </button>
<button <button
type="button" type="button"
@@ -665,7 +675,7 @@ export default function ProductionSchedulePage() {
) : ( ) : (
<ShowChart sx={{ fontSize: 16 }} /> <ShowChart sx={{ fontSize: 16 }} />
)} )}
預測排期
{t("forecast")}
</button> </button>
</> </>
} }
@@ -675,7 +685,7 @@ export default function ProductionSchedulePage() {
{/* Query Bar */} {/* Query Bar */}
<div className="app-search-criteria mb-4 flex flex-wrap items-center gap-2 p-4"> <div className="app-search-criteria mb-4 flex flex-wrap items-center gap-2 p-4">
<label className="sr-only" htmlFor="ps-search-date"> <label className="sr-only" htmlFor="ps-search-date">
生產日期
{t("productionDate")}
</label> </label>
<input <input
id="ps-search-date" 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" 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 }} /> <Search sx={{ fontSize: 16 }} />
搜索
{t("search")}
</button> </button>
</div> </div>


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


+ 4
- 3
src/app/api/chart/client.ts Näytä tiedosto

@@ -1,3 +1,4 @@
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api"; 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). */ /** 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 */ /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */
export async function fetchStaffDeliveryPerformance( export async function fetchStaffDeliveryPerformance(
@@ -591,8 +592,8 @@ export async function fetchStaffDeliveryPerformance(
if (endDate) p.set("endDate", endDate); if (endDate) p.set("endDate", endDate);
(staffNos ?? []).forEach((no) => p.append("staffNo", no)); (staffNos ?? []).forEach((no) => p.append("staffNo", no));
if (storeFilter === "null_only") p.set("storeIdNull", "true"); 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 q = p.toString();
const res = await clientAuthFetch( const res = await clientAuthFetch(
q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance` q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance`


+ 2
- 1
src/app/api/do/actions.tsx Näytä tiedosto

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




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




+ 3
- 2
src/app/api/doworkbench/actions.ts Näytä tiedosto

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


import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { BASE_API_URL } from "@/config/api"; import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil"; import { serverFetchJson } from "@/app/utils/fetchUtil";
@@ -244,7 +245,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelection(
storeId?: string, storeId?: string,
truck?: string, truck?: string,
releaseType?: 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 floor?: string
): Promise<ReleasedDoPickOrderListItem[]> { ): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -266,7 +267,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
truck?: string, truck?: string,
requiredDeliveryDate?: string, requiredDeliveryDate?: string,
releaseType?: 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 floor?: string
): Promise<ReleasedDoPickOrderListItem[]> { ): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();


+ 2
- 1
src/app/api/jo/actions.ts Näytä tiedosto

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


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


+ 2
- 1
src/app/api/pickOrder/actions.ts Näytä tiedosto

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


+ 3
- 2
src/app/api/settings/deliveryOrderFloor/constants.ts Näytä tiedosto

@@ -1,5 +1,6 @@
import { SETTING_SUPPLIERS_A, SETTING_SUPPLIERS_B } from "@/config/demoFloors";
/** `settings.name`:逗號分隔 supplier **code**(須與後端讀取邏輯一致)。 */ /** `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"; export const SETTING_DO_FLOOR_CATEGORY = "DO_FLOOR";

+ 3
- 2
src/app/utils/formatUtil.ts Näytä tiedosto

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

+ 4
- 3
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx Näytä tiedosto

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


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


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


+ 17
- 16
src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx Näytä tiedosto

@@ -1,5 +1,6 @@
"use client"; "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 React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EditOutlined from "@mui/icons-material/EditOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined";
@@ -57,7 +58,7 @@ function formatBracketList(codesCsv: string): string {
return `[${n.split(",").join(", ")}]`; return `[${n.split(",").join(", ")}]`;
} }


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


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


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


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


+ 9
- 8
src/components/DoDetail/DoDetail.tsx Näytä tiedosto

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


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


+ 10
- 9
src/components/DoSearch/DoSearch.tsx Näytä tiedosto

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


import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { DoResult } from "@/app/api/do"; import { DoResult } from "@/app/api/do";
import { import {
DoSearchAll, 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 SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>;
type SearchParamNames = keyof SearchBoxInputs; 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 // put all this into a new component
// ConsoDoForm // ConsoDoForm
@@ -103,7 +104,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [isWorkbench, setIsWorkbench] = useState(false); 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 [searchBoxResetKey, setSearchBoxResetKey] = useState(0);
const [pagingController, setPagingController] = useState({ const [pagingController, setPagingController] = useState({
pageNum: 1, pageNum: 1,
@@ -326,10 +327,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea


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


+ 3
- 2
src/components/DoSearchWorkbench/DoSearchWorkbench.tsx Näytä tiedosto

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


import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { DoResult } from "@/app/api/do"; import { DoResult } from "@/app/api/do";
import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo } from "@/app/api/do/actions"; import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo } from "@/app/api/do/actions";
import { import {
@@ -150,8 +151,8 @@ const DoSearchWorkbench: React.FC<Props> = ({
paramName: "floor", paramName: "floor",
type: "select-labelled", type: "select-labelled",
options: [ 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 Näytä tiedosto

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


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


+ 4
- 3
src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx Näytä tiedosto

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


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


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


+ 24
- 23
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx Näytä tiedosto

@@ -1,5 +1,6 @@
"use client"; "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 { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel } from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -53,7 +54,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
const { t } = useTranslation("pickOrder"); const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null }; const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined; 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 [selectedTruck, setSelectedTruck] = useState<string>("");
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [truckCounts2F, setTruckCounts2F] = useState<{ truck: string; count: number }[]>([]); const [truckCounts2F, setTruckCounts2F] = useState<{ truck: string; count: number }[]>([]);
@@ -92,7 +93,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
[ticketFloor], [ticketFloor],
); );
const defaultTruckCount = const defaultTruckCount =
(ticketFloor === "2/F" ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0;
(ticketFloor === FLOOR_A ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0;
const etraEnterInFlightRef = useRef(false); const etraEnterInFlightRef = useRef(false);
const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(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 === "tomorrow") dateParam = dayjs().add(1, "day").format("YYYY-MM-DD");
else if (selectedDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD"); else if (selectedDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD");
const [s2, s4] = await Promise.all([ 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); setSummary2F(s2);
setSummary4F(s4); setSummary4F(s4);
@@ -217,8 +218,8 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
startFullTimer(); startFullTimer();
try { try {
const [list2F, list4F] = await Promise.all([ 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 groupByTruck = (list: { truckLanceCode?: string | null }[]) => {
const map: Record<string, number> = {}; const map: Record<string, number> = {};
@@ -441,8 +442,8 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<InputLabel id="ticket-floor-select-label">{t("Floor ticket")}</InputLabel> <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"] })}> <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> </Select>
</FormControl> </FormControl>
</Box> </Box>
@@ -481,10 +482,10 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({


{!inEtraUi ? ( {!inEtraUi ? (
<Grid container spacing={2}> <Grid container spacing={2}>
{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}> <Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start"> <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 }}> <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() : ( {isLoadingSummary ? <Typography variant="caption">{t("Loading...")}</Typography> : !summary2F?.rows?.length ? renderNoEntry() : (
<Grid container spacing={1}> <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> <Typography variant="body2" sx={{ fontWeight: 600, minWidth: { sm: 60 } }}>{row.truckDepartureTime}</Typography>
<Stack direction="row" flexWrap="wrap" sx={{ gap: 1 }}> <Stack direction="row" flexWrap="wrap" sx={{ gap: 1 }}>
{row.lanes.map((lane) => ( {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})`} {`${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`}
</Button> </Button>
))} ))}
@@ -509,10 +510,10 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
</Grid> </Grid>
)} )}


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


{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}> <Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start"> <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 }}> <Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{truckCounts2F.length === 0 ? renderNoEntry() : ( {truckCounts2F.length === 0 ? renderNoEntry() : (
<Grid container spacing={1}> <Grid container spacing={1}>
{truckCounts2F.map(({ truck, count }) => ( {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 <Button
variant="outlined" variant="outlined"
onClick={() => { onClick={() => {
clearModalEtraContext(); clearModalEtraContext();
setIsDefaultTruck(false); setIsDefaultTruck(false);
setSelectedStore("2/F");
setSelectedStore(FLOOR_A);
setSelectedTruck(truck); setSelectedTruck(truck);
setModalOpen(true); setModalOpen(true);
}} }}
@@ -616,21 +617,21 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
</Grid> </Grid>
)} )}


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


+ 4
- 3
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx Näytä tiedosto

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


import { FLOOR_A_KEY, FLOOR_B_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import { import {
Box, Box,
Button, Button,
@@ -742,8 +743,8 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
.toUpperCase() .toUpperCase()
.replace(/\s/g, ""); .replace(/\s/g, "");
const floorKey = storeId.replace(/\//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; return undefined;
}, [fgPickOrders]); }, [fgPickOrders]);


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


+ 3
- 1
src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx Näytä tiedosto

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


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




+ 3
- 2
src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx Näytä tiedosto

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


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


+ 4
- 3
src/components/DoWorkbench/workbenchLanePanelPrefs.ts Näytä tiedosto

@@ -1,5 +1,6 @@
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
export type WorkbenchLaneDateKey = "today" | "tomorrow" | "dayAfterTomorrow"; 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. */ /** Tab 0 lane assignment filters — lifted to DoWorkbenchTabs so they survive tab switches. */
export type WorkbenchLanePanelPrefs = { export type WorkbenchLanePanelPrefs = {
@@ -10,7 +11,7 @@ export type WorkbenchLanePanelPrefs = {


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


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


export function isWorkbenchLaneFloor(v: string): v is WorkbenchLaneFloor { 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 Näytä tiedosto

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


+ 3
- 2
src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx Näytä tiedosto

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


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




+ 20
- 19
src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx Näytä tiedosto

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


import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Alert, Alert,
@@ -29,7 +30,7 @@ import {
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { useTranslation } from "react-i18next"; 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 BreakdownDimension = "lane" | "shop" | "floor";


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


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


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


+ 27
- 26
src/components/FinishedGoodSearch/FinishedGoodFloorLanePanel.tsx Näytä tiedosto

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


const [selectedDate, setSelectedDate] = useState<string>("today"); const [selectedDate, setSelectedDate] = useState<string>("today");
const [releaseType, setReleaseType] = useState<string>("batch"); 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 = () => { const startFullTimer = () => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const key = "__FG_FLOOR_FULL_TIMER_STARTED__" as const; 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([ 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); setSummary2F(s2);
setSummary4F(s4); setSummary4F(s4);
@@ -117,8 +118,8 @@ const [defaultDateScope, setDefaultDateScope] = useState<"today" | "before">("to
startFullTimer(); startFullTimer();
try { try {
const [list2F, list4F] = await Promise.all([ 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 groupByTruck = (list: { truckLanceCode?: string | null }[]) => {
const map: Record<string, number> = {}; const map: Record<string, number> = {};
@@ -388,10 +389,10 @@ const getDateLabel = (offset: number) => {
id="ticket-floor-select" id="ticket-floor-select"
value={ticketFloor} value={ticketFloor}
label={t("Floor ticket")} 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> </Select>
</FormControl> </FormControl>
</Box> </Box>
@@ -414,7 +415,7 @@ const getDateLabel = (offset: number) => {
{/* Grid containing both floors */} {/* Grid containing both floors */}
<Grid container spacing={2}> <Grid container spacing={2}>
{/* 2/F 楼层面板 */} {/* 2/F 楼层面板 */}
{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}> <Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start"> <Stack direction="row" spacing={2} alignItems="flex-start">
{/* Floor Label */} {/* Floor Label */}
@@ -426,7 +427,7 @@ const getDateLabel = (offset: number) => {
pt: 1 pt: 1
}} }}
> >
2/F
{FLOOR_A}
</Typography> </Typography>
{/* Content Box */} {/* Content Box */}
@@ -495,7 +496,7 @@ const getDateLabel = (offset: number) => {
disabled={lane.unassigned === 0 || isAssigning} disabled={lane.unassigned === 0 || isAssigning}
onClick={() => onClick={() =>
handleLaneButtonClick( handleLaneButtonClick(
"2/F",
FLOOR_A,
row.truckDepartureTime, row.truckDepartureTime,
lane.truckLanceCode, lane.truckLanceCode,
null, null,
@@ -530,7 +531,7 @@ const getDateLabel = (offset: number) => {
</Stack> </Stack>
</Grid> </Grid>
)} )}
{ticketFloor === "4/F" && (
{ticketFloor === FLOOR_B && (
<Grid item xs={12}> <Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start"> <Stack direction="row" spacing={2} alignItems="flex-start">
{/* Floor Label */} {/* Floor Label */}
@@ -542,7 +543,7 @@ const getDateLabel = (offset: number) => {
pt: 1 pt: 1
}} }}
> >
4/F
{FLOOR_B}
</Typography> </Typography>
{/* Content Box */} {/* Content Box */}
@@ -613,7 +614,7 @@ const getDateLabel = (offset: number) => {
disabled={slot.lane.unassigned === 0 || isAssigning} disabled={slot.lane.unassigned === 0 || isAssigning}
onClick={() => onClick={() =>
handleLaneButtonClick( handleLaneButtonClick(
"4/F",
FLOOR_B,
slot.truckDepartureTime, slot.truckDepartureTime,
slot.lane.truckLanceCode, slot.lane.truckLanceCode,
slot.lane.loadingSequence ?? null, slot.lane.loadingSequence ?? null,
@@ -720,11 +721,11 @@ const getDateLabel = (offset: number) => {
</Box> </Box>
</Grid> </Grid>
{ticketFloor === "2/F" && (
{ticketFloor === FLOOR_A && (
<Grid item xs={12}> <Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start"> <Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}> <Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>
2/F
{FLOOR_A}
</Typography> </Typography>
<Box <Box
sx={{ sx={{
@@ -751,7 +752,7 @@ const getDateLabel = (offset: number) => {
) : ( ) : (
<Grid container spacing={1}> <Grid container spacing={1}>
{truckCounts2F.map(({ truck, count }) => ( {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 <Button
@@ -759,7 +760,7 @@ const getDateLabel = (offset: number) => {
size="medium" size="medium"
onClick={() => { onClick={() => {
setIsDefaultTruck(false); setIsDefaultTruck(false);
setSelectedStore("2/F");
setSelectedStore(FLOOR_A);
setSelectedTruck(truck); setSelectedTruck(truck);
setModalOpen(true); setModalOpen(true);
}} }}
@@ -788,11 +789,11 @@ const getDateLabel = (offset: number) => {
</Stack> </Stack>
</Grid> </Grid>
)} )}
{ticketFloor === "4/F" && (
{ticketFloor === FLOOR_B && (
<Grid item xs={12}> <Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start"> <Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}> <Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>
4/F
{FLOOR_B}
</Typography> </Typography>
<Box <Box
sx={{ sx={{
@@ -819,7 +820,7 @@ const getDateLabel = (offset: number) => {
) : ( ) : (
<Grid container spacing={1}> <Grid container spacing={1}>
{truckCounts4F.map(({ truck, count }) => ( {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 <Button
@@ -827,7 +828,7 @@ const getDateLabel = (offset: number) => {
size="medium" size="medium"
onClick={() => { onClick={() => {
setIsDefaultTruck(false); setIsDefaultTruck(false);
setSelectedStore("4/F");
setSelectedStore(FLOOR_B);
setSelectedTruck(truck); setSelectedTruck(truck);
setModalOpen(true); setModalOpen(true);
}} }}
@@ -877,7 +878,7 @@ const getDateLabel = (offset: number) => {
variant="outlined" variant="outlined"
size="medium" size="medium"
onClick={() => { onClick={() => {
setSelectedStore("4/F"); // 或用專門標示 Truck X
setSelectedStore(FLOOR_B); // 或用專門標示 Truck X
setSelectedTruck("車線-X"); setSelectedTruck("車線-X");
setIsDefaultTruck(true); setIsDefaultTruck(true);
setDefaultDateScope("before"); // 類似上一輪說的 dateScope setDefaultDateScope("before"); // 類似上一輪說的 dateScope
@@ -902,8 +903,8 @@ const getDateLabel = (offset: number) => {
const loadCounts = async () => { const loadCounts = async () => {
try { try {
const [list2F, list4F] = await Promise.all([ 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 groupByTruck = (list: { truckLanceCode?: string | null }[]) => {
const map: Record<string, number> = {}; const map: Record<string, number> = {};


+ 4
- 3
src/components/FinishedGoodSearch/FinishedGoodSearch.tsx Näytä tiedosto

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


+ 6
- 5
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx Näytä tiedosto

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


import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import { import {
Box, Box,
Button, Button,
@@ -786,7 +787,7 @@ const PickExecution: React.FC<Props> = ({
.trim() .trim()
.toUpperCase() .toUpperCase()
.replace(/\s/g, ""); .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, ""); const floorKey = storeId.replace(/\//g, "");
return floorKey ? `${floorKey}-` : ""; return floorKey ? `${floorKey}-` : "";
}, [fgPickOrders]); }, [fgPickOrders]);
@@ -796,8 +797,8 @@ const PickExecution: React.FC<Props> = ({
.toUpperCase() .toUpperCase()
.replace(/\s/g, ""); .replace(/\s/g, "");
const floorKey = storeId.replace(/\//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; return undefined;
}, [fgPickOrders]); }, [fgPickOrders]);
// Add these missing state variables after line 352 // Add these missing state variables after line 352
@@ -1212,14 +1213,14 @@ const PickExecution: React.FC<Props> = ({
// 将层级数据转换为平铺格式(用于表格显示) // 将层级数据转换为平铺格式(用于表格显示)
const flatLotData: any[] = []; 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 ?? "") const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? "")
.trim() .trim()
.toUpperCase() .toUpperCase()
.replace(/\//g, "") .replace(/\//g, "")
.replace(/\s/g, ""); .replace(/\s/g, "");
const pickOrderLinesForDisplay = const pickOrderLinesForDisplay =
doFloorKey === "2F"
doFloorKey === FLOOR_A_KEY
? [...(mergedPickOrder.pickOrderLines || [])].sort( ? [...(mergedPickOrder.pickOrderLines || [])].sort(
(a: any, b: any) => { (a: any, b: any) => {
const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999; const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999;


+ 4
- 3
src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx Näytä tiedosto

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


import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { import {
Modal, Modal,
Box, Box,
@@ -37,7 +38,7 @@ export type ReleasedDoPickListBridge = {
storeId?: string, storeId?: string,
truck?: string, truck?: string,
releaseType?: 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 floor?: string
) => Promise<ReleasedDoPickOrderListItem[]>; ) => Promise<ReleasedDoPickOrderListItem[]>;
/** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */ /** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */
@@ -47,7 +48,7 @@ export type ReleasedDoPickListBridge = {
truck?: string, truck?: string,
requiredDeliveryDate?: string, requiredDeliveryDate?: string,
releaseType?: 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 floor?: string
) => Promise<ReleasedDoPickOrderListItem[]>; ) => Promise<ReleasedDoPickOrderListItem[]>;
assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>; assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>;
@@ -75,7 +76,7 @@ interface Props {
*/ */
filterRequiredDeliveryDate?: string; 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; truckXFloor?: string;
} }


+ 2
- 1
src/components/FinishedGoodSearch/TruckRoutingSummaryTab.tsx Näytä tiedosto

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


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


+ 6
- 4
src/components/InventorySearch/LotLabelPrintModal.tsx Näytä tiedosto

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


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


const selectedPrinter = useMemo(() => { const selectedPrinter = useMemo(() => {


+ 6
- 5
src/components/JoWorkbench/JoPickOrderList.tsx Näytä tiedosto

@@ -1,4 +1,5 @@
"use client"; "use client";
import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
Box, Box,
@@ -45,7 +46,7 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined); const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({ planStart: today }); 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>[] = [ const searchCriteria: Criterion<any>[] = [
{ label: t("Job Order Code"), paramName: "jobOrderCode", type: "text" }, { label: t("Job Order Code"), paramName: "jobOrderCode", type: "text" },
@@ -76,9 +77,9 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
paramName: "floor", paramName: "floor",
type: "select-labelled", type: "select-labelled",
options: [ 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" }, { label: t("No Lot"), value: "NO_LOT" },
], ],
}, },
@@ -86,7 +87,7 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {


const selectedFloor: FloorFilter = (() => { const selectedFloor: FloorFilter = (() => {
const floor = String(searchQuery.floor || "ALL"); 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 floor;
} }
return "ALL"; return "ALL";


+ 19
- 13
src/components/JoWorkbench/newJobPickExecution.tsx Näytä tiedosto

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


import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import { import {
Box, Box,
Button, Button,
@@ -1017,17 +1018,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
[], [],
); );
const extractFloor = (lot: any): string => { 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 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 combinedLotData = useMemo(() => {
const lots = getAllLotsFromHierarchical(jobOrderData); const lots = getAllLotsFromHierarchical(jobOrderData);
@@ -1156,8 +1162,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
.toUpperCase() .toUpperCase()
.replace(/\s/g, ""); .replace(/\s/g, "");
const floorKey = storeId.replace(/\//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; return undefined;
}, [workbenchStoreId]); }, [workbenchStoreId]);


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


+ 10
- 9
src/components/Jodetail/JoPickOrderList.tsx Näytä tiedosto

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


+ 2
- 1
src/components/Jodetail/JodetailSearch.tsx Näytä tiedosto

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


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


+ 22
- 21
src/components/Jodetail/PlasticBoxCartonDashboardTab.tsx Näytä tiedosto

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


import { FLOOR_A, FLOOR_B, FLOOR_D } from "@/config/demoFloors";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Alert, 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. */ /** Day.js has no typed `dayjs.invalid()` factory; Invalid Date parses to an invalid Dayjs instance. */
const DAYJS_INVALID = dayjs(new Date(Number.NaN)); 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 = { type DailySummaryRow = {
date: string; date: string;
@@ -81,21 +82,21 @@ const addRecordQty = (
const q3 = Number(record.plasticBoxCartonQty3f ?? 0); const q3 = Number(record.plasticBoxCartonQty3f ?? 0);
const q4 = Number(record.plasticBoxCartonQty4f ?? 0); const q4 = Number(record.plasticBoxCartonQty4f ?? 0);


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


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


const chartSeries = useMemo( 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) }, { name: t("Total plastic box carton qty"), data: rows.map((row) => row.total) },
], ],
[rows, t], [rows, t],
@@ -351,17 +352,17 @@ const PlasticBoxCartonDashboardTab: React.FC = () => {
["", "", "", "", ""], ["", "", "", "", ""],
[ [
t("Date"), 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"), t("Total plastic box carton qty"),
], ],
...dailyRows.map((row) => [row.date, row.floor2F, row.floor3F, row.floor4F, row.total]), ...dailyRows.map((row) => [row.date, row.floor2F, row.floor3F, row.floor4F, row.total]),
["", "", "", "", ""], ["", "", "", "", ""],
[t("Summary"), "", "", "", ""], [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, "", "", ""], [t("Total plastic box carton qty"), reportSummary.total, "", "", ""],
]; ];


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


+ 4
- 3
src/components/Jodetail/completeJobOrderRecord.tsx Näytä tiedosto

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


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


+ 16
- 8
src/components/Jodetail/newJobPickExecution.tsx Näytä tiedosto

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


import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import { import {
Box, Box,
Button, Button,
@@ -684,15 +685,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
return allLots; return allLots;
}, []); }, []);
const extractFloor = (lot: any): string => { 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 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 combinedLotData = useMemo(() => {
const lots = getAllLotsFromHierarchical(jobOrderData); const lots = getAllLotsFromHierarchical(jobOrderData);
@@ -2613,7 +2621,7 @@ const sortedData = [...sourceData].sort((a, b) => {
const floorB = extractFloor(b); const floorB = extractFloor(b);
const orderA = floorSortOrder(floorA); const orderA = floorSortOrder(floorA);
const orderB = floorSortOrder(floorB); 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 // 同楼层再按 routerIndex、pickOrderCode、lotNo
const aIndex = a.routerIndex ?? 0; const aIndex = a.routerIndex ?? 0;
const bIndex = b.routerIndex ?? 0; const bIndex = b.routerIndex ?? 0;


+ 5
- 4
src/components/Jodetail/pickRecordHelpers.ts Näytä tiedosto

@@ -1,10 +1,11 @@
"use client"; "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 Swal from "sweetalert2";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { fetchPickRecordPlasticBoxCartonQty } from "@/app/api/jo/actions"; 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 { export interface AllFloorsPlasticBoxCartonQtyResult {
qty2f: number; qty2f: number;
@@ -115,9 +116,9 @@ export async function promptAllFloorsPlasticBoxCartonQty(
title: t("Enter all floors plastic box carton qty"), title: t("Enter all floors plastic box carton qty"),
html: ` html: `
<div style="display:flex;flex-direction:column;gap:10px;text-align:left;"> <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> </div>
`, `,
showCancelButton: true, showCancelButton: true,


+ 9
- 8
src/components/LotSuggestionRules/LotSuggestionRules.tsx Näytä tiedosto

@@ -1,5 +1,6 @@
"use client"; "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 React, { useState } from "react";
import InfoOutlined from "@mui/icons-material/InfoOutlined"; import InfoOutlined from "@mui/icons-material/InfoOutlined";
import { import {
@@ -33,17 +34,17 @@ const sections: { title: string; lines: string[] }[] = [
{ {
title: "送貨揀貨的樓層", title: "送貨揀貨的樓層",
lines: [ 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: "目前不建議的倉位", title: "目前不建議的倉位",
lines: [ lines: [
"2F-W202-01-00",
"2F-W200-#A-00",
LOC_HOLD,
LOC_WIP,
"這兩個倉位寫在程式裡,畫面還不能改。", "這兩個倉位寫在程式裡,畫面還不能改。",
], ],
}, },
@@ -51,8 +52,8 @@ const sections: { title: string; lines: string[] }[] = [
title: "工單提料(和送貨揀貨不同)", title: "工單提料(和送貨揀貨不同)",
lines: [ lines: [
"同樣是先到期先出。", "同樣是先到期先出。",
"不建議 2F-W202-01-00。半成品會先試 2F-W200-#A-00。",
"工單可以使用 3F。",
"不建議 " + LOC_HOLD + "。半成品會先試 " + LOC_WIP + "。",
"工單可以使用 " + FLOOR_D_KEY + "。",
], ],
}, },
]; ];


+ 22
- 21
src/components/Shop/RouteBoard.tsx Näytä tiedosto

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


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


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


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


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


+ 4
- 3
src/components/Shop/Shop.tsx Näytä tiedosto

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


import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { import {
Box, Box,
Button, Button,
@@ -134,14 +135,14 @@ const Shop: React.FC = () => {
const loadingSeqNum = loadingSeq != null && loadingSeq !== undefined ? Number(loadingSeq) : null; const loadingSeqNum = loadingSeq != null && loadingSeq !== undefined ? Number(loadingSeq) : null;
const hasLoadingSequence = loadingSeqNum !== null && !isNaN(loadingSeqNum) && loadingSeqNum !== 0; 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) // 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; const storeId = (truck as any).store_id || (truck as any).storeId || (truck as any).Store_id;
let storeIdValid = false; let storeIdValid = false;
if (storeId != null && storeId !== undefined && storeId !== "") { if (storeId != null && storeId !== undefined && storeId !== "") {
const storeIdStr = String(storeId).trim(); 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; storeIdValid = true;
} else { } else {
const storeIdNum = Number(storeId); const storeIdNum = Number(storeId);


+ 3
- 2
src/components/Shop/ShopDetail.tsx Näytä tiedosto

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


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


const lane = selectedTruckLane!; const lane = selectedTruckLane!;
const storeIdStr = normalizeStoreId(lane.storeId) || "2F";
const storeIdStr = normalizeStoreId(lane.storeId) || FLOOR_A_KEY;
const remarkValue = 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() ? String(lane.remark).trim()
: null; : null;




+ 6
- 5
src/components/Shop/TruckLane.tsx Näytä tiedosto

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


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


@@ -373,8 +374,8 @@ const TruckLane: React.FC = () => {
}} }}
disabled={saving} 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> </Select>
</FormControl> </FormControl>
</Grid> </Grid>


+ 9
- 8
src/components/Shop/TruckLaneDetail.tsx Näytä tiedosto

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


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


await Promise.all( await Promise.all(
withIds.map((row) => { withIds.map((row) => {
const storeIdStr = normalizeStoreId(row.storeId) || "2F";
const storeIdStr = normalizeStoreId(row.storeId) || FLOOR_A_KEY;
let remarkValue: string | null = null; 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(); remarkValue = String(row.remark).trim();
} }
return updateTruckLaneClient({ return updateTruckLaneClient({
@@ -579,8 +580,8 @@ const TruckLaneDetail: React.FC = () => {
try { try {
const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId) ? 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 = ""; let departureTimeStr = "";
if (truckData.departureTime) { if (truckData.departureTime) {
@@ -597,8 +598,8 @@ const TruckLaneDetail: React.FC = () => {
); );
const shopIdValue = foundShop ? foundShop.id : null; 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 // Check if there's an "Unassign" row for this truck lane that should be replaced
let unassignTruck: Truck | null = null; let unassignTruck: Truck | null = null;
@@ -909,7 +910,7 @@ const TruckLaneDetail: React.FC = () => {
{(editingRowIndex === index || massEditing) ? ( {(editingRowIndex === index || massEditing) ? (
(() => { (() => {
const storeIdValue = truckData.storeId ? String(truckData.storeId) : null; const storeIdValue = truckData.storeId ? String(truckData.storeId) : null;
const isEditable = normalizeStoreId(storeIdValue) === "4F";
const isEditable = normalizeStoreId(storeIdValue) === FLOOR_B_KEY;
return ( return (
<Autocomplete <Autocomplete
freeSolo freeSolo
@@ -1096,7 +1097,7 @@ const TruckLaneDetail: React.FC = () => {
const storeIdValue = truckData?.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String const storeIdValue = truckData?.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId) ? String(truckData.storeId)
: String(truckData.storeId)) : null; : String(truckData.storeId)) : null;
const isEditable = normalizeStoreId(storeIdValue) === "4F";
const isEditable = normalizeStoreId(storeIdValue) === FLOOR_B_KEY;
return isEditable ? ( return isEditable ? (
<Grid item xs={12}> <Grid item xs={12}>
<Autocomplete <Autocomplete


+ 6
- 5
src/components/Shop/computeTruckLaneWarnings.ts Näytä tiedosto

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


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


+ 2
- 1
src/components/Shop/routeBoardImportPreview.ts Näytä tiedosto

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


const existing = laneMap.get(laneId); const existing = laneMap.get(laneId);


+ 4
- 3
src/components/StockTakeManagement/ApproverStockTakeAll.tsx Näytä tiedosto

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


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


+ 4
- 3
src/components/StockTakeManagement/PickerCardList.tsx Näytä tiedosto

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


import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { import {
Box, Box,
Button, Button,
@@ -173,7 +174,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
const [marqueePreviewIds, setMarqueePreviewIds] = useState<Set<string>>(() => new Set()); const [marqueePreviewIds, setMarqueePreviewIds] = useState<Set<string>>(() => new Set());
const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]); const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]);
const [stockTakeSectionOptions, setStockTakeSectionOptions] = 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"]; const statusOptions = ["pending", "stockTaking", "approving", "completed"];


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


+ 19
- 0
src/config/demoFloors.ts Näytä tiedosto

@@ -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 Näytä tiedosto

@@ -1,8 +1,9 @@
/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ /** 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"; import { NEXT_PUBLIC_API_URL } from "@/config/api";


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

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


const floorOptionsAll: ReportField["options"] = [ const floorOptionsAll: ReportField["options"] = [
{ label: "全部", value: "All" }, { 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"] = [ 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 => ({ const pasteItemCodeField = (): ReportField => ({


+ 121
- 1
src/i18n/en/scheduling.json Näytä tiedosto

@@ -1,5 +1,125 @@
{ {
"title": "Scheduling", "title": "Scheduling",
"Scheduling": "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 Näytä tiedosto

@@ -1,5 +1,125 @@
{ {
"title": "排程", "title": "排程",
"Scheduling": "排程", "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": "日"
} }

Ladataan…
Peruuta
Tallenna