Przeglądaj źródła

Merge branch 'production' into bomUpdateTest

bomUpdateTest
CANCERYS\kw093 2 tygodni temu
rodzic
commit
8d716d4e83
71 zmienionych plików z 4128 dodań i 483 usunięć
  1. +6
    -2
      scripts/update-chart-i18n.js
  2. +39
    -6
      src/app/(main)/chart/delivery/page.tsx
  3. +15
    -1
      src/app/(main)/m18Syn/layout.tsx
  4. +2
    -1
      src/app/(main)/m18Syn/page.tsx
  5. +1
    -1
      src/app/(main)/production/page.tsx
  6. +1
    -1
      src/app/(main)/productionProcess/page.tsx
  7. +25
    -18
      src/app/(main)/report/ItemQcReportFilters.tsx
  8. +9
    -2
      src/app/(main)/report/ReportSelectionDashboard.tsx
  9. +15
    -13
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  10. +89
    -57
      src/app/(main)/report/bomShopSyncReportApi.ts
  11. +81
    -52
      src/app/(main)/report/grnReportApi.ts
  12. +238
    -36
      src/app/(main)/report/page.tsx
  13. +3
    -3
      src/app/(main)/report/reportCategories.ts
  14. +48
    -0
      src/app/(main)/report/reportI18n.ts
  15. +88
    -39
      src/app/(main)/report/shopOrderReplenishmentReportApi.ts
  16. +21
    -0
      src/app/(main)/settings/itemDefaultShelfLife/page.tsx
  17. +218
    -2
      src/app/api/bagPrint/actions.ts
  18. +5
    -0
      src/app/api/chart/client.ts
  19. +32
    -0
      src/app/api/jo/actions.ts
  20. +61
    -4
      src/app/api/laserPrint/actions.ts
  21. +88
    -0
      src/app/api/settings/itemDefaultShelfLife/client.ts
  22. +10
    -1
      src/app/api/user/client.ts
  23. +2
    -0
      src/authorities.ts
  24. +2
    -0
      src/components/AppBar/AppBar.tsx
  25. +60
    -0
      src/components/AppBar/LanguageSwitcher.tsx
  26. +704
    -7
      src/components/BagPrint/BagPrintSearch.tsx
  27. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  28. +31
    -4
      src/components/CreateUser/CreateUser.tsx
  29. +5
    -0
      src/components/CreateUser/UserDetail.tsx
  30. +473
    -0
      src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx
  31. +5
    -1
      src/components/ItemTracing/buildJoPreludeGraphNodes.ts
  32. +16
    -4
      src/components/ItemTracing/buildTraceGraphNodes.ts
  33. +2
    -2
      src/components/JoSearch/JoCreateFormModal.tsx
  34. +4
    -4
      src/components/JoSearch/JoSearch.tsx
  35. +4
    -4
      src/components/JoWorkbench/JoWorkbenchSearch.tsx
  36. +36
    -26
      src/components/JoWorkbench/newJobPickExecution.tsx
  37. +28
    -9
      src/components/LaserPrint/LaserPrintSearch.tsx
  38. +9
    -1
      src/components/NavigationContent/NavigationContent.tsx
  39. +40
    -3
      src/components/PickOrderSearch/WorkbenchPickExecution.tsx
  40. +59
    -34
      src/components/PoDetail/PoDetail.tsx
  41. +76
    -31
      src/components/PoDetail/PoInputGrid.tsx
  42. +1
    -1
      src/components/PoDetail/QcStockInModal.tsx
  43. +319
    -54
      src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx
  44. +6
    -3
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  45. +4
    -4
      src/components/ProductionProcess/ProductionProcessList.tsx
  46. +39
    -17
      src/components/ProductionProcess/ProductionProcessStepExecution.tsx
  47. +80
    -5
      src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts
  48. +1
    -1
      src/components/Qc/QcStockInModal.tsx
  49. +23
    -1
      src/components/UserSearch/UserExcelSheetView.tsx
  50. +17
    -1
      src/config/authConfig.ts
  51. +165
    -6
      src/config/reportConfig.ts
  52. +3
    -1
      src/i18n/en/chart.json
  53. +1
    -0
      src/i18n/en/common.json
  54. +38
    -0
      src/i18n/en/itemDefaultShelfLife.json
  55. +2
    -1
      src/i18n/en/itemTracing.json
  56. +3
    -1
      src/i18n/en/jo.json
  57. +1
    -0
      src/i18n/en/navigation.json
  58. +10
    -0
      src/i18n/en/productionProcess.json
  59. +327
    -2
      src/i18n/en/report.json
  60. +8
    -1
      src/i18n/en/user.json
  61. +12
    -8
      src/i18n/index.tsx
  62. +23
    -0
      src/i18n/locale.ts
  63. +3
    -1
      src/i18n/zh/chart.json
  64. +1
    -0
      src/i18n/zh/common.json
  65. +38
    -0
      src/i18n/zh/itemDefaultShelfLife.json
  66. +2
    -1
      src/i18n/zh/itemTracing.json
  67. +3
    -2
      src/i18n/zh/jo.json
  68. +1
    -0
      src/i18n/zh/navigation.json
  69. +10
    -0
      src/i18n/zh/productionProcess.json
  70. +327
    -2
      src/i18n/zh/report.json
  71. +8
    -1
      src/i18n/zh/user.json

+ 6
- 2
scripts/update-chart-i18n.js Wyświetl plik

@@ -42,9 +42,11 @@ const en = {
"delivery_store": "Store", "delivery_store": "Store",
"delivery_staff": "Staff", "delivery_staff": "Staff",
"delivery_staffPlaceholder": "Leave empty for all", "delivery_staffPlaceholder": "Leave empty for all",
"delivery_staffPerfCaption": "Per-person pick count & total duration for period",
"delivery_staffPerfCaption": "Per-person pick count, item kinds, picked qty & total duration for period",
"delivery_colStaff": "Staff", "delivery_colStaff": "Staff",
"delivery_colPickCount": "Pick Count", "delivery_colPickCount": "Pick Count",
"delivery_colItemKindCount": "Item Kind Count",
"delivery_colItemQtyPicked": "Item Qty Picked",
"delivery_colTotalMin": "Total Min", "delivery_colTotalMin": "Total Min",
"delivery_colAvgMin": "Avg Min/Order", "delivery_colAvgMin": "Avg Min/Order",
"delivery_dailyByStaff": "Daily by Staff", "delivery_dailyByStaff": "Daily by Staff",
@@ -167,9 +169,11 @@ const zh = {
"delivery_store": "倉別", "delivery_store": "倉別",
"delivery_staff": "員工", "delivery_staff": "員工",
"delivery_staffPlaceholder": "不選則全部", "delivery_staffPlaceholder": "不選則全部",
"delivery_staffPerfCaption": "週期內每人揀單數及總耗時(首揀至完成)",
"delivery_staffPerfCaption": "週期內每人揀單數、品項數、揀貨數量及總耗時(首揀至完成)",
"delivery_colStaff": "員工", "delivery_colStaff": "員工",
"delivery_colPickCount": "揀單數", "delivery_colPickCount": "揀單數",
"delivery_colItemKindCount": "品項數",
"delivery_colItemQtyPicked": "揀貨數量",
"delivery_colTotalMin": "總分鐘", "delivery_colTotalMin": "總分鐘",
"delivery_colAvgMin": "平均分鐘/單", "delivery_colAvgMin": "平均分鐘/單",
"delivery_dailyByStaff": "每日按員工單數", "delivery_dailyByStaff": "每日按員工單數",


+ 39
- 6
src/app/(main)/chart/delivery/page.tsx Wyświetl plik

@@ -66,6 +66,7 @@ const defaultCriteria: Criteria = {
}, },
}; };


/** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */
export default function DeliveryChartPage() { export default function DeliveryChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]);
@@ -76,7 +77,14 @@ export default function DeliveryChartPage() {
const [chartData, setChartData] = useState<{ const [chartData, setChartData] = useState<{
delivery: { date: string; orderCount: number; totalQty: number }[]; delivery: { date: string; orderCount: number; totalQty: number }[];
topItems: { itemCode: string; itemName: string; totalQty: number }[]; topItems: { itemCode: string; itemName: string; totalQty: number }[];
staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[];
staffPerf: {
date: string;
staffName: string;
orderCount: number;
totalMinutes: number;
itemKindCount: number;
itemQtyPicked: number;
}[];
}>({ delivery: [], topItems: [], staffPerf: [] }); }>({ delivery: [], topItems: [], staffPerf: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});


@@ -146,6 +154,8 @@ export default function DeliveryChartPage() {
staffName: string; staffName: string;
orderCount: number; orderCount: number;
totalMinutes: number; totalMinutes: number;
itemKindCount: number;
itemQtyPicked: number;
}[], }[],
})) }))
) )
@@ -164,18 +174,30 @@ export default function DeliveryChartPage() {
}, [criteria.topItems.rangeDays]); }, [criteria.topItems.rangeDays]);


const staffPerfByStaff = useMemo(() => { const staffPerfByStaff = useMemo(() => {
const map = new Map<string, { orderCount: number; totalMinutes: number }>();
const map = new Map<
string,
{ orderCount: number; totalMinutes: number; itemKindCount: number; itemQtyPicked: number }
>();
for (const r of chartData.staffPerf) { for (const r of chartData.staffPerf) {
const name = r.staffName || "Unknown"; const name = r.staffName || "Unknown";
const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 };
const cur = map.get(name) ?? {
orderCount: 0,
totalMinutes: 0,
itemKindCount: 0,
itemQtyPicked: 0,
};
map.set(name, { map.set(name, {
orderCount: cur.orderCount + r.orderCount, orderCount: cur.orderCount + r.orderCount,
totalMinutes: cur.totalMinutes + r.totalMinutes, totalMinutes: cur.totalMinutes + r.totalMinutes,
itemKindCount: cur.itemKindCount + r.itemKindCount,
itemQtyPicked: cur.itemQtyPicked + r.itemQtyPicked,
}); });
} }
return Array.from(map.entries()).map(([staffName, v]) => ({ return Array.from(map.entries()).map(([staffName, v]) => ({
staffName, staffName,
orderCount: v.orderCount, orderCount: v.orderCount,
itemKindCount: v.itemKindCount,
itemQtyPicked: v.itemQtyPicked,
totalMinutes: v.totalMinutes, totalMinutes: v.totalMinutes,
avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0, avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0,
})); }));
@@ -298,7 +320,14 @@ export default function DeliveryChartPage() {
<ChartCard <ChartCard
title="員工發貨績效(每日揀貨數量與耗時)" title="員工發貨績效(每日揀貨數量與耗時)"
exportFilename="員工發貨績效" exportFilename="員工發貨績效"
exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))}
exportData={chartData.staffPerf.map((r) => ({
日期: r.date,
員工: r.staffName,
揀單數: r.orderCount,
總揀貨款數: r.itemKindCount,
總揀貨件數: r.itemQtyPicked,
總分鐘: r.totalMinutes,
}))}
filters={ filters={
<> <>
<DateRangeSelect <DateRangeSelect
@@ -388,7 +417,7 @@ export default function DeliveryChartPage() {
<> <>
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom> <Typography variant="subtitle2" color="text.secondary" gutterBottom>
週期內每人揀單數及總耗時(首揀至完成)
週期內每人揀單數、總揀貨款數、總揀貨件數及總耗時(首揀至完成)
</Typography> </Typography>
<Box <Box
component="table" component="table"
@@ -409,6 +438,8 @@ export default function DeliveryChartPage() {
<tr> <tr>
<th>員工</th> <th>員工</th>
<th>揀單數</th> <th>揀單數</th>
<th>總揀貨款數</th>
<th>總揀貨件數</th>
<th>總分鐘</th> <th>總分鐘</th>
<th>平均分鐘/單</th> <th>平均分鐘/單</th>
</tr> </tr>
@@ -416,13 +447,15 @@ export default function DeliveryChartPage() {
<tbody> <tbody>
{staffPerfByStaff.length === 0 ? ( {staffPerfByStaff.length === 0 ? (
<tr> <tr>
<td colSpan={4}>無數據</td>
<td colSpan={6}>無數據</td>
</tr> </tr>
) : ( ) : (
staffPerfByStaff.map((row) => ( staffPerfByStaff.map((row) => (
<tr key={row.staffName}> <tr key={row.staffName}>
<td>{row.staffName}</td> <td>{row.staffName}</td>
<td>{row.orderCount}</td> <td>{row.orderCount}</td>
<td>{row.itemKindCount}</td>
<td>{row.itemQtyPicked}</td>
<td>{row.totalMinutes}</td> <td>{row.totalMinutes}</td>
<td>{row.avgMinutesPerOrder}</td> <td>{row.avgMinutesPerOrder}</td>
</tr> </tr>


+ 15
- 1
src/app/(main)/m18Syn/layout.tsx Wyświetl plik

@@ -1,10 +1,24 @@
import { I18nProvider } from "@/i18n"; import { I18nProvider } from "@/i18n";
import { authOptions } from "@/config/authConfig";
import { AUTH, hasAbility } from "@/authorities";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";


export default function M18SyncLayout({
/** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */
export default async function M18SyncLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
const canAccess =
hasAbility(abilities, AUTH.M18_SYNC) || hasAbility(abilities, AUTH.ADMIN);

if (!canAccess) {
redirect("/dashboard");
}

return ( return (
<I18nProvider namespaces={["m18Sync", "navigation", "common"]}> <I18nProvider namespaces={["m18Sync", "navigation", "common"]}>
{children} {children}


+ 2
- 1
src/app/(main)/m18Syn/page.tsx Wyświetl plik

@@ -26,6 +26,7 @@ function TabPanel(props: TabPanelProps) {
); );
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */
export default function M18SynPage() { export default function M18SynPage() {
const [tabValue, setTabValue] = useState(0); const [tabValue, setTabValue] = useState(0);


@@ -270,7 +271,7 @@ export default function M18SynPage() {
M18 Sync (by code) M18 Sync (by code)
</Typography> </Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code.
Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code.
</Typography> </Typography>


<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth">


+ 1
- 1
src/app/(main)/production/page.tsx Wyświetl plik

@@ -38,7 +38,7 @@ const production: React.FC = async () => {
{t("Create Process")} {t("Create Process")}
</Button> */} </Button> */}
</Stack> </Stack>
<I18nProvider namespaces={["production","productionProcess","navigation","common","purchaseOrder","jo","dashboard"]}>
<I18nProvider namespaces={["production","productionProcess","navigation","common","purchaseOrder","jo","do","dashboard"]}>
<ProductionProcessPage printerCombo={printerCombo} /> {/* Use new component */} <ProductionProcessPage printerCombo={printerCombo} /> {/* Use new component */}
</I18nProvider> </I18nProvider>
</> </>


+ 1
- 1
src/app/(main)/productionProcess/page.tsx Wyświetl plik

@@ -36,7 +36,7 @@ const productionProcess: React.FC = async () => {
{t("Create Process")} {t("Create Process")}
</Button> */} </Button> */}
</Stack> </Stack>
<I18nProvider namespaces={["productionProcess","navigation","common","purchaseOrder","jo","dashboard"]}>
<I18nProvider namespaces={["productionProcess","navigation","common","purchaseOrder","jo","do","dashboard"]}>
<Suspense fallback={<ProductionProcessLoading />}> <Suspense fallback={<ProductionProcessLoading />}>
<ProductionProcessPage printerCombo={printerCombo} /> <ProductionProcessPage printerCombo={printerCombo} />
</Suspense> </Suspense>


+ 25
- 18
src/app/(main)/report/ItemQcReportFilters.tsx Wyświetl plik

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


import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { import {
FormHelperText, FormHelperText,
Grid, Grid,
@@ -8,10 +9,10 @@ import {
TextField, TextField,
} from "@mui/material"; } from "@mui/material";


export type QcItemFilter = "measurable" | "non_measurable";
export type QcItemFilter = "all" | "measurable";


export const REP010_DEFAULT_CRITERIA: Record<string, string> = { export const REP010_DEFAULT_CRITERIA: Record<string, string> = {
qcItemFilter: "measurable",
qcItemScope: "all",
}; };


interface ItemQcReportFiltersProps { interface ItemQcReportFiltersProps {
@@ -21,18 +22,24 @@ interface ItemQcReportFiltersProps {


const gridSize = { xs: 12, sm: 6 }; const gridSize = { xs: 12, sm: 6 };


/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
export default function ItemQcReportFilters({ export default function ItemQcReportFilters({
criteria, criteria,
onFieldChange, onFieldChange,
}: ItemQcReportFiltersProps) { }: ItemQcReportFiltersProps) {
const qcItemFilter = (criteria.qcItemFilter || "measurable") as QcItemFilter;
const { t } = useTranslation("report");
const qcItemFilter = (criteria.qcItemScope || "all") as QcItemFilter;
const field = (name: string, fallback: string) =>
t(`reports.rep-010.fields.${name}`, { defaultValue: fallback });
const opt = (fieldName: string, value: string, fallback: string) =>
t(`reports.rep-010.options.${fieldName}.${value}`, { defaultValue: fallback });


return ( return (
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid item {...gridSize}> <Grid item {...gridSize}>
<TextField <TextField
fullWidth fullWidth
label="QC 檢測日期:由 Last In Date Start"
label={field("lastInDateStart", "QC 檢測日期:由 QC Date Start")}
type="date" type="date"
value={criteria.lastInDateStart || ""} value={criteria.lastInDateStart || ""}
onChange={(e) => onFieldChange("lastInDateStart", e.target.value)} onChange={(e) => onFieldChange("lastInDateStart", e.target.value)}
@@ -42,7 +49,7 @@ export default function ItemQcReportFilters({
<Grid item {...gridSize}> <Grid item {...gridSize}>
<TextField <TextField
fullWidth fullWidth
label="QC 檢測日期:至 Last In Date End"
label={field("lastInDateEnd", "QC 檢測日期:至 QC Date End")}
type="date" type="date"
value={criteria.lastInDateEnd || ""} value={criteria.lastInDateEnd || ""}
onChange={(e) => onFieldChange("lastInDateEnd", e.target.value)} onChange={(e) => onFieldChange("lastInDateEnd", e.target.value)}
@@ -53,19 +60,19 @@ export default function ItemQcReportFilters({
<TextField <TextField
fullWidth fullWidth
select select
label="QC 類型"
value={criteria.qcType || ""}
label={field("qcType", "QC 類型")}
value={criteria.qcType || "all"}
onChange={(e) => onFieldChange("qcType", e.target.value)} onChange={(e) => onFieldChange("qcType", e.target.value)}
> >
<MenuItem value="">全部</MenuItem>
<MenuItem value="IQC">IQC</MenuItem>
<MenuItem value="EPQC">EPQC</MenuItem>
<MenuItem value="all">{opt("qcType", "all", "全部")}</MenuItem>
<MenuItem value="IQC">{opt("qcType", "IQC", "IQC(採購)")}</MenuItem>
<MenuItem value="EPQC">{opt("qcType", "EPQC", "EPQC(工單)")}</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
<Grid item {...gridSize}> <Grid item {...gridSize}>
<TextField <TextField
fullWidth fullWidth
label="貨品編號 Item Code"
label={field("itemCode", "貨品編號 Item Code")}
value={criteria.itemCode || ""} value={criteria.itemCode || ""}
onChange={(e) => onFieldChange("itemCode", e.target.value)} onChange={(e) => onFieldChange("itemCode", e.target.value)}
placeholder="e.g. MJ0364" placeholder="e.g. MJ0364"
@@ -75,17 +82,17 @@ export default function ItemQcReportFilters({
<TextField <TextField
fullWidth fullWidth
select select
label="溫濕度數據篩選"
value={qcItemFilter}
onChange={(e) => onFieldChange("qcItemFilter", e.target.value)}
label={field("qcItemScope", "QC 項目範圍")}
value={qcItemFilter === "measurable" ? "measurable" : "all"}
onChange={(e) => onFieldChange("qcItemScope", e.target.value)}
> >
<MenuItem value="measurable">只包含溫度濕度</MenuItem>
<MenuItem value="non_measurable">不包含溫度濕度</MenuItem>
<MenuItem value="all">{opt("qcItemScope", "all", "全部 QC 項目")}</MenuItem>
<MenuItem value="measurable">{opt("qcItemScope", "measurable", "只包含溫度濕度")}</MenuItem>
</TextField> </TextField>
<FormHelperText> <FormHelperText>
{qcItemFilter === "measurable" {qcItemFilter === "measurable"
? "僅匯出已填寫實測值的溫度/濕度 QC 項目。"
: "僅匯出非溫度/濕度之其他 QC 檢驗項目。"}
? t("qcScopeHelpMeasurable")
: t("qcScopeHelpAll")}
</FormHelperText> </FormHelperText>
</Grid> </Grid>
</Grid> </Grid>


+ 9
- 2
src/app/(main)/report/ReportSelectionDashboard.tsx Wyświetl plik

@@ -19,17 +19,22 @@ import PieChartOutlineOutlinedIcon from "@mui/icons-material/PieChartOutlineOutl
import type { SvgIconComponent } from "@mui/icons-material"; import type { SvgIconComponent } from "@mui/icons-material";
import { REPORTS } from "@/config/reportConfig"; import { REPORTS } from "@/config/reportConfig";
import { REPORT_CATEGORIES, type ReportCategoryConfig } from "./reportCategories"; import { REPORT_CATEGORIES, type ReportCategoryConfig } from "./reportCategories";
import { useReportLabels } from "./reportI18n";


const REPORT_ICON_MAP: Record<string, SvgIconComponent> = { const REPORT_ICON_MAP: Record<string, SvgIconComponent> = {
"rep-011": Inventory2OutlinedIcon, "rep-011": Inventory2OutlinedIcon,
"rep-007": MonetizationOnOutlinedIcon, "rep-007": MonetizationOnOutlinedIcon,
"rep-012": LayersOutlinedIcon, "rep-012": LayersOutlinedIcon,
"rep-021": Inventory2OutlinedIcon,
"rep-010": SearchOutlinedIcon, "rep-010": SearchOutlinedIcon,
"rep-004": LocalShippingOutlinedIcon, "rep-004": LocalShippingOutlinedIcon,
"rep-014": LocalShippingOutlinedIcon, "rep-014": LocalShippingOutlinedIcon,
"rep-008": OutboundOutlinedIcon, "rep-008": OutboundOutlinedIcon,
"rep-009": OutboundOutlinedIcon, "rep-009": OutboundOutlinedIcon,
"rep-013": LocalShippingOutlinedIcon, "rep-013": LocalShippingOutlinedIcon,
"rep-016": OutboundOutlinedIcon,
"rep-017": LocalShippingOutlinedIcon,
"rep-018": SearchOutlinedIcon,
"rep-006": BarChartOutlinedIcon, "rep-006": BarChartOutlinedIcon,
"rep-005": PieChartOutlineOutlinedIcon, "rep-005": PieChartOutlineOutlinedIcon,
"rep-015": LayersOutlinedIcon, "rep-015": LayersOutlinedIcon,
@@ -113,6 +118,7 @@ function CategoryColumn({
selectedReportId: string; selectedReportId: string;
onSelectReport: (reportId: string) => void; onSelectReport: (reportId: string) => void;
}) { }) {
const { reportTitle, categoryTitle } = useReportLabels();
const reports = category.reportIds const reports = category.reportIds
.map((id) => reportById[id]) .map((id) => reportById[id])
.filter(Boolean); .filter(Boolean);
@@ -137,7 +143,7 @@ function CategoryColumn({
}} }}
> >
<Typography variant="subtitle1" fontWeight="bold"> <Typography variant="subtitle1" fontWeight="bold">
{category.title}
{categoryTitle(category.id, category.title)}
</Typography> </Typography>
</Box> </Box>
<Box <Box
@@ -152,7 +158,7 @@ function CategoryColumn({
<Grid item xs={6} key={report.id}> <Grid item xs={6} key={report.id}>
<ReportCard <ReportCard
reportId={report.id} reportId={report.id}
title={report.title}
title={reportTitle(report)}
accent={category.accent} accent={category.accent}
selected={selectedReportId === report.id} selected={selectedReportId === report.id}
onClick={() => onSelectReport(report.id)} onClick={() => onSelectReport(report.id)}
@@ -165,6 +171,7 @@ function CategoryColumn({
); );
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */
export default function ReportSelectionDashboard({ export default function ReportSelectionDashboard({
selectedReportId, selectedReportId,
onSelectReport, onSelectReport,


+ 15
- 13
src/app/(main)/report/SemiFGProductionAnalysisReport.tsx Wyświetl plik

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


import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from "react-i18next";
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,
@@ -43,6 +44,7 @@ export default function SemiFGProductionAnalysisReport({
reportTitle = '成品/半成品生產分析報告', reportTitle = '成品/半成品生產分析報告',
onExportSuccess, onExportSuccess,
}: SemiFGProductionAnalysisReportProps) { }: SemiFGProductionAnalysisReportProps) {
const { t } = useTranslation("report");
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]);
const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({});
@@ -70,7 +72,7 @@ export default function SemiFGProductionAnalysisReport({
setExportFormat(format); setExportFormat(format);
// Validate required fields // Validate required fields
if (requiredFieldLabels.length > 0) { if (requiredFieldLabels.length > 0) {
alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`);
alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') }));
return; return;
} }


@@ -107,7 +109,7 @@ export default function SemiFGProductionAnalysisReport({
setShowConfirmDialog(false); setShowConfirmDialog(false);
} catch (error) { } catch (error) {
console.error('Failed to generate report:', error); console.error('Failed to generate report:', error);
alert('An error occurred while generating the report. Please try again.');
alert(t('generateError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -124,7 +126,7 @@ export default function SemiFGProductionAnalysisReport({
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? '生成 PDF...' : '下載報告 (PDF)'}
{loading ? t('generatingPdf') : t('downloadPdf')}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
@@ -134,7 +136,7 @@ export default function SemiFGProductionAnalysisReport({
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? '生成 Excel...' : '下載報告 (Excel)'}
{loading ? t('generatingExcel') : t('downloadExcel')}
</Button> </Button>
</div> </div>


@@ -147,22 +149,22 @@ export default function SemiFGProductionAnalysisReport({
> >
<DialogTitle> <DialogTitle>
<Typography variant="h6" fontWeight="bold"> <Typography variant="h6" fontWeight="bold">
已選擇的物料編號以及列印成品/半成品生產分析報告
{t('semiFgConfirmTitle')}
</Typography> </Typography>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
請確認以下已選擇的物料編號及其類別:
{t('semiFgConfirmHint')}
</Typography> </Typography>
<TableContainer component={Paper} variant="outlined"> <TableContainer component={Paper} variant="outlined">
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell> <TableCell>
<strong>物料編號及名稱</strong>
<strong>{t('semiFgColItem')}</strong>
</TableCell> </TableCell>
<TableCell> <TableCell>
<strong>類別</strong>
<strong>{t('semiFgColCategory')}</strong>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -187,7 +189,7 @@ export default function SemiFGProductionAnalysisReport({
</TableContainer> </TableContainer>
</DialogContent> </DialogContent>
<DialogActions sx={{ p: 2 }}> <DialogActions sx={{ p: 2 }}>
<Button onClick={() => setShowConfirmDialog(false)}>取消</Button>
<Button onClick={() => setShowConfirmDialog(false)}>{t('cancel')}</Button>
<Button <Button
variant="contained" variant="contained"
onClick={() => executeExport()} onClick={() => executeExport()}
@@ -196,11 +198,11 @@ export default function SemiFGProductionAnalysisReport({
> >
{loading {loading
? exportFormat === 'excel' ? exportFormat === 'excel'
? '生成 Excel...'
: '生成 PDF...'
? t('generatingExcel')
: t('generatingPdf')
: exportFormat === 'excel' : exportFormat === 'excel'
? '確認下載 Excel'
: '確認下載 PDF'}
? t('confirmDownloadExcel')
: t('confirmDownloadPdf')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>


+ 89
- 57
src/app/(main)/report/bomShopSyncReportApi.ts Wyświetl plik

@@ -5,6 +5,8 @@ import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { import {
exportMultiSheetToXlsx, exportMultiSheetToXlsx,
} from "@/app/(main)/chart/_components/exportChartToXlsx"; } from "@/app/(main)/chart/_components/exportChartToXlsx";
import { reportExcelT as tx } from "./reportI18n";
import type { TFunction } from "i18next";


export interface BomShopSyncReportSummary { export interface BomShopSyncReportSummary {
totalAttempts?: number; totalAttempts?: number;
@@ -55,43 +57,64 @@ export interface BomShopSyncReportResponse {
materialRows?: BomShopSyncMaterialRow[]; materialRows?: BomShopSyncMaterialRow[];
} }


const SHEET_SYNC = "BOM同步記錄";
const SHEET_MATERIALS = "BOM物料明細";

const NO_DATA_NOTE =
"(篩選範圍內無資料 / No records in the selected range)";
function bomSyncLabels(t?: TFunction) {
return {
sheetSync: tx(t, "excel.bomSync.sheetSync", "BOM同步記錄"),
sheetMaterials: tx(t, "excel.bomSync.sheetMaterials", "BOM物料明細"),
noData: tx(t, "excel.noData", "(篩選範圍內無資料)"),
syncTime: tx(t, "excel.bomSync.syncTime", "同步時間"),
finishedItemCode: tx(t, "excel.bomSync.finishedItemCode", "成品貨號"),
finishedItemName: tx(t, "excel.bomSync.finishedItemName", "成品名稱"),
bomRoutingCode: tx(t, "excel.bomSync.bomRoutingCode", "BOM路由編號"),
version: tx(t, "excel.bomSync.version", "版本"),
status: tx(t, "excel.bomSync.status", "狀態"),
failureReason: tx(t, "excel.bomSync.failureReason", "失敗原因"),
message: tx(t, "excel.bomSync.message", "訊息"),
lineNo: tx(t, "excel.bomSync.lineNo", "行號"),
materialName: tx(t, "excel.bomSync.materialName", "物料名稱"),
uom: tx(t, "excel.bomSync.uom", "單位"),
qty: tx(t, "excel.bomSync.qty", "用量"),
statusSuccess: tx(t, "excel.bomSync.statusSuccess", "成功"),
statusSkipped: tx(t, "excel.bomSync.statusSkipped", "略過(內容未變)"),
statusFailed: tx(t, "excel.bomSync.statusFailed", "失敗"),
};
}


/** Column keys for sheet 1 — used for headers when there are no data rows. */
function emptySyncSheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> {
function emptySyncSheetRow(
L: ReturnType<typeof bomSyncLabels>,
note?: string,
): Record<string, unknown> {
return { return {
同步時間: note,
成品貨號: "",
成品名稱: "",
BOM路由編號: "",
[L.syncTime]: note ?? L.noData,
[L.finishedItemCode]: "",
[L.finishedItemName]: "",
[L.bomRoutingCode]: "",
"M18 BOM Code": "", "M18 BOM Code": "",
版本: "",
[L.version]: "",
"M18 Record Id": "", "M18 Record Id": "",
狀態: "",
失敗原因: "",
訊息: "",
[L.status]: "",
[L.failureReason]: "",
[L.message]: "",
"BOM Id": "", "BOM Id": "",
"Sync Log Id": "", "Sync Log Id": "",
}; };
} }


/** Column keys for sheet 2 — used for headers when there are no data rows. */
function emptyMaterialSheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> {
function emptyMaterialSheetRow(
L: ReturnType<typeof bomSyncLabels>,
note?: string,
): Record<string, unknown> {
return { return {
同步時間: note,
成品貨號: "",
[L.syncTime]: note ?? L.noData,
[L.finishedItemCode]: "",
"M18 BOM Code": "", "M18 BOM Code": "",
版本: "",
狀態: "",
行號: "",
物料名稱: "",
[L.version]: "",
[L.status]: "",
[L.lineNo]: "",
[L.materialName]: "",
"M18 Product Id": "", "M18 Product Id": "",
單位: "",
用量: "",
[L.uom]: "",
[L.qty]: "",
"M18 Supplier Id": "", "M18 Supplier Id": "",
"M18 Purchase Unit Id": "", "M18 Purchase Unit Id": "",
"Sync Log Id": "", "Sync Log Id": "",
@@ -117,52 +140,59 @@ export async function fetchBomShopSyncReportData(
return (await response.json()) as BomShopSyncReportResponse; return (await response.json()) as BomShopSyncReportResponse;
} }


function syncStatusLabel(status: string | undefined): string {
function syncStatusLabel(
status: string | undefined,
L: ReturnType<typeof bomSyncLabels>,
): string {
switch (status) { switch (status) {
case "SUCCESS": case "SUCCESS":
return "成功";
return L.statusSuccess;
case "SKIPPED_UNCHANGED": case "SKIPPED_UNCHANGED":
return "略過(內容未變)";
return L.statusSkipped;
case "FAILED": case "FAILED":
return "失敗";
return L.statusFailed;
default: default:
return status ?? ""; return status ?? "";
} }
} }


function toSyncExcelRow(r: BomShopSyncRow): Record<string, unknown> {
const base = emptySyncSheetRow("");
function toSyncExcelRow(
r: BomShopSyncRow,
L: ReturnType<typeof bomSyncLabels>,
): Record<string, unknown> {
return { return {
...base,
同步時間: r.syncDateTime ?? "",
成品貨號: r.finishedItemCode ?? "",
成品名稱: r.finishedItemName ?? "",
BOM路由編號: r.bomRoutingCode ?? "",
...emptySyncSheetRow(L, ""),
[L.syncTime]: r.syncDateTime ?? "",
[L.finishedItemCode]: r.finishedItemCode ?? "",
[L.finishedItemName]: r.finishedItemName ?? "",
[L.bomRoutingCode]: r.bomRoutingCode ?? "",
"M18 BOM Code": r.m18HeaderCode ?? "", "M18 BOM Code": r.m18HeaderCode ?? "",
版本: r.version ?? "",
[L.version]: r.version ?? "",
"M18 Record Id": r.m18RecordId ?? "", "M18 Record Id": r.m18RecordId ?? "",
狀態: syncStatusLabel(r.syncStatus),
失敗原因: r.failureReason ?? "",
訊息: r.message ?? "",
[L.status]: syncStatusLabel(r.syncStatus, L),
[L.failureReason]: r.failureReason ?? "",
[L.message]: r.message ?? "",
"BOM Id": r.bomId ?? "", "BOM Id": r.bomId ?? "",
"Sync Log Id": r.syncLogId ?? "", "Sync Log Id": r.syncLogId ?? "",
}; };
} }


function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record<string, unknown> {
const base = emptyMaterialSheetRow("");
function toMaterialExcelRow(
r: BomShopSyncMaterialRow,
L: ReturnType<typeof bomSyncLabels>,
): Record<string, unknown> {
return { return {
...base,
同步時間: r.syncDateTime ?? "",
成品貨號: r.finishedItemCode ?? "",
...emptyMaterialSheetRow(L, ""),
[L.syncTime]: r.syncDateTime ?? "",
[L.finishedItemCode]: r.finishedItemCode ?? "",
"M18 BOM Code": r.m18HeaderCode ?? "", "M18 BOM Code": r.m18HeaderCode ?? "",
版本: r.version ?? "",
狀態: syncStatusLabel(r.syncStatus),
行號: r.lineNo ?? "",
物料名稱: r.materialName ?? "",
[L.version]: r.version ?? "",
[L.status]: syncStatusLabel(r.syncStatus, L),
[L.lineNo]: r.lineNo ?? "",
[L.materialName]: r.materialName ?? "",
"M18 Product Id": r.udfProductM18Id ?? "", "M18 Product Id": r.udfProductM18Id ?? "",
單位: r.udfBaseUnit ?? "",
用量: r.udfQty ?? "",
[L.uom]: r.udfBaseUnit ?? "",
[L.qty]: r.udfQty ?? "",
"M18 Supplier Id": r.udfSupplierM18Id ?? "", "M18 Supplier Id": r.udfSupplierM18Id ?? "",
"M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "", "M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "",
"Sync Log Id": r.syncLogId ?? "", "Sync Log Id": r.syncLogId ?? "",
@@ -172,16 +202,18 @@ function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record<string, unknown>
export async function generateBomShopSyncReportExcel( export async function generateBomShopSyncReportExcel(
criteria: Record<string, string>, criteria: Record<string, string>,
reportTitle: string = "M18 BOM Shop 同步記錄", reportTitle: string = "M18 BOM Shop 同步記錄",
t?: TFunction,
): Promise<void> { ): Promise<void> {
const L = bomSyncLabels(t);
const data = await fetchBomShopSyncReportData(criteria); const data = await fetchBomShopSyncReportData(criteria);
const syncRows = const syncRows =
(data.syncRows ?? []).length > 0 (data.syncRows ?? []).length > 0
? (data.syncRows ?? []).map(toSyncExcelRow)
: [emptySyncSheetRow()];
? (data.syncRows ?? []).map((r) => toSyncExcelRow(r, L))
: [emptySyncSheetRow(L)];
const materialRows = const materialRows =
(data.materialRows ?? []).length > 0 (data.materialRows ?? []).length > 0
? (data.materialRows ?? []).map(toMaterialExcelRow)
: [emptyMaterialSheetRow()];
? (data.materialRows ?? []).map((r) => toMaterialExcelRow(r, L))
: [emptyMaterialSheetRow(L)];


const start = criteria.syncDateStart; const start = criteria.syncDateStart;
const end = criteria.syncDateEnd; const end = criteria.syncDateEnd;
@@ -197,8 +229,8 @@ export async function generateBomShopSyncReportExcel(


exportMultiSheetToXlsx( exportMultiSheetToXlsx(
[ [
{ name: SHEET_SYNC, rows: syncRows },
{ name: SHEET_MATERIALS, rows: materialRows },
{ name: L.sheetSync, rows: syncRows },
{ name: L.sheetMaterials, rows: materialRows },
], ],
filename, filename,
); );


+ 81
- 52
src/app/(main)/report/grnReportApi.ts Wyświetl plik

@@ -6,6 +6,8 @@ import {
exportChartToXlsx, exportChartToXlsx,
exportMultiSheetToXlsx, exportMultiSheetToXlsx,
} from "@/app/(main)/chart/_components/exportChartToXlsx"; } from "@/app/(main)/chart/_components/exportChartToXlsx";
import { reportExcelT as tx } from "./reportI18n";
import type { TFunction } from "i18next";


export interface GrnReportRow { export interface GrnReportRow {
poCode?: string; poCode?: string;
@@ -122,77 +124,102 @@ const formatQty = (n: number | undefined | null): string => {
}).format(Number(n)); }).format(Number(n));
}; };


/** Excel column headers (bilingual) for GRN report */
function grnLabels(t?: TFunction) {
return {
sheetDetail: tx(t, "excel.grn.sheetDetail", "PO入倉記錄"),
sheetListedPo: tx(t, "excel.grn.sheetListedPo", "已上架PO金額"),
poNo: tx(t, "excel.grn.poNo", "訂單編號"),
deliveryNoteNo: tx(t, "excel.grn.deliveryNoteNo", "送貨單編號"),
receiptDate: tx(t, "excel.grn.receiptDate", "收貨日期"),
itemCode: tx(t, "excel.grn.itemCode", "物料編號"),
itemName: tx(t, "excel.grn.itemName", "物料名稱"),
qty: tx(t, "excel.grn.qty", "數量"),
demandQty: tx(t, "excel.grn.demandQty", "訂單數量"),
uom: tx(t, "excel.grn.uom", "單位"),
supplierLotNo: tx(t, "excel.grn.supplierLotNo", "供應商批次"),
expiryDate: tx(t, "excel.grn.expiryDate", "到期日"),
supplierCode: tx(t, "excel.grn.supplierCode", "供應商編號"),
supplier: tx(t, "excel.grn.supplier", "供應商"),
status: tx(t, "excel.grn.status", "入倉狀態"),
unitPrice: tx(t, "excel.grn.unitPrice", "單價"),
currency: tx(t, "excel.grn.currency", "貨幣"),
amount: tx(t, "excel.grn.amount", "金額"),
grnCode: tx(t, "excel.grn.grnCode", "M18 入倉單號"),
grnId: tx(t, "excel.grn.grnId", "M18 記錄編號"),
poCreator: tx(t, "excel.grn.poCreator", "PO建立者(M18)"),
note: tx(t, "excel.grn.note", "備註"),
category: tx(t, "excel.grn.category", "類別"),
totalAmount: tx(t, "excel.grn.totalAmount", "金額"),
grnCodes: tx(t, "excel.grn.grnCodes", "M18 入倉單號"),
noCompletedPo: tx(t, "excel.grn.noCompletedPo", "(篩選範圍內無已完成之 PO 行)"),
categoryCurrencyTotal: tx(t, "excel.grn.categoryCurrencyTotal", "貨幣小計"),
categoryPo: tx(t, "excel.grn.categoryPo", "訂單"),
};
}

function toExcelRow( function toExcelRow(
r: GrnReportRow, r: GrnReportRow,
includeFinancialColumns: boolean
includeFinancialColumns: boolean,
t?: TFunction,
): Record<string, string | number | undefined> { ): Record<string, string | number | undefined> {
const L = grnLabels(t);
const base: Record<string, string | number | undefined> = { const base: Record<string, string | number | undefined> = {
"PO No. / 訂單編號": r.poCode ?? "",
"Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "",
"Receipt Date / 收貨日期": r.receiptDate ?? "",
"Item Code / 物料編號": r.itemCode ?? "",
"Item Name / 物料名稱": r.itemName ?? "",
"Qty / 數量": formatQty(
r.acceptedQty ?? r.receivedQty ?? undefined
),
"Demand Qty / 訂單數量": formatQty(r.demandQty),
"UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "",
"Supplier Lot No. 供應商批次": r.productLotNo ?? "",
"Expiry Date / 到期日": r.expiryDate ?? "",
"Supplier Code / 供應商編號": r.supplierCode ?? "",
"Supplier / 供應商": r.supplier ?? "",
"入倉狀態": r.status ?? "",
[L.poNo]: r.poCode ?? "",
[L.deliveryNoteNo]: r.deliveryNoteNo ?? "",
[L.receiptDate]: r.receiptDate ?? "",
[L.itemCode]: r.itemCode ?? "",
[L.itemName]: r.itemName ?? "",
[L.qty]: formatQty(r.acceptedQty ?? r.receivedQty ?? undefined),
[L.demandQty]: formatQty(r.demandQty),
[L.uom]: r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "",
[L.supplierLotNo]: r.productLotNo ?? "",
[L.expiryDate]: r.expiryDate ?? "",
[L.supplierCode]: r.supplierCode ?? "",
[L.supplier]: r.supplier ?? "",
[L.status]: r.status ?? "",
}; };
if (includeFinancialColumns) { if (includeFinancialColumns) {
base["Unit Price / 單價"] = moneyCellValue(r.unitPrice);
base["Currency / 貨幣"] = r.currencyCode ?? "";
base["Amount / 金額"] = moneyCellValue(r.lineAmount);
base[L.unitPrice] = moneyCellValue(r.unitPrice);
base[L.currency] = r.currencyCode ?? "";
base[L.amount] = moneyCellValue(r.lineAmount);
} }
base["GRN Code / M18 入倉單號"] = r.grnCode ?? "";
base["GRN Id / M18 記錄編號"] = r.grnId ?? "";
base["PO建立者(M18) / PO creator (M18)"] = r.poM18CreatorDisplay ?? "";
base[L.grnCode] = r.grnCode ?? "";
base[L.grnId] = r.grnId ?? "";
base[L.poCreator] = r.poM18CreatorDisplay ?? "";
return base; return base;
} }


const GRN_SHEET_DETAIL = "PO入倉記錄";
const GRN_SHEET_LISTED_PO = "已上架PO金額";

/** Rows for sheet "已上架PO金額" (ADMIN-only; do not add this sheet for other users). */
function buildListedPoAmountSheetRows( function buildListedPoAmountSheetRows(
listed: ListedPoAmounts | undefined
listed: ListedPoAmounts | undefined,
t?: TFunction,
): Record<string, string | number | undefined>[] { ): Record<string, string | number | undefined>[] {
const L = grnLabels(t);
if ( if (
!listed || !listed ||
(listed.currencyTotals.length === 0 && (listed.currencyTotals.length === 0 &&
listed.byPurchaseOrder.length === 0) listed.byPurchaseOrder.length === 0)
) { ) {
return [
{
"Note / 備註":
"(篩選範圍內無已完成之 PO 行) / No completed PO lines in the selected range",
},
];
return [{ [L.note]: L.noCompletedPo }];
} }
const out: Record<string, string | number | undefined>[] = []; const out: Record<string, string | number | undefined>[] = [];
for (const c of listed.currencyTotals) { for (const c of listed.currencyTotals) {
out.push({ out.push({
"Category / 類別": "貨幣小計 / Currency total",
"Receipt Date / 收貨日期": c.receiptDate ?? "",
"PO No. / 訂單編號": "",
"Currency / 貨幣": c.currencyCode ?? "",
"Total Amount / 金額": moneyCellValue(c.totalAmount),
"GRN Code(s) / M18 入倉單號": "",
[L.category]: L.categoryCurrencyTotal,
[L.receiptDate]: c.receiptDate ?? "",
[L.poNo]: "",
[L.currency]: c.currencyCode ?? "",
[L.totalAmount]: moneyCellValue(c.totalAmount),
[L.grnCodes]: "",
}); });
} }
for (const p of listed.byPurchaseOrder) { for (const p of listed.byPurchaseOrder) {
out.push({ out.push({
"Category / 類別": "訂單 / PO",
"Receipt Date / 收貨日期": p.receiptDate ?? "",
"PO No. / 訂單編號": p.poCode ?? "",
"Currency / 貨幣": p.currencyCode ?? "",
"Total Amount / 金額": moneyCellValue(p.totalAmount),
"GRN Code(s) / M18 入倉單號": p.grnCodes ?? "",
[L.category]: L.categoryPo,
[L.receiptDate]: p.receiptDate ?? "",
[L.poNo]: p.poCode ?? "",
[L.currency]: p.currencyCode ?? "",
[L.totalAmount]: moneyCellValue(p.totalAmount),
[L.grnCodes]: p.grnCodes ?? "",
}); });
} }
return out; return out;
@@ -206,10 +233,11 @@ export async function generateGrnReportExcel(
criteria: Record<string, string>, criteria: Record<string, string>,
reportTitle: string = "PO 入倉記錄", reportTitle: string = "PO 入倉記錄",
/** Only users with ADMIN authority should pass true (must match backend). */ /** Only users with ADMIN authority should pass true (must match backend). */
includeFinancialColumns: boolean = false
includeFinancialColumns: boolean = false,
t?: TFunction,
): Promise<void> { ): Promise<void> {
const { rows, listedPoAmounts } = await fetchGrnReportData(criteria); const { rows, listedPoAmounts } = await fetchGrnReportData(criteria);
const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns));
const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns, t));
const start = criteria.receiptDateStart; const start = criteria.receiptDateStart;
const end = criteria.receiptDateEnd; const end = criteria.receiptDateEnd;
let datePart: string; let datePart: string;
@@ -222,17 +250,18 @@ export async function generateGrnReportExcel(
} }
const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
const filename = `${reportTitle}_${safeDatePart}`; const filename = `${reportTitle}_${safeDatePart}`;
const L = grnLabels(t);


if (includeFinancialColumns) { if (includeFinancialColumns) {
const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts);
const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts, t);
exportMultiSheetToXlsx( exportMultiSheetToXlsx(
[ [
{ name: GRN_SHEET_DETAIL, rows: excelRows as Record<string, unknown>[] },
{ name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record<string, unknown>[] },
{ name: L.sheetDetail, rows: excelRows as Record<string, unknown>[] },
{ name: L.sheetListedPo, rows: sheet2 as Record<string, unknown>[] },
], ],
filename filename
); );
} else { } else {
exportChartToXlsx(excelRows as Record<string, unknown>[], filename, GRN_SHEET_DETAIL);
exportChartToXlsx(excelRows as Record<string, unknown>[], filename, L.sheetDetail);
} }
} }

+ 238
- 36
src/app/(main)/report/page.tsx Wyświetl plik

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


import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig"; import { SessionWithTokens } from "@/config/authConfig";
import { AUTH } from "@/authorities"; import { AUTH } from "@/authorities";
@@ -18,13 +18,24 @@ import {
Autocomplete, Autocomplete,
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material'; } from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
import { REPORTS, ReportDefinition } from '@/config/reportConfig';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { REPORTS } from '@/config/reportConfig';
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';
import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
import ReportSelectionDashboard from './ReportSelectionDashboard'; import ReportSelectionDashboard from './ReportSelectionDashboard';
import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-hk';
import { OUTPUT_DATE_FORMAT } from '@/app/utils/formatUtil';
import { useReportLabels } from './reportI18n';
import { import {
fetchSemiFGItemCodes, fetchSemiFGItemCodes,
fetchSemiFGItemCodesWithCategory fetchSemiFGItemCodesWithCategory
@@ -44,16 +55,23 @@ interface ItemCodeWithName {
} }


/** 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 */
/** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */
/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */
export default function ReportPage() { export default function ReportPage() {
const { data: session } = useSession() as { data: SessionWithTokens | null }; const { data: session } = useSession() as { data: SessionWithTokens | null };
const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels();
const isZh = (i18n.language || 'zh').startsWith('zh');
const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY';
const includeGrnFinancialColumns = const includeGrnFinancialColumns =
session?.abilities?.includes(AUTH.ADMIN) ?? false; session?.abilities?.includes(AUTH.ADMIN) ?? false;


const [selectedReportId, setSelectedReportId] = useState<string>(''); const [selectedReportId, setSelectedReportId] = useState<string>('');
const [criteria, setCriteria] = useState<Record<string, string>>({}); const [criteria, setCriteria] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const excelInFlightRef = useRef(false);
const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showNoDataDialog, setShowNoDataDialog] = useState(false);
// Find the configuration for the currently selected report // Find the configuration for the currently selected report
const rep012RoundIds = useMemo(() => { const rep012RoundIds = useMemo(() => {
@@ -73,12 +91,27 @@ export default function ReportPage() {
const handleSelectReport = (reportId: string) => { const handleSelectReport = (reportId: string) => {
if (reportId === selectedReportId) return; if (reportId === selectedReportId) return;
setSelectedReportId(reportId); setSelectedReportId(reportId);
setCriteria({});
if (reportId === 'rep-010') {
setCriteria({ qcType: 'all', qcItemScope: 'all' });
} else if (reportId === 'rep-004') {
setCriteria({ storeId: 'All', poPrefix: 'All' });
} else if (reportId === 'rep-021') {
setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' });
} else {
setCriteria({});
}
}; };


const handleFieldChange = (name: string, value: string | string[]) => { const handleFieldChange = (name: string, value: string | string[]) => {
const stringValue = Array.isArray(value) ? value.join(',') : value; const stringValue = Array.isArray(value) ? value.join(',') : value;
setCriteria((prev) => ({ ...prev, [name]: stringValue }));
setCriteria((prev) => {
const next = { ...prev, [name]: stringValue };
if (currentReport?.id === 'rep-021' && name === 'warehouse') {
const m = stringValue.trim().match(/^w(\d)/i);
if (m) next.storeId = `${m[1]}F`;
}
return next;
});
// If this is stockCategory and there's a field that depends on it, fetch dynamic options // If this is stockCategory and there's a field that depends on it, fetch dynamic options
if (name === 'stockCategory' && currentReport) { if (name === 'stockCategory' && currentReport) {
@@ -135,8 +168,27 @@ export default function ReportPage() {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);


const data = await response.json(); const data = await response.json();
const options = Array.isArray(data)
? data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item) }))
const options = Array.isArray(data)
? field.name === 'stockTakeSectionDescription'
? (() => {
const seen = new Set<string>();
const mapped: { label: string; value: string }[] = [{ label: '全部', value: 'All' }];
data.forEach((item: { stockTakeSectionDescription?: string; stockTakeSection?: string }) => {
const desc = (item.stockTakeSectionDescription || '').trim();
if (!desc || seen.has(desc)) return;
seen.add(desc);
const section = (item.stockTakeSection || '').trim();
mapped.push({
label: section ? `${desc} (${section})` : desc,
value: desc,
});
});
return mapped;
})()
: data.map((item: any) => ({
label: item.label || item.name || item.code || String(item),
value: item.value || item.code || String(item),
}))
: []; : [];
setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
@@ -194,7 +246,9 @@ export default function ReportPage() {


if (currentReport.id === 'rep-012') { if (currentReport.id === 'rep-012') {
if (rep012RoundIds.length === 0) { if (rep012RoundIds.length === 0) {
alert('缺少必填條件:\n- 盤點輪次');
alert(t('missingRequired', {
fields: fieldLabel('rep-012', { name: 'stockTakeRoundId', label: '盤點輪次' }),
}));
return false; return false;
} }
return true; return true;
@@ -206,10 +260,26 @@ export default function ReportPage() {
if (!field.required) return false; if (!field.required) return false;
return !criteria[field.name]; return !criteria[field.name];
}) })
.map(field => field.label);
.map((field) => fieldLabel(currentReport.id, field));


if (missingFields.length > 0) { if (missingFields.length > 0) {
alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`);
alert(t('missingRequired', { fields: missingFields.join('\n- ') }));
return false;
}

// Date fields with minDate: 'today' must not be before local today
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const beforeToday = currentReport.fields
.filter((field) => field.type === 'date' && field.minDate === 'today')
.filter((field) => {
const v = (criteria[field.name] || '').trim();
return v && v < todayStr;
})
.map((field) => fieldLabel(currentReport.id, field));

if (beforeToday.length > 0) {
alert(t('dateNotBeforeToday', { fields: beforeToday.join('\n- ') }));
return false; return false;
} }


@@ -235,6 +305,28 @@ export default function ReportPage() {
return p.toString(); return p.toString();
}; };


/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
/** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */
const buildRep010QueryString = (): string => {
const p = new URLSearchParams();
Object.entries(criteria).forEach(([key, value]) => {
if (key === 'qcItemScope') return;
if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return;
if (value != null && String(value).trim() !== '') {
p.set(key, String(value));
}
});
const scope = (criteria.qcItemScope || 'all').trim().toLowerCase();
if (scope === 'measurable') {
p.set('includeMeasurable', 'true');
p.set('includeOther', 'false');
} else {
p.set('includeMeasurable', 'true');
p.set('includeOther', 'true');
}
return p.toString();
};

const handlePrint = async () => { const handlePrint = async () => {
if (!currentReport) return; if (!currentReport) return;
if (!validateRequiredFields()) return; if (!validateRequiredFields()) return;
@@ -259,23 +351,28 @@ export default function ReportPage() {


const executeExcelReport = async () => { const executeExcelReport = async () => {
if (!currentReport) return; if (!currentReport) return;
if (excelInFlightRef.current) return;
excelInFlightRef.current = true;
setLoading(true); setLoading(true);
try { try {
if (currentReport.id === 'rep-014') { if (currentReport.id === 'rep-014') {
await generateGrnReportExcel( await generateGrnReportExcel(
criteria, criteria,
currentReport.title,
includeGrnFinancialColumns
reportTitle(currentReport),
includeGrnFinancialColumns,
t,
); );
} else if (currentReport.id === 'rep-015') { } else if (currentReport.id === 'rep-015') {
await generateBomShopSyncReportExcel(criteria, currentReport.title);
await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t);
} else if (currentReport.id === 'rep-017') { } else if (currentReport.id === 'rep-017') {
await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title);
await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t);
} else { } else {
// Backend returns actual .xlsx bytes for this Excel endpoint. // Backend returns actual .xlsx bytes for this Excel endpoint.
let queryParams = let queryParams =
currentReport.id === 'rep-012' currentReport.id === 'rep-012'
? buildRep012QueryString() ? buildRep012QueryString()
: currentReport.id === 'rep-010'
? buildRep010QueryString()
: new URLSearchParams(criteria).toString(); : new URLSearchParams(criteria).toString();
// rep-016: single-day UI — mirror dateStart to dateEnd for backend API. // rep-016: single-day UI — mirror dateStart to dateEnd for backend API.
if (currentReport.id === 'rep-016') { if (currentReport.id === 'rep-016') {
@@ -295,6 +392,10 @@ export default function ReportPage() {
}); });


if (response.status === 401 || response.status === 403) return; if (response.status === 401 || response.status === 403) return;
if (response.status === 204) {
setShowNoDataDialog(true);
return;
}
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error("Response error:", errorText); console.error("Response error:", errorText);
@@ -307,7 +408,7 @@ export default function ReportPage() {
link.href = downloadUrl; link.href = downloadUrl;


const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get('Content-Disposition');
let fileName = `${currentReport.title}.xlsx`;
let fileName = `${reportTitle(currentReport)}.xlsx`;
if (contentDisposition?.includes('filename=')) { if (contentDisposition?.includes('filename=')) {
fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
} }
@@ -328,9 +429,10 @@ export default function ReportPage() {
setShowConfirmDialog(false); setShowConfirmDialog(false);
} catch (error) { } catch (error) {
console.error("Failed to generate Excel report:", error); console.error("Failed to generate Excel report:", error);
alert("An error occurred while generating the report. Please try again.");
alert(t('generateError'));
} finally { } finally {
setLoading(false); setLoading(false);
excelInFlightRef.current = false;
} }
}; };


@@ -339,9 +441,11 @@ export default function ReportPage() {


setLoading(true); setLoading(true);
try { try {
const queryParams =
let queryParams =
currentReport.id === 'rep-012' currentReport.id === 'rep-012'
? buildRep012QueryString() ? buildRep012QueryString()
: currentReport.id === 'rep-010'
? buildRep010QueryString()
: new URLSearchParams(criteria).toString(); : new URLSearchParams(criteria).toString();
const url = `${currentReport.apiEndpoint}?${queryParams}`; const url = `${currentReport.apiEndpoint}?${queryParams}`;


@@ -363,7 +467,7 @@ export default function ReportPage() {
link.href = downloadUrl; link.href = downloadUrl;
const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get('Content-Disposition');
let fileName = `${currentReport.title}.pdf`;
let fileName = `${reportTitle(currentReport)}.pdf`;
if (contentDisposition?.includes('filename=')) { if (contentDisposition?.includes('filename=')) {
fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
} }
@@ -383,16 +487,17 @@ export default function ReportPage() {
setShowConfirmDialog(false); setShowConfirmDialog(false);
} catch (error) { } catch (error) {
console.error("Failed to generate report:", error); console.error("Failed to generate report:", error);
alert("An error occurred while generating the report. Please try again.");
alert(t('generateError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };


return ( return (
<>
<Box sx={{ p: 4, maxWidth: 1280, margin: '0 auto' }}> <Box sx={{ p: 4, maxWidth: 1280, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom fontWeight="bold"> <Typography variant="h4" gutterBottom fontWeight="bold">
報告管理
{t('title')}
</Typography> </Typography>


<ReportSelectionDashboard <ReportSelectionDashboard
@@ -404,15 +509,33 @@ export default function ReportPage() {
<Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}>
<CardContent> <CardContent>
<Typography variant="h6" color="primary" gutterBottom> <Typography variant="h6" color="primary" gutterBottom>
搜索條件: {currentReport.title}
{t('searchCriteriaWithTitle', { title: reportTitle(currentReport) })}
</Typography> </Typography>
<Divider sx={{ mb: 3 }} /> <Divider sx={{ mb: 3 }} />


<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={isZh ? 'zh-hk' : 'en'}
localeText={
isZh
? {
fieldDayPlaceholder: () => '日',
fieldMonthPlaceholder: () => '月',
fieldYearPlaceholder: () => '年',
}
: undefined
}
>
<Grid container spacing={3}> <Grid container spacing={3}>
{currentReport.fields.map((field) => { {currentReport.fields.map((field) => {
const options = field.dynamicOptions
? (dynamicOptions[field.name] || [])
const translatedLabel = fieldLabel(currentReport.id, field);
const rawOptions = field.dynamicOptions
? (dynamicOptions[field.name] || field.options || [])
: (field.options || []); : (field.options || []);
const options = rawOptions.map((opt) => ({
...opt,
label: optionLabel(currentReport.id, field.name, opt),
}));
const currentValue = criteria[field.name] || ''; const currentValue = criteria[field.name] || '';
const valueForSelect = field.multiple const valueForSelect = field.multiple
? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : [])
@@ -430,6 +553,41 @@ export default function ReportPage() {
field.name === 'status' && field.name === 'status' &&
rep012MultiRound; rep012MultiRound;


if (field.type === 'date') {
const parsed = currentValue ? dayjs(currentValue) : null;
return (
<Grid item {...gridSize} key={field.name}>
<DatePicker
label={translatedLabel}
format={dateDisplayFormat}
value={parsed?.isValid() ? parsed : null}
minDate={field.minDate === 'today' ? dayjs().startOf('day') : undefined}
disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled}
onChange={(date) => {
handleFieldChange(
field.name,
date?.isValid() ? date.format(OUTPUT_DATE_FORMAT) : '',
);
}}
slotProps={{
textField: {
fullWidth: true,
sx: currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': {
minHeight: '64px',
fontSize: '1rem'
},
'& .MuiInputLabel-root': {
fontSize: '1rem'
}
} : {},
},
}}
/>
</Grid>
);
}

if (field.type === 'checkbox') { if (field.type === 'checkbox') {
return ( return (
<Grid item {...gridSize} key={field.name}> <Grid item {...gridSize} key={field.name}>
@@ -442,7 +600,7 @@ export default function ReportPage() {
} }
/> />
} }
label={field.label}
label={translatedLabel}
/> />
</Grid> </Grid>
); );
@@ -498,8 +656,8 @@ export default function ReportPage() {
<TextField <TextField
{...params} {...params}
fullWidth fullWidth
label={field.label}
placeholder={field.placeholder || "選擇或輸入物料編號"}
label={translatedLabel}
placeholder={field.placeholder || t('selectOrEnterItemCode')}
sx={currentReport.id === 'rep-005' ? { sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': { '& .MuiOutlinedInput-root': {
minHeight: '64px', minHeight: '64px',
@@ -541,11 +699,10 @@ export default function ReportPage() {
<Grid item {...gridSize} key={field.name}> <Grid item {...gridSize} key={field.name}>
<TextField <TextField
fullWidth fullWidth
label={field.label}
label={translatedLabel}
type={field.type} type={field.type}
placeholder={field.placeholder} placeholder={field.placeholder}
disabled={disabledByCheckedCheckbox || disabledRep012Status}
InputLabelProps={field.type === 'date' ? { shrink: true } : {}}
disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled}
sx={currentReport.id === 'rep-005' ? { sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': { '& .MuiOutlinedInput-root': {
minHeight: '64px', minHeight: '64px',
@@ -624,15 +781,16 @@ export default function ReportPage() {
); );
})} })}
</Grid> </Grid>
</LocalizationProvider>


<Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}> <Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
{currentReport.id === 'rep-005' ? ( {currentReport.id === 'rep-005' ? (
<SemiFGProductionAnalysisReport <SemiFGProductionAnalysisReport
criteria={criteria} criteria={criteria}
requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)}
requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))}
loading={loading} loading={loading}
setLoading={setLoading} setLoading={setLoading}
reportTitle={currentReport.title}
reportTitle={reportTitle(currentReport)}
onExportSuccess={(format) => { onExportSuccess={(format) => {
logFeatureUsage( logFeatureUsage(
FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE.REPORT_MANAGEMENT,
@@ -651,7 +809,7 @@ export default function ReportPage() {
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? "生成 PDF..." : "下載報告 (PDF)"}
{loading ? t('generatingPdf') : t('downloadPdf')}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
@@ -661,7 +819,7 @@ export default function ReportPage() {
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? "生成 Excel..." : "下載報告 (Excel)"}
{loading ? t('generatingExcel') : t('downloadExcel')}
</Button> </Button>
</> </>
) : currentReport.id === 'rep-006' || currentReport.id === 'rep-010' ? ( ) : currentReport.id === 'rep-006' || currentReport.id === 'rep-010' ? (
@@ -674,7 +832,7 @@ export default function ReportPage() {
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? "生成 PDF..." : "下載報告 (PDF)"}
{loading ? t('generatingPdf') : t('downloadPdf')}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
@@ -684,7 +842,7 @@ export default function ReportPage() {
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? "生成 Excel..." : "下載報告 (Excel)"}
{loading ? t('generatingExcel') : t('downloadExcel')}
</Button> </Button>
</> </>
) : currentReport.responseType === 'excel' ? ( ) : currentReport.responseType === 'excel' ? (
@@ -696,7 +854,7 @@ export default function ReportPage() {
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? "生成 Excel..." : "下載報告 (Excel)"}
{loading ? t('generatingExcel') : t('downloadExcel')}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -707,7 +865,7 @@ export default function ReportPage() {
disabled={loading} disabled={loading}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{loading ? "生成報告..." : "下載報告 (PDF)"}
{loading ? t('generatingReport') : t('downloadPdf')}
</Button> </Button>
)} )}
</Box> </Box>
@@ -715,5 +873,49 @@ export default function ReportPage() {
</Card> </Card>
)} )}
</Box> </Box>
<Dialog
open={showNoDataDialog}
onClose={() => setShowNoDataDialog(false)}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
px: 1,
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pt: 3, px: 4, pb: 1.5 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: '50%',
bgcolor: 'warning.light',
color: 'warning.dark',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<InfoOutlinedIcon fontSize="small" />
</Box>
<Typography component="span" variant="h6" fontWeight="bold">
{t('noDataFoundTitle')}
</Typography>
</DialogTitle>
<DialogContent sx={{ px: 4, pt: 0.5 }}>
<Typography color="text.secondary" sx={{ pl: 6.5 }}>
{t('noDataFoundHint')}
</Typography>
</DialogContent>
<DialogActions sx={{ justifyContent: 'center', px: 4, pb: 3, pt: 2 }}>
<Button variant="contained" onClick={() => setShowNoDataDialog(false)} autoFocus sx={{ minWidth: 96 }}>
{t('ok')}
</Button>
</DialogActions>
</Dialog>
</>
); );
} }

+ 3
- 3
src/app/(main)/report/reportCategories.ts Wyświetl plik

@@ -9,7 +9,7 @@ export interface ReportCategoryConfig {
reportIds: string[]; reportIds: string[];
} }


/** Display order and grouping for the report management dashboard. */
/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */
export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [
{ {
id: "inventory", id: "inventory",
@@ -17,7 +17,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [
headerBg: "#b8e0b8", headerBg: "#b8e0b8",
bodyBg: "#eef8ee", bodyBg: "#eef8ee",
accent: "#2e7d32", accent: "#2e7d32",
reportIds: ["rep-011", "rep-007", "rep-012", "rep-010"],
reportIds: ["rep-011", "rep-007", "rep-012", "rep-021", "rep-010"],
}, },
{ {
id: "inbound-outbound", id: "inbound-outbound",
@@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [
headerBg: "#b3d4f0", headerBg: "#b3d4f0",
bodyBg: "#eef5fc", bodyBg: "#eef5fc",
accent: "#1565c0", accent: "#1565c0",
reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017"],
reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017", "rep-018"],
}, },
{ {
id: "production", id: "production",


+ 48
- 0
src/app/(main)/report/reportI18n.ts Wyświetl plik

@@ -0,0 +1,48 @@
"use client";

import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ReportDefinition, ReportField } from "@/config/reportConfig";

/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */
export function useReportLabels() {
const { t, i18n } = useTranslation("report");

const reportTitle = (report: Pick<ReportDefinition, "id" | "title">) =>
t(`reports.${report.id}.title`, { defaultValue: report.title });

const fieldLabel = (
reportId: string,
field: Pick<ReportField, "name" | "label">,
) =>
t(`reports.${reportId}.fields.${field.name}`, {
defaultValue: field.label,
});

const optionLabel = (
reportId: string,
fieldName: string,
opt: { label: string; value: string },
) =>
t(
[
`reports.${reportId}.options.${fieldName}.${opt.value}`,
`options.${opt.value}`,
],
{ defaultValue: opt.label },
);

const categoryTitle = (id: string, fallback: string) =>
t(`categories.${id}`, { defaultValue: fallback });

return { t, i18n, reportTitle, fieldLabel, optionLabel, categoryTitle };
}

export function reportExcelT(
t: TFunction | undefined,
key: string,
fallback: string,
): string {
if (!t) return fallback;
return String(t(key, { defaultValue: fallback }));
}

+ 88
- 39
src/app/(main)/report/shopOrderReplenishmentReportApi.ts Wyświetl plik

@@ -3,6 +3,8 @@
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";
import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx";
import { reportExcelT as tx } from "./reportI18n";
import type { TFunction } from "i18next";


export interface ShopOrderReplenishmentReportRow { export interface ShopOrderReplenishmentReportRow {
shopNo?: string; shopNo?: string;
@@ -13,10 +15,12 @@ export interface ShopOrderReplenishmentReportRow {
itemName?: string; itemName?: string;
firstOrderQty?: number | string; firstOrderQty?: number | string;
firstOrderActualPickQty?: number | string; firstOrderActualPickQty?: number | string;
firstOrderPickerHandler?: string;
reorderQty?: number | string; reorderQty?: number | string;
reorderDate?: string; reorderDate?: string;
reason?: string; reason?: string;
actualDeliveredQty?: number | string; actualDeliveredQty?: number | string;
actualDeliveredHandler?: string;
deliveredDate?: string; deliveredDate?: string;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -25,37 +29,75 @@ export interface ShopOrderReplenishmentReportResponse {
rows: ShopOrderReplenishmentReportRow[]; rows: ShopOrderReplenishmentReportRow[];
} }


const SHEET_NAME = "店鋪訂單補貨記錄";
function shopReplenishmentLabels(t?: TFunction) {
return {
sheetName: tx(t, "excel.shopReplenishment.sheetName", "店鋪訂單補貨記錄"),
noData: tx(t, "excel.noData", "(篩選範圍內無資料)"),
shopCode: tx(t, "excel.shopReplenishment.shopCode", "店鋪編號"),
shopName: tx(t, "excel.shopReplenishment.shopName", "店鋪名稱"),
shopOrderDate: tx(t, "excel.shopReplenishment.shopOrderDate", "店鋪訂單日期"),
shopOrderNo: tx(t, "excel.shopReplenishment.shopOrderNo", "店鋪訂單編號"),
itemCode: tx(t, "excel.shopReplenishment.itemCode", "貨品編號"),
itemName: tx(t, "excel.shopReplenishment.itemName", "貨品名稱"),
firstOrderQty: tx(t, "excel.shopReplenishment.firstOrderQty", "原訂單數量"),
firstOrderActualPickQty: tx(
t,
"excel.shopReplenishment.firstOrderActualPickQty",
"原單實際提料數量",
),
firstOrderPicker: tx(t, "excel.shopReplenishment.firstOrderPicker", "原單提料人"),
reorderQty: tx(t, "excel.shopReplenishment.reorderQty", "補貨數量"),
reorderDate: tx(t, "excel.shopReplenishment.reorderDate", "補貨日期"),
reason: tx(t, "excel.shopReplenishment.reason", "補貨原因"),
actualDeliveredQty: tx(t, "excel.shopReplenishment.actualDeliveredQty", "實際補貨數量"),
actualDeliveredHandler: tx(
t,
"excel.shopReplenishment.actualDeliveredHandler",
"實際補貨提料人",
),
deliveredDate: tx(t, "excel.shopReplenishment.deliveredDate", "送貨日期"),
reasonQuality: tx(t, "excel.shopReplenishment.reasonQuality", "質素問題"),
reasonOutOfStock: tx(t, "excel.shopReplenishment.reasonOutOfStock", "缺貨"),
reasonOther: tx(t, "excel.shopReplenishment.reasonOther", "其他"),
};
}


const NO_DATA_NOTE =
"(篩選範圍內無資料 / No records in the selected range)";
type ShopReplenishmentLabels = ReturnType<typeof shopReplenishmentLabels>;


function emptySheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> {
function emptySheetRow(
L: ShopReplenishmentLabels,
note?: string,
): Record<string, unknown> {
return { return {
"Shop No. / 店鋪編號": note,
"Shop Name / 店鋪名稱": "",
"Shop Order Date / 店鋪訂單日期": "",
"Shop Order No. / 店鋪訂單編號": "",
"Item No. / 貨品編號": "",
"Item Name / 貨品名稱": "",
"First Order Qty / 原訂單數量": "",
"First Order Actual Pick Qty / 原單實際提料數量": "",
"Reorder Qty / 補貨數量": "",
"Reorder Date / 補貨日期": "",
"Reason / 補貨原因": "",
"Actual Delivered Qty / 實際補貨數量": "",
"Delivered Date / 送貨日期": "",
[L.shopCode]: note ?? L.noData,
[L.shopName]: "",
[L.shopOrderDate]: "",
[L.shopOrderNo]: "",
[L.itemCode]: "",
[L.itemName]: "",
[L.firstOrderQty]: "",
[L.firstOrderActualPickQty]: "",
[L.firstOrderPicker]: "",
[L.reorderQty]: "",
[L.reorderDate]: "",
[L.reason]: "",
[L.actualDeliveredQty]: "",
[L.actualDeliveredHandler]: "",
[L.deliveredDate]: "",
}; };
} }


function formatReason(reason: string | undefined): string {
function formatReason(
reason: string | undefined,
L: ShopReplenishmentLabels,
): string {
switch ((reason ?? "").trim()) { switch ((reason ?? "").trim()) {
case "quality_issue": case "quality_issue":
return "質素問題";
return L.reasonQuality;
case "out_of_stock": case "out_of_stock":
return "缺貨";
return L.reasonOutOfStock;
case "other": case "other":
return "其他";
return L.reasonOther;
default: default:
return reason ?? ""; return reason ?? "";
} }
@@ -88,23 +130,27 @@ function formatQty(value: unknown): string | number {
return n; return n;
} }


function toExcelRow(r: ShopOrderReplenishmentReportRow): Record<string, unknown> {
const base = emptySheetRow("");
function toExcelRow(
r: ShopOrderReplenishmentReportRow,
L: ShopReplenishmentLabels,
): Record<string, unknown> {
return { return {
...base,
"Shop No. / 店鋪編號": r.shopNo ?? "",
"Shop Name / 店鋪名稱": r.shopName ?? "",
"Shop Order Date / 店鋪訂單日期": formatDateCell(r.shopOrderDate),
"Shop Order No. / 店鋪訂單編號": r.shopOrderNo ?? "",
"Item No. / 貨品編號": r.itemNo ?? "",
"Item Name / 貨品名稱": r.itemName ?? "",
"First Order Qty / 原訂單數量": formatQty(r.firstOrderQty),
"First Order Actual Pick Qty / 原單實際提料數量": formatQty(r.firstOrderActualPickQty),
"Reorder Qty / 補貨數量": formatQty(r.reorderQty),
"Reorder Date / 補貨日期": formatDateCell(r.reorderDate),
"Reason / 補貨原因": formatReason(r.reason),
"Actual Delivered Qty / 實際補貨數量": formatQty(r.actualDeliveredQty),
"Delivered Date / 送貨日期": formatDateCell(r.deliveredDate),
...emptySheetRow(L, ""),
[L.shopCode]: r.shopNo ?? "",
[L.shopName]: r.shopName ?? "",
[L.shopOrderDate]: formatDateCell(r.shopOrderDate),
[L.shopOrderNo]: r.shopOrderNo ?? "",
[L.itemCode]: r.itemNo ?? "",
[L.itemName]: r.itemName ?? "",
[L.firstOrderQty]: formatQty(r.firstOrderQty),
[L.firstOrderActualPickQty]: formatQty(r.firstOrderActualPickQty),
[L.firstOrderPicker]: r.firstOrderPickerHandler ?? "",
[L.reorderQty]: formatQty(r.reorderQty),
[L.reorderDate]: formatDateCell(r.reorderDate),
[L.reason]: formatReason(r.reason, L),
[L.actualDeliveredQty]: formatQty(r.actualDeliveredQty),
[L.actualDeliveredHandler]: r.actualDeliveredHandler ?? "",
[L.deliveredDate]: formatDateCell(r.deliveredDate),
}; };
} }


@@ -132,15 +178,18 @@ export async function fetchShopOrderReplenishmentReportData(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10
* Generate and download Shop Orders Replenishment Records as Excel. * Generate and download Shop Orders Replenishment Records as Excel.
*/ */
export async function generateShopOrderReplenishmentReportExcel( export async function generateShopOrderReplenishmentReportExcel(
criteria: Record<string, string>, criteria: Record<string, string>,
reportTitle: string = "店鋪訂單補貨記錄", reportTitle: string = "店鋪訂單補貨記錄",
t?: TFunction,
): Promise<void> { ): Promise<void> {
const L = shopReplenishmentLabels(t);
const rows = await fetchShopOrderReplenishmentReportData(criteria); const rows = await fetchShopOrderReplenishmentReportData(criteria);
const excelRows = const excelRows =
rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()];
rows.length > 0 ? rows.map((r) => toExcelRow(r, L)) : [emptySheetRow(L)];


const dateCandidates = [ const dateCandidates = [
criteria.reorderDateStart, criteria.reorderDateStart,
@@ -157,5 +206,5 @@ export async function generateShopOrderReplenishmentReportExcel(
const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
const filename = `${reportTitle}_${safeDatePart}`; const filename = `${reportTitle}_${safeDatePart}`;


exportChartToXlsx(excelRows, filename, SHEET_NAME);
exportChartToXlsx(excelRows, filename, L.sheetName);
} }

+ 21
- 0
src/app/(main)/settings/itemDefaultShelfLife/page.tsx Wyświetl plik

@@ -0,0 +1,21 @@
import ItemDefaultShelfLifeSettings from "@/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings";
import { getServerI18n, I18nProvider } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Item default shelf life",
};

export default async function ItemDefaultShelfLifePage() {
const { t } = await getServerI18n("itemDefaultShelfLife");

return (
<I18nProvider namespaces={["itemDefaultShelfLife", "navigation", "common"]}>
<Stack spacing={2}>
<Typography variant="h4">{t("title")}</Typography>
<ItemDefaultShelfLifeSettings />
</Stack>
</I18nProvider>
);
}

+ 218
- 2
src/app/api/bagPrint/actions.ts Wyświetl plik

@@ -13,6 +13,12 @@ export interface JobOrderListItem {
stockInLineId: number | null; stockInLineId: number | null;
itemId: number | null; itemId: number | null;
lotNo: string | null; lotNo: string | null;
/** Effective shelf life days used for print (chilled or -18, according to useMinus18). */
defaultShelfLifeDays?: number | null;
/** True when expiry is computed from -18 warehouse days. */
useMinus18?: boolean | null;
/** Print date + effective shelf life days (yyyy-MM-dd). */
expiryDate?: string | null;
/** 打袋機 DataFlex cumulative printed qty */ /** 打袋機 DataFlex cumulative printed qty */
bagPrintedQty?: number; bagPrintedQty?: number;
/** 標簽機 cumulative printed qty */ /** 標簽機 cumulative printed qty */
@@ -37,6 +43,8 @@ export interface OnPackQrDownloadRequest {
jobOrderId: number; jobOrderId: number;
itemCode: string; itemCode: string;
}[]; }[];
/** /bagPrint filter date (YYYY-MM-DD). Used by expiry ZIP for production date. */
planDate?: string;
} }


/** Same mapping as Bag Print download buttons: one entry per row with a non-empty item code. */ /** Same mapping as Bag Print download buttons: one entry per row with a non-empty item code. */
@@ -80,17 +88,27 @@ export async function pushOnPackTextQrZipToNgpcl(request: OnPackQrDownloadReques


/** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */ /** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */
async function zipDownloadError(res: Response): Promise<Error> { async function zipDownloadError(res: Response): Promise<Error> {
return parseBagPrintApiError(res, "下載");
}

/** Backend ErrorRes is `{ timestamp, traceId }` with no message; avoid calling that a ZIP download failure. */
async function parseBagPrintApiError(res: Response, action: string): Promise<Error> {
const text = await res.text(); const text = await res.text();
const ct = res.headers.get("content-type") ?? ""; const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) { if (ct.includes("application/json")) {
try { try {
const j = JSON.parse(text) as { message?: string; error?: string };
const j = JSON.parse(text) as { message?: string; error?: string; traceId?: string };
if (typeof j.message === "string" && j.message.length > 0) { if (typeof j.message === "string" && j.message.length > 0) {
return new Error(j.message); return new Error(j.message);
} }
if (typeof j.error === "string" && j.error.length > 0) { if (typeof j.error === "string" && j.error.length > 0) {
return new Error(j.error); return new Error(j.error);
} }
if (typeof j.traceId === "string" && j.traceId.length > 0) {
return new Error(
`${action}失敗(HTTP ${res.status})。請重啟後端以執行 Liquibase,或查看日誌 traceId ${j.traceId}。`,
);
}
} catch { } catch {
/* ignore parse */ /* ignore parse */
} }
@@ -98,7 +116,7 @@ async function zipDownloadError(res: Response): Promise<Error> {
if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) {
return new Error(text); return new Error(text);
} }
return new Error(`下載失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`);
return new Error(`${action}失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`);
} }


/** /**
@@ -149,6 +167,40 @@ export async function downloadOnPackQrZip(
return res.blob(); return res.blob();
} }


export type OnPackZipDownload = {
blob: Blob;
skippedWithoutExpiry: string[];
};

function skippedWithoutExpiryFromResponse(res: Response): string[] {
const raw = res.headers.get("X-OnPack-Skipped-Expiry") ?? "";
return raw
.split(",")
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
}

/** 汁水機 OnPack — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */
export async function downloadOnPackQrZipWithExpiry(
request: OnPackQrDownloadRequest,
): Promise<OnPackZipDownload> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});

if (!res.ok) {
throw await zipDownloadError(res);
}

return {
blob: await res.blob(),
skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res),
};
}

/** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */
export async function downloadOnPackTextQrZip( export async function downloadOnPackTextQrZip(
request: OnPackQrDownloadRequest, request: OnPackQrDownloadRequest,
@@ -166,3 +218,167 @@ export async function downloadOnPackTextQrZip(


return res.blob(); return res.blob();
} }

/** OnPack2023 檸檬機 — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */
export async function downloadOnPackTextQrZipWithExpiry(
request: OnPackQrDownloadRequest,
): Promise<OnPackZipDownload> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});

if (!res.ok) {
throw await zipDownloadError(res);
}

return {
blob: await res.blob(),
skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res),
};
}

export type OnPackMachine = "juice" | "lemon";

export interface OnPackTemplateFileDto {
id: number;
machine: OnPackMachine | string;
itemCode: string;
fileName: string;
byteSize: number;
modified?: string | null;
}

export interface OnPackTemplateUploadResponse {
machine: string;
itemCode: string;
saved: string[];
}

export interface OnPackSupportedItemDto {
itemCode: string;
printable: boolean;
inDatabase: boolean;
builtin: boolean;
registered: boolean;
}

export interface OnPackSupportedCatalogDto {
juice: OnPackSupportedItemDto[];
lemon: OnPackSupportedItemDto[];
}

export interface OnPackExpiryItemCodeDto {
machine: string;
itemCode: string;
printName?: string | null;
defaultPrintName?: string | null;
defaultDays?: number | null;
minus18Days?: number | null;
useMinus18?: boolean;
effectiveDays?: number | null;
}

export type OnPackExpiryItemCodeUpdate = {
itemCode: string;
machine?: OnPackMachine;
printName?: string | null;
useMinus18?: boolean;
};

export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice"): Promise<OnPackExpiryItemCodeDto[]> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}`;
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
throw await parseBagPrintApiError(res, "讀取到期日 ZIP 品號");
}
return (await res.json()) as OnPackExpiryItemCodeDto[];
}

export async function addOnPackExpiryCode(
itemCode: string,
machine: OnPackMachine = "juice",
): Promise<OnPackExpiryItemCodeDto> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemCode, machine }),
});
if (!res.ok) {
throw await parseBagPrintApiError(res, "新增到期日 ZIP 品號");
}
return (await res.json()) as OnPackExpiryItemCodeDto;
}

export async function updateOnPackExpiryCode(
body: OnPackExpiryItemCodeUpdate,
): Promise<OnPackExpiryItemCodeDto> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`;
const res = await clientAuthFetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw await parseBagPrintApiError(res, "更新到期日 ZIP 品號");
}
return (await res.json()) as OnPackExpiryItemCodeDto;
}

export async function deleteOnPackExpiryCode(
itemCode: string,
machine: OnPackMachine = "juice",
): Promise<void> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}&itemCode=${encodeURIComponent(itemCode)}`;
const res = await clientAuthFetch(url, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
throw await parseBagPrintApiError(res, "刪除到期日 ZIP 品號");
}
}

export async function fetchOnPackSupportedCatalog(): Promise<OnPackSupportedCatalogDto> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`;
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單");
}
return (await res.json()) as OnPackSupportedCatalogDto;
}

export async function listOnPackTemplates(machine?: OnPackMachine): Promise<OnPackTemplateFileDto[]> {
const q = machine ? `?machine=${encodeURIComponent(machine)}` : "";
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`;
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
throw await parseBagPrintApiError(res, "讀取 OnPack 模板");
}
return (await res.json()) as OnPackTemplateFileDto[];
}

export async function uploadOnPackTemplates(
machine: OnPackMachine,
itemCode: string,
files: File[],
): Promise<OnPackTemplateUploadResponse> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`;
const body = new FormData();
body.append("machine", machine);
body.append("itemCode", itemCode);
files.forEach((f) => body.append("files", f));
const res = await clientAuthFetch(url, { method: "POST", body });
if (!res.ok) {
throw await parseBagPrintApiError(res, "上傳 OnPack 模板");
}
return (await res.json()) as OnPackTemplateUploadResponse;
}

export async function deleteOnPackTemplate(id: number): Promise<void> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/${id}`;
const res = await clientAuthFetch(url, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
throw await parseBagPrintApiError(res, "刪除 OnPack 模板");
}
}

+ 5
- 0
src/app/api/chart/client.ts Wyświetl plik

@@ -131,6 +131,8 @@ export interface StaffDeliveryPerformanceRow {
staffName: string; staffName: string;
orderCount: number; orderCount: number;
totalMinutes: number; totalMinutes: number;
itemKindCount: number;
itemQtyPicked: number;
} }


export interface StaffOption { export interface StaffOption {
@@ -577,6 +579,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" | "2/F" | "4/F" | "null_only";


/** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */
export async function fetchStaffDeliveryPerformance( export async function fetchStaffDeliveryPerformance(
startDate?: string, startDate?: string,
endDate?: string, endDate?: string,
@@ -604,6 +607,8 @@ export async function fetchStaffDeliveryPerformance(
staffName: String(row.staffName ?? row.staffname ?? ""), staffName: String(row.staffName ?? row.staffname ?? ""),
orderCount: Number(row.orderCount ?? row.ordercount ?? 0), orderCount: Number(row.orderCount ?? row.ordercount ?? 0),
totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0), totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0),
itemKindCount: Number(row.itemKindCount ?? row.itemkindcount ?? 0),
itemQtyPicked: Number(row.itemQtyPicked ?? row.itemqtypicked ?? 0),
}; };
}); });
} }


+ 32
- 0
src/app/api/jo/actions.ts Wyświetl plik

@@ -1741,6 +1741,38 @@ export const fetchDrinkProductionQty = cache(
}, },
); );


export interface DrinkShipmentQtyDeliveryDetail {
deliveryOrderId: number;
deliveryOrderCode?: string | null;
deliveryDate?: string | null;
shopCode?: string | null;
shopName?: string | null;
deliveryOrderStatus?: string | null;
orderQty: number;
shippedQty: number;
}

export interface DrinkShipmentQtyResponse {
itemCode?: string | null;
itemName?: string | null;
uom?: string | null;
totalOrderQty: number;
totalShippedQty: number;
deliveries?: DrinkShipmentQtyDeliveryDetail[];
}

export const fetchDrinkShipmentQty = cache(async (date?: string) => {
const params = new URLSearchParams();
if (date) params.set("date", date);
const qs = params.toString();
const url = `${BASE_API_URL}/product-process/Demo/DrinkShipmentQty${qs ? `?${qs}` : ""}`;

return serverFetchJson<DrinkShipmentQtyResponse[]>(url, {
method: "GET",
next: { tags: ["drinkShipmentQty"] },
});
});

// ===== Equipment Status Dashboard ===== // ===== Equipment Status Dashboard =====


export interface EquipmentStatusProcessInfo { export interface EquipmentStatusProcessInfo {


+ 61
- 4
src/app/api/laserPrint/actions.ts Wyświetl plik

@@ -13,6 +13,10 @@ export interface JobOrderListItem {
stockInLineId: number | null; stockInLineId: number | null;
itemId: number | null; itemId: number | null;
lotNo: string | null; lotNo: string | null;
defaultShelfLifeDays?: number | null;
useMinus18?: boolean | null;
/** ISO `yyyy-MM-dd`, or Jackson date array `[yyyy,M,d]`. */
expiryDate?: string | number[] | null;
bagPrintedQty?: number; bagPrintedQty?: number;
labelPrintedQty?: number; labelPrintedQty?: number;
laserPrintedQty?: number; laserPrintedQty?: number;
@@ -48,6 +52,8 @@ export interface LaserBag2SendRequest {
jobOrderId?: number | null; jobOrderId?: number | null;
jobOrderNo?: string | null; jobOrderNo?: string | null;
lotNo?: string | null; lotNo?: string | null;
/** Print-time expiry from job list (`yyyy-MM-dd`); backend sends as 4th TCP field. */
expiryDate?: string | null;
source?: string | null; source?: string | null;
} }


@@ -109,6 +115,39 @@ export async function fetchLaserBag2Settings(): Promise<LaserBag2Settings> {
return res.json() as Promise<LaserBag2Settings>; return res.json() as Promise<LaserBag2Settings>;
} }


/** List API may return LocalDate as `"2026-08-27"` or `[2026,8,27]` (@EnableWebMvc raw Jackson). */
export function expiryDateForLaserSend(value: unknown): string | null {
if (value == null || value === "") return null;
if (typeof value === "string") {
const s = value.trim();
if (!s) return null;
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
return s;
}
if (Array.isArray(value) && value.length >= 3) {
const y = Number(value[0]);
const m = Number(value[1]);
const d = Number(value[2]);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d) || y < 1 || m < 1 || d < 1) {
return null;
}
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
}
return null;
}

function messageFromLaserSendBody(data: Record<string, unknown>, status: number): string {
const message = typeof data.message === "string" ? data.message.trim() : "";
if (message) return message;
const detail = typeof data.detail === "string" ? data.detail.trim() : "";
if (detail) return detail;
const error = typeof data.error === "string" ? data.error.trim() : "";
if (error) return error;
const traceId = typeof data.traceId === "string" ? data.traceId.trim() : "";
if (traceId) return `送出失敗(traceId ${traceId})`;
return `送出失敗(HTTP ${status})`;
}

export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> { export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`;
const res = await clientAuthFetch(url, { const res = await clientAuthFetch(url, {
@@ -116,11 +155,29 @@ export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<Lase
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = (await res.json()) as LaserBag2SendResponse;
if (!res.ok) {
return data;
let data: Record<string, unknown> = {};
try {
const text = await res.text();
data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
} catch {
return { success: false, message: `送出失敗(HTTP ${res.status},無法解析回應)` };
} }
return data;
if (!res.ok || data.success === false) {
return {
success: false,
message: messageFromLaserSendBody(data, res.status),
payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null,
printerAck: typeof data.printerAck === "string" ? data.printerAck : null,
receiveAcknowledged: Boolean(data.receiveAcknowledged),
};
}
return {
success: true,
message: typeof data.message === "string" && data.message.trim() ? data.message : "已送出",
payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null,
printerAck: typeof data.printerAck === "string" ? data.printerAck : null,
receiveAcknowledged: Boolean(data.receiveAcknowledged),
};
} }


export interface PrinterStatusRequest { export interface PrinterStatusRequest {


+ 88
- 0
src/app/api/settings/itemDefaultShelfLife/client.ts Wyświetl plik

@@ -0,0 +1,88 @@
"use client";

import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

const base = NEXT_PUBLIC_API_URL;

export type ItemDefaultShelfLifeRow = {
id: number;
itemCode: string;
itemName?: string | null;
defaultDays?: number | null;
minus18Days?: number | null;
useMinus18: boolean;
openedDays?: number | null;
storageC?: string | null;
remarks?: string | null;
effectiveDays?: number | null;
};

export type ItemDefaultShelfLifeInput = {
itemCode: string;
defaultDays?: number | null;
minus18Days?: number | null;
useMinus18: boolean;
openedDays?: number | null;
storageC?: string | null;
remarks?: string | null;
};

async function parseJson<T>(res: Response): Promise<T> {
if (!res.ok) {
throw new Error(await readError(res));
}
return res.json() as Promise<T>;
}

async function readError(res: Response): Promise<string> {
const text = await res.text().catch(() => "");
if (!text) return `HTTP ${res.status}`;
try {
const json = JSON.parse(text) as { message?: string; error?: string };
return json.message || json.error || text;
} catch {
return text;
}
}

export async function fetchItemDefaultShelfLives(
q?: string,
): Promise<ItemDefaultShelfLifeRow[]> {
const url = new URL(`${base}/itemDefaultShelfLives`);
if (q?.trim()) url.searchParams.set("q", q.trim());
const res = await clientAuthFetch(url.toString(), { method: "GET" });
return parseJson<ItemDefaultShelfLifeRow[]>(res);
}

export async function createItemDefaultShelfLife(
data: ItemDefaultShelfLifeInput,
): Promise<ItemDefaultShelfLifeRow> {
const res = await clientAuthFetch(`${base}/itemDefaultShelfLives`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return parseJson<ItemDefaultShelfLifeRow>(res);
}

export async function updateItemDefaultShelfLife(
id: number,
data: ItemDefaultShelfLifeInput,
): Promise<ItemDefaultShelfLifeRow> {
const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return parseJson<ItemDefaultShelfLifeRow>(res);
}

export async function deleteItemDefaultShelfLife(
id: number,
): Promise<ItemDefaultShelfLifeRow[]> {
const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, {
method: "DELETE",
});
return parseJson<ItemDefaultShelfLifeRow[]>(res);
}

+ 10
- 1
src/app/api/user/client.ts Wyświetl plik

@@ -164,6 +164,15 @@ export const updateUser = async (
if (response.status === 401) { if (response.status === 401) {
throw new Error("Unauthorized: Please log in again"); throw new Error("Unauthorized: Please log in again");
} }
throw new Error(`Failed to update user: ${response.status} ${response.statusText}`);
let detail = "";
try {
const body = await response.json();
detail = body?.error || body?.message || "";
} catch {
// ignore parse errors
}
throw new Error(
`Failed to update user: ${response.status} ${response.statusText}${detail ? `. ${detail}` : ""}`,
);
} }
}; };

+ 2
- 0
src/authorities.ts Wyświetl plik

@@ -25,6 +25,8 @@ export const AUTH = {
*/ */
PRODUCT_PROCESS: "PRODUCT_PROCESS", PRODUCT_PROCESS: "PRODUCT_PROCESS",
REPORT_MGMT: "REPORT_MGMT", REPORT_MGMT: "REPORT_MGMT",
/** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 — Manual M18 sync page (/m18Syn): ADMIN or this ability */
M18_SYNC: "M18_SYNC",
} as const; } as const;


/** /**


+ 2
- 0
src/components/AppBar/AppBar.tsx Wyświetl plik

@@ -1,6 +1,7 @@
import MUIAppBar from "@mui/material/AppBar"; import MUIAppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import React from "react"; import React from "react";
import LanguageSwitcher from "./LanguageSwitcher";
import Profile from "./Profile"; import Profile from "./Profile";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import NavigationToggle from "./NavigationToggle"; import NavigationToggle from "./NavigationToggle";
@@ -35,6 +36,7 @@ const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => {
gap: 1, gap: 1,
}} }}
> >
<LanguageSwitcher />
<Profile <Profile
avatarImageSrc={avatarImageSrc} avatarImageSrc={avatarImageSrc}
profileName={profileName} profileName={profileName}


+ 60
- 0
src/components/AppBar/LanguageSwitcher.tsx Wyświetl plik

@@ -0,0 +1,60 @@
"use client";

import React, { useRef } from "react";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useTranslation } from "react-i18next";
import {
type AppLanguage,
isAppLanguage,
setLanguageCookie,
} from "@/i18n/locale";

const LanguageSwitcher: React.FC = () => {
const { i18n, t } = useTranslation("common");
const { update } = useSession();
const router = useRouter();
const inFlightRef = useRef(false);

const current: AppLanguage = isAppLanguage(i18n.language) ? i18n.language : "zh";

const onChange = async (_: React.MouseEvent<HTMLElement>, next: AppLanguage | null) => {
if (!next || next === current) return;
if (inFlightRef.current) return;
inFlightRef.current = true;
try {
setLanguageCookie(next);
await update({ locale: next });
router.refresh();
} finally {
inFlightRef.current = false;
}
};

return (
<ToggleButtonGroup
exclusive
size="small"
value={current}
onChange={onChange}
aria-label={t("Language")}
sx={{
mr: 0.5,
"& .MuiToggleButton-root": {
px: 1,
py: 0.25,
fontSize: "0.75rem",
lineHeight: 1.4,
textTransform: "none",
},
}}
>
<ToggleButton value="zh">中</ToggleButton>
<ToggleButton value="en">EN</ToggleButton>
</ToggleButtonGroup>
);
};

export default LanguageSwitcher;

+ 704
- 7
src/components/BagPrint/BagPrintSearch.tsx Wyświetl plik

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


import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Alert,
Box, Box,
Button, Button,
Chip,
FormControl, FormControl,
InputLabel, InputLabel,
MenuItem, MenuItem,
@@ -19,6 +21,15 @@ import {
DialogActions, DialogActions,
TextField, TextField,
Snackbar, Snackbar,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight"; import ChevronRight from "@mui/icons-material/ChevronRight";
@@ -29,11 +40,21 @@ import {
buildOnPackJobOrdersPayload, buildOnPackJobOrdersPayload,
checkPrinterStatus, checkPrinterStatus,
downloadOnPackQrZip, downloadOnPackQrZip,
downloadOnPackQrZipWithExpiry,
downloadOnPackTextQrZip, downloadOnPackTextQrZip,
downloadOnPackTextQrZipWithExpiry,
fetchJobOrders, fetchJobOrders,
fetchOnPackExpiryCodes,
addOnPackExpiryCode,
updateOnPackExpiryCode,
deleteOnPackExpiryCode,
fetchOnPackSupportedCatalog,
JobOrderListItem, JobOrderListItem,
OnPackExpiryItemCodeDto,
} from "@/app/api/bagPrint/actions"; } from "@/app/api/bagPrint/actions";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
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";


@@ -56,6 +77,28 @@ const REFRESH_MS = 60 * 1000;
const PRINTER_CHECK_MS = 60 * 1000; const PRINTER_CHECK_MS = 60 * 1000;
const PRINTER_RETRY_MS = 30 * 1000; const PRINTER_RETRY_MS = 30 * 1000;
const SETTINGS_KEY = "bagPrint_settings"; const SETTINGS_KEY = "bagPrint_settings";
const ONPACK_ADMIN_USERNAME = "2fi";

/** Login username from backend JWT `sub` (UserDetails.username). */
function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string {
const token = session?.accessToken?.trim();
if (token) {
try {
const parts = token.split(".");
if (parts.length >= 2) {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const payload = JSON.parse(atob(padded)) as { sub?: unknown };
if (typeof payload.sub === "string" && payload.sub.trim()) {
return payload.sub.trim();
}
}
} catch {
// fall through to display name
}
}
return (session?.user?.name ?? "").trim();
}


const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
dabag_ip: "", dabag_ip: "",
@@ -95,7 +138,91 @@ function getBatch(jo: JobOrderListItem): string {
return (jo.lotNo || "—").trim() || "—"; return (jo.lotNo || "—").trim() || "—";
} }


function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set<string> {
return new Set(rows.map((r) => r.itemCode.trim().toUpperCase()).filter(Boolean));
}

function daysLabel(value: number | null | undefined): string {
return value == null ? "未設" : String(value);
}

type ExpirySortKey =
| "itemCode"
| "name"
| "defaultDays"
| "minus18Days"
| "useMinus18"
| "effectiveDays";

function displayName(row: OnPackExpiryItemCodeDto): string {
return (row.printName || row.defaultPrintName || "").trim();
}

function cmpText(a: string, b: string): number {
return a.localeCompare(b, "zh-Hant", { numeric: true, sensitivity: "base" });
}

/** Null (未設) sorts first on asc so unset rows are easy to find. */
function cmpDays(a: number | null | undefined, b: number | null | undefined): number {
const av = a == null ? Number.NEGATIVE_INFINITY : a;
const bv = b == null ? Number.NEGATIVE_INFINITY : b;
return av - bv;
}

function skippedExpirySnackbar(okMessage: string, skipped: string[]): {
open: true;
message: string;
severity: "success" | "warning";
duration: number;
} {
if (skipped.length === 0) {
return { open: true, message: okMessage, severity: "success", duration: 3000 };
}
return {
open: true,
message: `${okMessage}。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`,
severity: "warning",
duration: 10000,
};
}

function sortExpiryRows(
rows: OnPackExpiryItemCodeDto[],
key: ExpirySortKey,
dir: "asc" | "desc",
): OnPackExpiryItemCodeDto[] {
const sign = dir === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
let cmp = 0;
switch (key) {
case "itemCode":
cmp = cmpText(a.itemCode, b.itemCode);
break;
case "name":
cmp = cmpText(displayName(a), displayName(b));
break;
case "defaultDays":
cmp = cmpDays(a.defaultDays, b.defaultDays);
break;
case "minus18Days":
cmp = cmpDays(a.minus18Days, b.minus18Days);
break;
case "useMinus18":
cmp = Number(a.useMinus18 === true) - Number(b.useMinus18 === true);
break;
case "effectiveDays":
cmp = cmpDays(a.effectiveDays, b.effectiveDays);
break;
}
if (cmp === 0) cmp = cmpText(a.itemCode, b.itemCode);
return cmp * sign;
});
}

const BagPrintSearch: React.FC = () => { const BagPrintSearch: React.FC = () => {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const canSeeOnPackAdmin =
loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME;
const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -109,12 +236,35 @@ const BagPrintSearch: React.FC = () => {
const [printContinuous, setPrintContinuous] = useState(false); const [printContinuous, setPrintContinuous] = useState(false);
const [printing, setPrinting] = useState(false); const [printing, setPrinting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" });
const [templatesOpen, setTemplatesOpen] = useState(false);
const [expiryCodes, setExpiryCodes] = useState<OnPackExpiryItemCodeDto[]>([]);
const [expiryCodeInput, setExpiryCodeInput] = useState("");
const [expiryCodesLoading, setExpiryCodesLoading] = useState(false);
const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({});
const [expirySortKey, setExpirySortKey] = useState<ExpirySortKey>("itemCode");
const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc");
const [lemonCodeSet, setLemonCodeSet] = useState<Set<string>>(() => new Set());
const expiryAddRef = useRef(false);
const expiryDeleteRef = useRef(false);
const expirySaveRef = useRef<Set<string>>(new Set());
const expiryToggleRef = useRef<Set<string>>(new Set());
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity?: "success" | "info" | "warning" | "error";
duration?: number;
}>({ open: false, message: "" });
const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [printerConnected, setPrinterConnected] = useState(false); const [printerConnected, setPrinterConnected] = useState(false);
const [printerMessage, setPrinterMessage] = useState("列印機未連接"); const [printerMessage, setPrinterMessage] = useState("列印機未連接");
const [downloadingOnPack, setDownloadingOnPack] = useState(false); const [downloadingOnPack, setDownloadingOnPack] = useState(false);
const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false);
const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); const [downloadingOnPackText, setDownloadingOnPackText] = useState(false);
const [downloadingOnPackTextExp, setDownloadingOnPackTextExp] = useState(false);
const downloadingOnPackRef = useRef(false);
const downloadingOnPackExpRef = useRef(false);
const downloadingOnPackTextRef = useRef(false);
const downloadingOnPackTextExpRef = useRef(false);


useEffect(() => { useEffect(() => {
setSettings(loadSettings()); setSettings(loadSettings());
@@ -275,6 +425,7 @@ const BagPrintSearch: React.FC = () => {
}; };


const handleDownloadOnPackQr = async () => { const handleDownloadOnPackQr = async () => {
if (downloadingOnPackRef.current) return;
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);


if (onPackJobOrders.length === 0) { if (onPackJobOrders.length === 0) {
@@ -282,6 +433,7 @@ const BagPrintSearch: React.FC = () => {
return; return;
} }


downloadingOnPackRef.current = true;
setDownloadingOnPack(true); setDownloadingOnPack(true);
try { try {
const blob = await downloadOnPackQrZip({ const blob = await downloadOnPackQrZip({
@@ -306,10 +458,51 @@ const BagPrintSearch: React.FC = () => {
}); });
} finally { } finally {
setDownloadingOnPack(false); setDownloadingOnPack(false);
downloadingOnPackRef.current = false;
}
};

const handleDownloadOnPackQrWithExpiry = async () => {
if (downloadingOnPackExpRef.current) return;
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);

if (onPackJobOrders.length === 0) {
setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
return;
}

downloadingOnPackExpRef.current = true;
setDownloadingOnPackExp(true);
try {
const { blob, skippedWithoutExpiry } = await downloadOnPackQrZipWithExpiry({
jobOrders: onPackJobOrders,
planDate,
});

const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `onpack_qr_exp_${planDate}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);

setSnackbar(skippedExpirySnackbar("OnPack 汁水機(含到期日)ZIP 已下載", skippedWithoutExpiry));
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "下載 OnPack 汁水機(含到期日)失敗",
severity: "error",
});
} finally {
setDownloadingOnPackExp(false);
downloadingOnPackExpRef.current = false;
} }
}; };


const handleDownloadOnPackTextQr = async () => { const handleDownloadOnPackTextQr = async () => {
if (downloadingOnPackTextRef.current) return;
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);


if (onPackJobOrders.length === 0) { if (onPackJobOrders.length === 0) {
@@ -317,6 +510,7 @@ const BagPrintSearch: React.FC = () => {
return; return;
} }


downloadingOnPackTextRef.current = true;
setDownloadingOnPackText(true); setDownloadingOnPackText(true);
try { try {
const blob = await downloadOnPackTextQrZip({ const blob = await downloadOnPackTextQrZip({
@@ -341,6 +535,209 @@ const BagPrintSearch: React.FC = () => {
}); });
} finally { } finally {
setDownloadingOnPackText(false); setDownloadingOnPackText(false);
downloadingOnPackTextRef.current = false;
}
};

const handleDownloadOnPackTextQrWithExpiry = async () => {
if (downloadingOnPackTextExpRef.current) return;
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);

if (onPackJobOrders.length === 0) {
setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
return;
}

downloadingOnPackTextExpRef.current = true;
setDownloadingOnPackTextExp(true);
try {
const { blob, skippedWithoutExpiry } = await downloadOnPackTextQrZipWithExpiry({
jobOrders: onPackJobOrders,
planDate,
});

const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `onpack2023_lemon_qr_exp_${planDate}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);

setSnackbar(skippedExpirySnackbar("OnPack2023檸檬機(含到期日)ZIP 已下載", skippedWithoutExpiry));
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機(含到期日)失敗",
severity: "error",
});
} finally {
setDownloadingOnPackTextExp(false);
downloadingOnPackTextExpRef.current = false;
}
};

const loadExpiryCodes = useCallback(async (notify = false) => {
setExpiryCodesLoading(true);
try {
const rows = await fetchOnPackExpiryCodes("juice");
setExpiryCodes(rows);
setNameDrafts(
Object.fromEntries(
rows.map((r) => [r.itemCode, r.printName || r.defaultPrintName || ""]),
),
);
} catch (e) {
if (notify) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "讀取到期日 ZIP 品號失敗",
severity: "error",
});
}
} finally {
setExpiryCodesLoading(false);
}
}, []);

useEffect(() => {
void loadExpiryCodes();
}, [loadExpiryCodes]);

useEffect(() => {
void (async () => {
try {
const catalog = await fetchOnPackSupportedCatalog();
setLemonCodeSet(
new Set(
(catalog.lemon ?? [])
.filter((row) => row.printable)
.map((row) => row.itemCode.trim().toUpperCase())
.filter(Boolean),
),
);
} catch {
/* 檸檬機標籤可沒有;不擋畫面 */
}
})();
}, []);

useEffect(() => {
if (!templatesOpen) return;
void loadExpiryCodes(true);
}, [templatesOpen, loadExpiryCodes]);

const handleAddExpiryCode = async () => {
if (expiryAddRef.current) return;
const itemCode = expiryCodeInput.trim();
if (!itemCode) {
setSnackbar({ open: true, message: "請先填寫品號", severity: "error" });
return;
}
expiryAddRef.current = true;
try {
await addOnPackExpiryCode(itemCode, "juice");
setExpiryCodeInput("");
setSnackbar({ open: true, message: `已加入到期日 ZIP:${itemCode.toUpperCase()}`, severity: "success" });
await loadExpiryCodes();
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "新增到期日 ZIP 品號失敗",
severity: "error",
});
} finally {
expiryAddRef.current = false;
}
};

const handleDeleteExpiryCode = async (itemCode: string) => {
if (expiryDeleteRef.current) return;
expiryDeleteRef.current = true;
try {
await deleteOnPackExpiryCode(itemCode, "juice");
setSnackbar({ open: true, message: `已從到期日 ZIP 移除 ${itemCode}`, severity: "success" });
await loadExpiryCodes();
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "刪除到期日 ZIP 品號失敗",
severity: "error",
});
} finally {
expiryDeleteRef.current = false;
}
};

const applyExpiryRow = (updated: OnPackExpiryItemCodeDto) => {
setExpiryCodes((prev) => prev.map((r) => (r.itemCode === updated.itemCode ? updated : r)));
setNameDrafts((prev) => ({
...prev,
[updated.itemCode]: updated.printName || updated.defaultPrintName || "",
}));
};

const handleSaveExpiryPrintName = async (itemCode: string) => {
if (expirySaveRef.current.has(itemCode)) return;
expirySaveRef.current.add(itemCode);
try {
const updated = await updateOnPackExpiryCode({
itemCode,
machine: "juice",
printName: (nameDrafts[itemCode] ?? "").trim(),
});
applyExpiryRow(updated);
setSnackbar({ open: true, message: `已儲存 ${itemCode} 列印名稱`, severity: "success" });
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "儲存列印名稱失敗",
severity: "error",
});
} finally {
expirySaveRef.current.delete(itemCode);
}
};

const handleToggleUseMinus18 = async (itemCode: string, useMinus18: boolean) => {
if (expiryToggleRef.current.has(itemCode)) return;
expiryToggleRef.current.add(itemCode);
try {
const updated = await updateOnPackExpiryCode({
itemCode,
machine: "juice",
useMinus18,
});
applyExpiryRow(updated);
setSnackbar({
open: true,
message: useMinus18 ? `已改用 ${itemCode} 的 -18 天數` : `已改用 ${itemCode} 的冷藏天數`,
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "更新保質期旗標失敗",
severity: "error",
});
} finally {
expiryToggleRef.current.delete(itemCode);
}
};

const juiceExpiryCodeSet = expiryCodeSet(expiryCodes);
const sortedExpiryCodes = useMemo(
() => sortExpiryRows(expiryCodes, expirySortKey, expirySortDir),
[expiryCodes, expirySortKey, expirySortDir],
);

const onExpirySort = (key: ExpirySortKey) => {
if (expirySortKey === key) {
setExpirySortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setExpirySortKey(key);
setExpirySortDir("asc");
} }
}; };


@@ -369,6 +766,11 @@ const BagPrintSearch: React.FC = () => {
<Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}>
設定 設定
</Button> </Button>
{canSeeOnPackAdmin && (
<Button variant="outlined" onClick={() => setTemplatesOpen(true)}>
OnPack 到期日 ZIP
</Button>
)}
<Box <Box
sx={{ sx={{
px: 1.5, px: 1.5,
@@ -403,19 +805,66 @@ const BagPrintSearch: React.FC = () => {
variant="contained" variant="contained"
startIcon={<Download />} startIcon={<Download />}
onClick={handleDownloadOnPackQr} onClick={handleDownloadOnPackQr}
disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0}
disabled={
loading ||
downloadingOnPack ||
downloadingOnPackExp ||
downloadingOnPackText ||
downloadingOnPackTextExp ||
jobOrders.length === 0
}
> >
{downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"}
</Button> </Button>
<Button
variant="contained"
startIcon={<Download />}
onClick={handleDownloadOnPackQrWithExpiry}
disabled={
loading ||
downloadingOnPack ||
downloadingOnPackExp ||
downloadingOnPackText ||
downloadingOnPackTextExp ||
jobOrders.length === 0
}
>
{downloadingOnPackExp ? "下載中..." : "下載 OnPack 汁水機(含到期日)"}
</Button>
<Button <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
startIcon={<Download />} startIcon={<Download />}
onClick={handleDownloadOnPackTextQr} onClick={handleDownloadOnPackTextQr}
disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0}
disabled={
loading ||
downloadingOnPack ||
downloadingOnPackExp ||
downloadingOnPackText ||
downloadingOnPackTextExp ||
jobOrders.length === 0
}
> >
{downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"} {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"}
</Button> </Button>
{canSeeOnPackAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={<Download />}
onClick={handleDownloadOnPackTextQrWithExpiry}
disabled={
loading ||
downloadingOnPack ||
downloadingOnPackExp ||
downloadingOnPackText ||
downloadingOnPackTextExp ||
jobOrders.length === 0
}
>
{downloadingOnPackTextExp ? "下載中..." : "下載 OnPack2023檸檬機(含到期日)"}
</Button>
)}
</Stack> </Stack>
</Paper> </Paper>


@@ -436,6 +885,9 @@ const BagPrintSearch: React.FC = () => {
const batch = getBatch(jo); const batch = getBatch(jo);
const qtyStr = formatQty(jo.reqQty); const qtyStr = formatQty(jo.reqQty);
const isSelected = selectedId === jo.id; const isSelected = selectedId === jo.id;
const codeKey = (jo.itemCode || "").trim().toUpperCase();
const juiceExpiryOk = juiceExpiryCodeSet.has(codeKey);
const lemonOk = lemonCodeSet.has(codeKey);
return ( return (
<Paper <Paper
key={jo.id} key={jo.id}
@@ -471,6 +923,10 @@ const BagPrintSearch: React.FC = () => {
<Typography variant="h6" sx={{ fontSize: "1.35rem" }}> <Typography variant="h6" sx={{ fontSize: "1.35rem" }}>
{jo.itemCode || "—"} {jo.itemCode || "—"}
</Typography> </Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}>
{juiceExpiryOk ? <Chip size="small" label="汁水機" color="primary" /> : null}
{lemonOk ? <Chip size="small" label="檸檬機" color="secondary" /> : null}
</Stack>
</Box> </Box>
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}> <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}>
@@ -632,13 +1088,254 @@ const BagPrintSearch: React.FC = () => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>


<Dialog
open={templatesOpen && canSeeOnPackAdmin}
onClose={() => setTemplatesOpen(false)}
maxWidth="xl"
fullWidth
scroll="paper"
PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }}
>
<DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle>
<DialogContent
sx={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
pt: 1,
}}
>
<Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}>
<Typography variant="subtitle2" color="primary">
汁水機({expiryCodes.length})
</Typography>
<Typography variant="body2" color="text.secondary">
「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。
點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
label="新增品號"
size="small"
placeholder="例如 PP2211"
value={expiryCodeInput}
onChange={(e) => setExpiryCodeInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAddExpiryCode();
}
}}
sx={{ minWidth: 180 }}
/>
<Button variant="contained" onClick={() => void handleAddExpiryCode()}>
加入
</Button>
</Stack>
</Stack>
{expiryCodesLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 1 }}>
<CircularProgress size={20} />
</Box>
) : expiryCodes.length === 0 ? (
<Typography color="text.secondary">清單空白</Typography>
) : (
<TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
<TableSortLabel
active={expirySortKey === "itemCode"}
direction={expirySortKey === "itemCode" ? expirySortDir : "asc"}
onClick={() => onExpirySort("itemCode")}
>
品號
</TableSortLabel>
</TableCell>
<TableCell sx={{ fontWeight: 700 }}>
<TableSortLabel
active={expirySortKey === "name"}
direction={expirySortKey === "name" ? expirySortDir : "asc"}
onClick={() => onExpirySort("name")}
>
中文名稱+單位
</TableSortLabel>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
<TableSortLabel
active={expirySortKey === "defaultDays"}
direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"}
onClick={() => onExpirySort("defaultDays")}
>
冷藏
</TableSortLabel>
<Typography variant="caption" display="block" color="text.secondary">
</Typography>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
<TableSortLabel
active={expirySortKey === "minus18Days"}
direction={expirySortKey === "minus18Days" ? expirySortDir : "asc"}
onClick={() => onExpirySort("minus18Days")}
>
-18
</TableSortLabel>
<Typography variant="caption" display="block" color="text.secondary">
</Typography>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
<TableSortLabel
active={expirySortKey === "useMinus18"}
direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"}
onClick={() => onExpirySort("useMinus18")}
>
用 -18
</TableSortLabel>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
<TableSortLabel
active={expirySortKey === "effectiveDays"}
direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"}
onClick={() => onExpirySort("effectiveDays")}
>
列印
</TableSortLabel>
<Typography variant="caption" display="block" color="text.secondary">
</Typography>
</TableCell>
<TableCell align="right" sx={{ fontWeight: 700 }}>
操作
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedExpiryCodes.map((row) => {
const draft = nameDrafts[row.itemCode] ?? "";
const savedName = row.printName || row.defaultPrintName || "";
const nameDirty = draft.trim() !== savedName.trim();
const hasShelf = row.defaultDays != null || row.minus18Days != null;
const canUseMinus18 = row.minus18Days != null && row.minus18Days > 0;
const missingHint = hasShelf
? canUseMinus18
? ""
: "此品號沒有 -18 天數"
: "未設定保質期,請到設定 → 物品預設保質期新增";
return (
<TableRow key={row.itemCode} hover>
<TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}>
{row.itemCode}
</TableCell>
<TableCell sx={{ minWidth: 280 }}>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
value={draft}
onChange={(e) =>
setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value }))
}
placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"}
inputProps={{ maxLength: 255 }}
fullWidth
/>
<Button
variant="contained"
size="small"
disabled={!nameDirty}
onClick={() => void handleSaveExpiryPrintName(row.itemCode)}
>
儲存
</Button>
</Stack>
</TableCell>
<TableCell align="center">
<Typography
variant="h6"
sx={{ fontWeight: 700, lineHeight: 1.2 }}
color={row.defaultDays == null ? "warning.main" : "text.primary"}
>
{daysLabel(row.defaultDays)}
</Typography>
</TableCell>
<TableCell align="center">
<Typography
variant="h6"
sx={{ fontWeight: 700, lineHeight: 1.2 }}
color={row.minus18Days == null ? "warning.main" : "text.primary"}
>
{daysLabel(row.minus18Days)}
</Typography>
</TableCell>
<TableCell align="center">
<Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}>
<span>
<Switch
size="small"
checked={row.useMinus18 === true}
disabled={!canUseMinus18}
onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)}
inputProps={{ "aria-label": `${row.itemCode} 用 -18` }}
/>
</span>
</Tooltip>
</TableCell>
<TableCell align="center">
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.2 }}
color={row.effectiveDays == null ? "warning.main" : "primary.main"}
>
{daysLabel(row.effectiveDays)}
</Typography>
{!hasShelf && (
<Typography variant="caption" color="warning.main" display="block">
未設定
</Typography>
)}
</TableCell>
<TableCell align="right">
<Button
size="small"
color="error"
onClick={() => void handleDeleteExpiryCode(row.itemCode)}
>
移除
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}>
檸檬機到期日 ZIP 品號稍後加入。
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setTemplatesOpen(false)}>關閉</Button>
</DialogActions>
</Dialog>

<Snackbar <Snackbar
open={snackbar.open} open={snackbar.open}
autoHideDuration={3000}
autoHideDuration={snackbar.duration ?? 3000}
onClose={() => setSnackbar((s) => ({ ...s, open: false }))} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
message={snackbar.message}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }} anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
>
<Alert
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
severity={snackbar.severity ?? "info"}
variant="filled"
sx={{ width: "100%", maxWidth: 720 }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Box> </Box>
); );
}; };


+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Wyświetl plik

@@ -24,6 +24,7 @@ const pathToLabelKey: { [path: string]: string } = {
"/settings/user": "nav.settings.user", "/settings/user": "nav.settings.user",
"/settings/clientMonitor": "nav.settings.clientMonitor", "/settings/clientMonitor": "nav.settings.clientMonitor",
"/settings/items": "nav.settings.items", "/settings/items": "nav.settings.items",
"/settings/itemDefaultShelfLife": "nav.settings.itemDefaultShelfLife",
"/settings/warehouse": "nav.settings.warehouse", "/settings/warehouse": "nav.settings.warehouse",
"/settings/qcCategory": "nav.settings.qcCategory", "/settings/qcCategory": "nav.settings.qcCategory",
"/settings/bomWeighting": "nav.settings.bomWeighting", "/settings/bomWeighting": "nav.settings.bomWeighting",


+ 31
- 4
src/components/CreateUser/CreateUser.tsx Wyświetl plik

@@ -29,7 +29,7 @@ import {
useForm, useForm,
useFormContext, useFormContext,
} from "react-hook-form"; } from "react-hook-form";
import { Check, Close, Error, RestartAlt } from "@mui/icons-material";
import { Check, Close, Error as ErrorIcon, RestartAlt } from "@mui/icons-material";
import { import {
UserInputs, UserInputs,
adminChangePassword, adminChangePassword,
@@ -46,8 +46,9 @@ interface Props {
auths: auth[]; auths: auth[];
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */
const CreateUser: React.FC<Props> = ({ rules, auths }) => { const CreateUser: React.FC<Props> = ({ rules, auths }) => {
console.log(auths);
// console.log(auths);
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const formProps = useForm<UserInputs>(); const formProps = useForm<UserInputs>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -172,7 +173,33 @@ const CreateUser: React.FC<Props> = ({ rules, auths }) => {
router.replace("/settings/user"); router.replace("/settings/user");
} catch (e) { } catch (e) {
console.log(e); console.log(e);
setServerError(t("An error has occurred. Please try again later."));
const msg = e instanceof Error ? e.message : String(e);

if (msg.includes("USERNAME_NOT_AVAILABLE")) {
const text = t("Username is already taken");
setServerError(text);
formProps.setError("username", { message: text, type: "validate" });
} else if (msg.includes("NAME_NOT_AVAILABLE")) {
const text = t("Name is already taken");
setServerError(text);
formProps.setError("name", { message: text, type: "validate" });
} else if (msg.includes("STAFF_NO_NOT_AVAILABLE")) {
const text = t("Staff No is already taken");
setServerError(text);
formProps.setError("staffNo", { message: text, type: "validate" });
} else if (msg.includes("USER_WRONG_NEW_PWD")) {
setServerError(t("New password does not meet the rules"));
} else if (/\b400\b/.test(msg)) {
setServerError(t("Invalid request. Please check your input"));
} else if (/\b401\b/.test(msg) || /\b403\b/.test(msg)) {
setServerError(t("Unauthorized or no permission"));
} else if (/\b404\b/.test(msg)) {
setServerError(t("User Not Found"));
} else if (/\b500\b/.test(msg)) {
setServerError(t("Server error. Please try again later"));
} else {
setServerError(t("An error has occurred. Please try again later."));
}
} }
}, },
[router], [router],
@@ -212,7 +239,7 @@ const CreateUser: React.FC<Props> = ({ rules, auths }) => {
label={t("User Detail")} label={t("User Detail")}
icon={ icon={
hasErrorsInTab(0, errors) ? ( hasErrorsInTab(0, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />
<ErrorIcon sx={{ marginInlineEnd: 1 }} color="error" />
) : undefined ) : undefined
} }
iconPosition="end" iconPosition="end"


+ 5
- 0
src/components/CreateUser/UserDetail.tsx Wyświetl plik

@@ -35,6 +35,11 @@ const UserDetail: React.FC = () => {
required: "username required!", required: "username required!",
})} })}
error={Boolean(errors.username)} error={Boolean(errors.username)}
helperText={
Boolean(errors.username) && errors.username?.message
? t(errors.username.message)
: ""
}
/> />
</Grid> </Grid>
<Grid item xs={6}> <Grid item xs={6}>


+ 473
- 0
src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx Wyświetl plik

@@ -0,0 +1,473 @@
"use client";

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Add from "@mui/icons-material/Add";
import DeleteOutline from "@mui/icons-material/DeleteOutline";
import EditOutlined from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
Checkbox,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
FormHelperText,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Typography,
} from "@mui/material";
import {
createItemDefaultShelfLife,
deleteItemDefaultShelfLife,
fetchItemDefaultShelfLives,
updateItemDefaultShelfLife,
type ItemDefaultShelfLifeInput,
type ItemDefaultShelfLifeRow,
} from "@/app/api/settings/itemDefaultShelfLife/client";

type FormState = {
itemCode: string;
defaultDays: string;
minus18Days: string;
useMinus18: boolean;
openedDays: string;
storageC: string;
remarks: string;
};

const emptyForm = (): FormState => ({
itemCode: "",
defaultDays: "",
minus18Days: "",
useMinus18: false,
openedDays: "",
storageC: "",
remarks: "",
});

function parseOptionalDays(raw: string): number | null | "invalid" {
const t = raw.trim();
if (!t) return null;
if (!/^\d+$/.test(t)) return "invalid";
return Number(t);
}

function daysFromForm(form: FormState): { defaultDays: number | null; minus18Days: number | null } | "invalid" {
const defaultDays = parseOptionalDays(form.defaultDays);
const minus18Days = parseOptionalDays(form.minus18Days);
if (defaultDays === "invalid" || minus18Days === "invalid") return "invalid";
return { defaultDays, minus18Days };
}

function effectiveDays(form: FormState): number | null {
const parsed = daysFromForm(form);
if (parsed === "invalid") return null;
const chosen = form.useMinus18 ? parsed.minus18Days : parsed.defaultDays;
return chosen != null && chosen > 0 ? chosen : null;
}

function expiryPreview(days: number | null): string | null {
if (days == null) return null;
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + days);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}

function toForm(row: ItemDefaultShelfLifeRow): FormState {
return {
itemCode: row.itemCode ?? "",
defaultDays: row.defaultDays != null ? String(row.defaultDays) : "",
minus18Days: row.minus18Days != null ? String(row.minus18Days) : "",
useMinus18: row.useMinus18 === true,
openedDays: row.openedDays != null ? String(row.openedDays) : "",
storageC: row.storageC ?? "",
remarks: row.remarks ?? "",
};
}

const ItemDefaultShelfLifeSettings: React.FC = () => {
const { t } = useTranslation("itemDefaultShelfLife");
const saveInFlightRef = useRef(false);
const deleteInFlightRef = useRef(false);

const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [rows, setRows] = useState<ItemDefaultShelfLifeRow[]>([]);
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);

const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<ItemDefaultShelfLifeRow | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);

const [deleteTarget, setDeleteTarget] = useState<ItemDefaultShelfLifeRow | null>(null);
const [deleting, setDeleting] = useState(false);

const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setRows(await fetchItemDefaultShelfLives());
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void load();
}, [load]);

const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return rows;
return rows.filter((r) =>
[r.itemCode, r.itemName, r.remarks].some((v) => v?.toLowerCase().includes(needle)),
);
}, [query, rows]);

useEffect(() => {
setPage(0);
}, [query]);

const paged = useMemo(() => {
const start = page * rowsPerPage;
return filtered.slice(start, start + rowsPerPage);
}, [filtered, page, rowsPerPage]);

const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setFormError(null);
setDialogOpen(true);
};

const openEdit = (row: ItemDefaultShelfLifeRow) => {
setEditing(row);
setForm(toForm(row));
setFormError(null);
setDialogOpen(true);
};

const closeDialog = () => {
if (saving) return;
setDialogOpen(false);
};

const onSave = async () => {
if (saveInFlightRef.current) return;
const code = form.itemCode.trim();
if (!code) {
setFormError(t("Item code required"));
return;
}
const parsed = daysFromForm(form);
const openedDays = parseOptionalDays(form.openedDays);
if (parsed === "invalid" || openedDays === "invalid") {
setFormError(t("Days invalid"));
return;
}
const payload: ItemDefaultShelfLifeInput = {
itemCode: code,
defaultDays: parsed.defaultDays,
minus18Days: parsed.minus18Days,
useMinus18: form.useMinus18,
openedDays,
storageC: form.storageC.trim() || null,
remarks: form.remarks.trim() || null,
};
saveInFlightRef.current = true;
setSaving(true);
setFormError(null);
setError(null);
setSuccess(null);
try {
if (editing) {
const updated = await updateItemDefaultShelfLife(editing.id, payload);
setRows((prev) =>
prev
.map((r) => (r.id === updated.id ? updated : r))
.sort((a, b) => a.itemCode.localeCompare(b.itemCode)),
);
} else {
const created = await createItemDefaultShelfLife(payload);
setRows((prev) =>
[...prev.filter((r) => r.id !== created.id), created].sort((a, b) =>
a.itemCode.localeCompare(b.itemCode),
),
);
}
setSuccess(t("Saved"));
setDialogOpen(false);
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
saveInFlightRef.current = false;
}
};

const onDelete = async () => {
if (!deleteTarget || deleteInFlightRef.current) return;
deleteInFlightRef.current = true;
setDeleting(true);
setError(null);
setSuccess(null);
try {
const next = await deleteItemDefaultShelfLife(deleteTarget.id);
setRows(next);
setSuccess(t("Deleted"));
setDeleteTarget(null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDeleting(false);
deleteInFlightRef.current = false;
}
};

const previewDays = effectiveDays(form);
const previewDate = expiryPreview(previewDays);
const from = filtered.length === 0 ? 0 : page * rowsPerPage + 1;
const to = Math.min(filtered.length, (page + 1) * rowsPerPage);

return (
<Stack spacing={2}>
<Typography variant="body2" color="text.secondary">
{t("Intro")}
</Typography>
{error && <Alert severity="error">{error}</Alert>}
{success && (
<Alert severity="success" onClose={() => setSuccess(null)}>
{success}
</Alert>
)}
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
<TextField
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("Search placeholder")}
sx={{ minWidth: 260, flex: 1 }}
/>
<Button variant="contained" startIcon={<Add />} onClick={openCreate}>
{t("Add")}
</Button>
</Stack>
{loading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : (
<>
<TableContainer sx={{ maxHeight: 640 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>{t("Col itemCode")}</TableCell>
<TableCell>{t("Col itemName")}</TableCell>
<TableCell align="right">{t("Col defaultDays")}</TableCell>
<TableCell align="right">{t("Col minus18Days")}</TableCell>
<TableCell>{t("Col useMinus18")}</TableCell>
<TableCell align="right">{t("Col effectiveDays")}</TableCell>
<TableCell align="right">{t("Col openedDays")}</TableCell>
<TableCell>{t("Col storageC")}</TableCell>
<TableCell>{t("Col remarks")}</TableCell>
<TableCell align="right">{t("Col actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paged.length === 0 ? (
<TableRow>
<TableCell colSpan={10}>
<Typography color="text.secondary">
{rows.length === 0 ? t("Empty") : t("No match")}
</Typography>
</TableCell>
</TableRow>
) : (
paged.map((row) => (
<TableRow key={row.id} hover>
<TableCell>{row.itemCode}</TableCell>
<TableCell>{row.itemName || "—"}</TableCell>
<TableCell align="right">{row.defaultDays ?? "—"}</TableCell>
<TableCell align="right">{row.minus18Days ?? "—"}</TableCell>
<TableCell>
<Chip
size="small"
label={row.useMinus18 ? t("Yes") : t("No")}
color={row.useMinus18 ? "warning" : "default"}
variant={row.useMinus18 ? "filled" : "outlined"}
/>
</TableCell>
<TableCell align="right">{row.effectiveDays ?? "—"}</TableCell>
<TableCell align="right">{row.openedDays ?? "—"}</TableCell>
<TableCell>{row.storageC || "—"}</TableCell>
<TableCell>{row.remarks || "—"}</TableCell>
<TableCell align="right">
<IconButton size="small" aria-label={t("Edit")} onClick={() => openEdit(row)}>
<EditOutlined fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label={t("Delete")}
onClick={() => setDeleteTarget(row)}
>
<DeleteOutline fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap">
<Typography variant="body2" color="text.secondary">
{t("Showing", { from, to, total: filtered.length })}
</Typography>
<TablePagination
component="div"
count={filtered.length}
page={page}
onPageChange={(_, next) => setPage(next)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(parseInt(e.target.value, 10));
setPage(0);
}}
rowsPerPageOptions={[25, 50, 100]}
/>
</Stack>
</>
)}

<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{editing ? t("Edit title") : t("Add title")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{formError && <Alert severity="error">{formError}</Alert>}
<TextField
required
label={t("Col itemCode")}
value={form.itemCode}
onChange={(e) => setForm((s) => ({ ...s, itemCode: e.target.value }))}
disabled={saving}
autoFocus={!editing}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label={t("Col defaultDays")}
value={form.defaultDays}
onChange={(e) => setForm((s) => ({ ...s, defaultDays: e.target.value }))}
disabled={saving}
fullWidth
/>
<TextField
label={t("Col minus18Days")}
value={form.minus18Days}
onChange={(e) => setForm((s) => ({ ...s, minus18Days: e.target.value }))}
disabled={saving}
fullWidth
/>
</Stack>
<Box>
<FormControlLabel
control={
<Checkbox
checked={form.useMinus18}
onChange={(e) => setForm((s) => ({ ...s, useMinus18: e.target.checked }))}
disabled={saving}
/>
}
label={t("Use minus18")}
/>
<FormHelperText>{t("Use minus18 help")}</FormHelperText>
</Box>
<Typography variant="body2" color={previewDate ? "text.secondary" : "warning.main"}>
{previewDate
? t("Expiry preview", { date: previewDate })
: t("Expiry preview none")}
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label={t("Col openedDays")}
value={form.openedDays}
onChange={(e) => setForm((s) => ({ ...s, openedDays: e.target.value }))}
disabled={saving}
fullWidth
/>
<TextField
label={t("Col storageC")}
value={form.storageC}
onChange={(e) => setForm((s) => ({ ...s, storageC: e.target.value }))}
disabled={saving}
inputProps={{ maxLength: 20 }}
fullWidth
/>
</Stack>
<TextField
label={t("Col remarks")}
value={form.remarks}
onChange={(e) => setForm((s) => ({ ...s, remarks: e.target.value }))}
disabled={saving}
inputProps={{ maxLength: 255 }}
multiline
minRows={2}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog} disabled={saving}>
{t("Cancel")}
</Button>
<Button variant="contained" onClick={onSave} disabled={saving}>
{saving ? t("Saving") : t("Save")}
</Button>
</DialogActions>
</Dialog>

<Dialog open={!!deleteTarget} onClose={() => !deleting && setDeleteTarget(null)}>
<DialogTitle>{t("Delete title")}</DialogTitle>
<DialogContent>
<Typography>
{t("Delete confirm", { itemCode: deleteTarget?.itemCode ?? "" })}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleting}>
{t("Cancel")}
</Button>
<Button color="error" variant="contained" onClick={onDelete} disabled={deleting}>
{t("Delete")}
</Button>
</DialogActions>
</Dialog>
</Stack>
);
};

export default ItemDefaultShelfLifeSettings;

+ 5
- 1
src/components/ItemTracing/buildJoPreludeGraphNodes.ts Wyświetl plik

@@ -1,4 +1,8 @@
import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
import {
ItemLotTraceJoPickLine,
ItemLotTraceJoPrelude,
ItemLotTraceMaterialInput,
} from "@/app/api/itemTracing";
import { import {
TraceGraphDetailField, TraceGraphDetailField,
TraceGraphDetailLabels, TraceGraphDetailLabels,


+ 16
- 4
src/components/ItemTracing/buildTraceGraphNodes.ts Wyświetl plik

@@ -168,6 +168,11 @@ export interface TraceGraphNode {
/** Raw status codes (for coloring). */ /** Raw status codes (for coloring). */
processingStatus?: string; processingStatus?: string;
matchStatus?: string; matchStatus?: string;
/** Material-pick stock summary chips (JO pick cards). */
stockAvailableLabel?: string;
stockStatusLabel?: string;
bomReqQtyLabel?: string;
stockReqQtyLabel?: string;
} }


export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { export interface TraceGraphDetailLabels extends TraceGraphNodeLabels {
@@ -255,6 +260,16 @@ export interface TraceGraphDetailLabels extends TraceGraphNodeLabels {
categoryTerminal: string; categoryTerminal: string;
processingStatus: string; processingStatus: string;
matchStatus: string; matchStatus: string;
detailBomReqQty: string;
detailStockReqQty: string;
detailStockAvailable: string;
detailStockStatus: string;
stockStatusSufficient: string;
stockStatusInsufficient: string;
/** Displayed when a JO pick-table field does not apply. */
na: string;
/** Unpicked pick-order line (no stock-out yet). */
pendingPick: string;
} }


const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => { const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => {
@@ -873,10 +888,7 @@ export const buildTraceGraphNodes = (
fieldIf(labels.detailRemarks, m.remarks), fieldIf(labels.detailRemarks, m.remarks),
) )
: detailsOf( : detailsOf(
field(
labels.detailType,
kind === "JO_CREATED" ? labels.nodeJoCreated : labels.tr.movementType(m.movementType),
),
field(labels.detailType, labels.tr.movementType(m.movementType)),
field(labels.detailDirection, labels.tr.direction(m.direction)), field(labels.detailDirection, labels.tr.direction(m.direction)),
field(labels.detailSourceDoc, m.refCode, { field(labels.detailSourceDoc, m.refCode, {
linkKind, linkKind,


+ 2
- 2
src/components/JoSearch/JoCreateFormModal.tsx Wyświetl plik

@@ -60,7 +60,7 @@ const JoCreateFormModal: React.FC<Props> = ({
/* /*
const handleAutoCompleteChange = useCallback( const handleAutoCompleteChange = useCallback(
(event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => {
console.log("BOM changed to:", value);
// console.log("BOM changed to:", value);
onChange(value.id); onChange(value.id);
// 重置倍数为 1 // 重置倍数为 1
@@ -101,7 +101,7 @@ const JoCreateFormModal: React.FC<Props> = ({
}, [bomCombo]); }, [bomCombo]);
const handleAutoCompleteChange = useCallback( const handleAutoCompleteChange = useCallback(
(event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => {
console.log("BOM changed to:", value);
// console.log("BOM changed to:", value);
onChange(value.id); onChange(value.id);


if (value.outputQty != null) { if (value.outputQty != null) {


+ 4
- 4
src/components/JoSearch/JoSearch.tsx Wyświetl plik

@@ -272,13 +272,13 @@ const JoSearch: React.FC<Props> = ({
pageSize: pagingController.pageSize, pageSize: pagingController.pageSize,
}; };
const response = await fetchJos(params); const response = await fetchJos(params);
console.log("newPageFetch params:", params)
console.log("newPageFetch response:", response)
// console.log("newPageFetch params:", params)
// console.log("newPageFetch response:", response)
if (response && response.records) { if (response && response.records) {
console.log("newPageFetch - setting filteredJos with", response.records.length, "records");
// console.log("newPageFetch - setting filteredJos with", response.records.length, "records");
setTotalCount(response.total); setTotalCount(response.total);
setFilteredJos(response.records); setFilteredJos(response.records);
console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id);
// console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id);
} else { } else {
console.warn("newPageFetch - no response or no records"); console.warn("newPageFetch - no response or no records");
setFilteredJos([]); setFilteredJos([]);


+ 4
- 4
src/components/JoWorkbench/JoWorkbenchSearch.tsx Wyświetl plik

@@ -273,13 +273,13 @@ const JoWorkbenchSearch: React.FC<Props> = ({
pageSize: pagingController.pageSize, pageSize: pagingController.pageSize,
}; };
const response = await fetchJosForWorkbench(params); const response = await fetchJosForWorkbench(params);
console.log("newPageFetch params:", params)
console.log("newPageFetch response:", response)
// console.log("newPageFetch params:", params)
// console.log("newPageFetch response:", response)
if (response && response.records) { if (response && response.records) {
console.log("newPageFetch - setting filteredJos with", response.records.length, "records");
// console.log("newPageFetch - setting filteredJos with", response.records.length, "records");
setTotalCount(response.total); setTotalCount(response.total);
setFilteredJos(response.records); setFilteredJos(response.records);
console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id);
// console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id);
} else { } else {
console.warn("newPageFetch - no response or no records"); console.warn("newPageFetch - no response or no records");
setFilteredJos([]); setFilteredJos([]);


+ 36
- 26
src/components/JoWorkbench/newJobPickExecution.tsx Wyświetl plik

@@ -618,7 +618,7 @@ const QrCodeModal: React.FC<{
); );
}; };


/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.1 | 2026-08-10 */
const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => {
const workbenchMode = true; const workbenchMode = true;
const { t } = useTranslation("jo"); const { t } = useTranslation("jo");
@@ -896,10 +896,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
data.pickOrderLines.forEach((line) => { data.pickOrderLines.forEach((line) => {
// 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次) // 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次)
const lotIdSet = new Set<number>(); const lotIdSet = new Set<number>();
/** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數),對齊 DO Workbench */
let lotsAllocatedSumForLine = 0;


// lots:按 lotId 去重并合并 requiredQty(对齐 GoodPickExecutiondetail)
// lots:按 lotId 去重并合并 requiredQty(对齐 DO Workbench / GoodPickExecutiondetail)
if (line.lots && line.lots.length > 0) { if (line.lots && line.lots.length > 0) {
const lotMap = new Map<number, any>(); const lotMap = new Map<number, any>();


@@ -916,7 +914,6 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
}); });


lotMap.forEach((lot: any) => { lotMap.forEach((lot: any) => {
lotsAllocatedSumForLine += Number(lot.requiredQty) || 0;
if (lot.lotId != null) lotIdSet.add(lot.lotId); if (lot.lotId != null) lotIdSet.add(lot.lotId);


allLots.push({ allLots.push({
@@ -945,20 +942,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
}); });
} }


/** 工單 API 常在有揀貨後仍回傳 lots: [],缺口只在 stockouts;此時用非 noLot 的已揀量扣 POL(對齊實際剩餘) */
const stockoutsPickedSumNonNoLot = (line.stockouts ?? []).reduce(
(acc: number, s: any) => {
if (!s || s.noLot) return acc;
return acc + (Number(s.qty) || 0);
},
0,
);
const noLotRemainingBasis =
lotsAllocatedSumForLine > 0
? lotsAllocatedSumForLine
: stockoutsPickedSumNonNoLot;

// stockouts:用于“无 suggested lot / noLot”场景也显示并可 submit 0 闭环 // stockouts:用于“无 suggested lot / noLot”场景也显示并可 submit 0 闭环
// 批號需求數:對齊 DO Workbench——用後端 stockout/SPL qty,不前端推 gap
if (line.stockouts && line.stockouts.length > 0) { if (line.stockouts && line.stockouts.length > 0) {
line.stockouts.forEach((stockout: any) => { line.stockouts.forEach((stockout: any) => {
const hasLot = stockout.lotId != null; const hasLot = stockout.lotId != null;
@@ -970,6 +955,17 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
return; return;
} }


const stockoutRequiredQty = Number(
stockout?.requiredQty ??
stockout?.suggestedPickQty ??
stockout?.suggestedPickLotQty,
);
const effectiveStockoutRequiredQty = Number.isFinite(
stockoutRequiredQty,
)
? stockoutRequiredQty
: Number(line.requiredQty) || 0;

allLots.push({ allLots.push({
pickOrderLineId: line.id, pickOrderLineId: line.id,
itemId: line.itemId, itemId: line.itemId,
@@ -996,19 +992,13 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
expiryDate: null, expiryDate: null,
location: stockout.location || null, location: stockout.location || null,
availableQty: stockout.availableQty ?? 0, availableQty: stockout.availableQty ?? 0,
// 無批次列:有 SPL 時扣 suggested 合計;僅有 stockouts(lots 空)時扣已揀量(對齊 DO + workbench 僅 SOL 情境)
requiredQty: stockout.noLot
? Math.max(
0,
(Number(line.requiredQty) || 0) - noLotRemainingBasis,
)
: Number(line.requiredQty) || 0,
requiredQty: effectiveStockoutRequiredQty,
actualPickQty: stockout.qty ?? 0, actualPickQty: stockout.qty ?? 0,
processingStatus: stockout.status || "pending", processingStatus: stockout.status || "pending",
lotAvailability: stockout.noLot lotAvailability: stockout.noLot
? "insufficient_stock" ? "insufficient_stock"
: "available", : "available",
suggestedPickLotId: null,
suggestedPickLotId: stockout.suggestedPickLotId ?? null,
stockOutLineId: stockout.id || null, stockOutLineId: stockout.id || null,
stockOutLineQty: stockout.qty ?? 0, stockOutLineQty: stockout.qty ?? 0,
stockOutLineStatus: stockout.status || null, stockOutLineStatus: stockout.status || null,
@@ -3655,6 +3645,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
const isNoLotTailRow = (lot: any) => const isNoLotTailRow = (lot: any) =>
lot.noLot === true || lot.lotId == null || lot.lotId === undefined; lot.noLot === true || lot.lotId == null || lot.lotId === undefined;


/** 同 POL 內對齊 DO Workbench:已掃/已完成在前,resuggest pending 在後 */
const statusRank = (lot: any) => {
const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
if (
st === "completed" ||
st === "partially_completed" ||
st === "partially_complete"
) {
return 0;
}
if (st === "checked") return 1;
if (st === "pending") return 2;
if (st === "rejected") return 3;
return 9;
};

const sortedData = [...sourceData].sort((a, b) => { const sortedData = [...sourceData].sort((a, b) => {
const efA = effectiveFloorOrder(a); const efA = effectiveFloorOrder(a);
const efB = effectiveFloorOrder(b); const efB = effectiveFloorOrder(b);
@@ -3674,6 +3680,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
const bName = String(b.itemName || ""); const bName = String(b.itemName || "");
if (aName !== bName) return aName.localeCompare(bName); if (aName !== bName) return aName.localeCompare(bName);


const ra = statusRank(a);
const rb = statusRank(b);
if (ra !== rb) return ra - rb;

const tailA = isNoLotTailRow(a) ? 1 : 0; const tailA = isNoLotTailRow(a) ? 1 : 0;
const tailB = isNoLotTailRow(b) ? 1 : 0; const tailB = isNoLotTailRow(b) ? 1 : 0;
if (tailA !== tailB) return tailA - tailB; if (tailA !== tailB) return tailA - tailB;


+ 28
- 9
src/components/LaserPrint/LaserPrintSearch.tsx Wyświetl plik

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


import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { import {
Alert, Alert,
Box, Box,
@@ -26,6 +26,7 @@ import {
type LaserLastReceiveSuccess, type LaserLastReceiveSuccess,
JobOrderListItem, JobOrderListItem,
patchSetting, patchSetting,
expiryDateForLaserSend,
sendLaserBag2Job, sendLaserBag2Job,
} from "@/app/api/laserPrint/actions"; } from "@/app/api/laserPrint/actions";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -89,6 +90,7 @@ const LaserPrintSearch: React.FC = () => {
const [settingsLoaded, setSettingsLoaded] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false);
const [printerConnected, setPrinterConnected] = useState(false); const [printerConnected, setPrinterConnected] = useState(false);
const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接");
const sendInFlightRef = useRef(false);


const loadSystemSettings = useCallback(async () => { const loadSystemSettings = useCallback(async () => {
try { try {
@@ -191,31 +193,38 @@ const LaserPrintSearch: React.FC = () => {
jobOrderId: jo.id, jobOrderId: jo.id,
jobOrderNo: jo.code, jobOrderNo: jo.code,
lotNo: jo.lotNo, lotNo: jo.lotNo,
expiryDate: expiryDateForLaserSend(jo.expiryDate),
source: "MANUAL", source: "MANUAL",
}); });


const handleRowClick = async (jo: JobOrderListItem) => { const handleRowClick = async (jo: JobOrderListItem) => {
if (sendingJobId !== null) return;
if (sendInFlightRef.current || sendingJobId !== null) return;


if (!laserHost.trim()) { if (!laserHost.trim()) {
setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" }); setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" });
return; return;
} }


sendInFlightRef.current = true;
setSelectedId(jo.id); setSelectedId(jo.id);
setSendingJobId(jo.id); setSendingJobId(jo.id);
try { try {
let lastAck: string | undefined; let lastAck: string | undefined;
let anyReceiveAck = false; let anyReceiveAck = false;
let sentOk = 0;
let laterFail: string | null = null;
for (let i = 0; i < LASER_SEND_COUNT; i++) { for (let i = 0; i < LASER_SEND_COUNT; i++) {
const r = await sendOne(jo); const r = await sendOne(jo);
if (!r.success) { if (!r.success) {
setErrorSnackbar({
open: true,
message: r.message || "檸檬機(激光機)未收到指令",
});
return;
const failMsg = r.message?.trim() || `第 ${i + 1} 次送出失敗`;
if (sentOk === 0) {
setErrorSnackbar({ open: true, message: failMsg });
return;
}
laterFail = failMsg;
break;
} }
sentOk += 1;
if (r.printerAck) lastAck = r.printerAck; if (r.printerAck) lastAck = r.printerAck;
if (r.receiveAcknowledged) anyReceiveAck = true; if (r.receiveAcknowledged) anyReceiveAck = true;
if (i < LASER_SEND_COUNT - 1) { if (i < LASER_SEND_COUNT - 1) {
@@ -228,7 +237,11 @@ const LaserPrintSearch: React.FC = () => {
: lastAck : lastAck
? `(最後回覆:${lastAck})` ? `(最後回覆:${lastAck})`
: ""; : "";
setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)${ackHint}`);
setSuccessSignal(
laterFail
? `已送出 ${sentOk} 次至檸檬機(激光機)${ackHint}(後續重送失敗:${laterFail})`
: `已送出 ${sentOk} 次至檸檬機(激光機)${ackHint}`,
);
await loadSystemSettings(); await loadSystemSettings();
} catch (e) { } catch (e) {
setErrorSnackbar({ setErrorSnackbar({
@@ -237,6 +250,7 @@ const LaserPrintSearch: React.FC = () => {
}); });
} finally { } finally {
setSendingJobId(null); setSendingJobId(null);
sendInFlightRef.current = false;
} }
}; };


@@ -268,7 +282,12 @@ const LaserPrintSearch: React.FC = () => {
{settingsLoaded && lastLaserReceive && ( {settingsLoaded && lastLaserReceive && (
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} {formatHongKongDateTime(lastLaserReceive.sentAt)}
上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} 
{formatHongKongDateTime(lastLaserReceive.sentAt)}
{lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
此時間只會在檸檬機回覆 receive 時更新。之後送出失敗不會改這裡。
</Typography> </Typography>
</Alert> </Alert>
)} )}


+ 9
- 1
src/components/NavigationContent/NavigationContent.tsx Wyświetl plik

@@ -40,6 +40,7 @@ import UploadFile from "@mui/icons-material/UploadFile";
import Sync from "@mui/icons-material/Sync"; import Sync from "@mui/icons-material/Sync";
import Layers from "@mui/icons-material/Layers"; import Layers from "@mui/icons-material/Layers";
import Devices from "@mui/icons-material/Devices"; import Devices from "@mui/icons-material/Devices";
import EventAvailable from "@mui/icons-material/EventAvailable";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@@ -61,6 +62,7 @@ interface NavigationItem {
requiredAbility?: string | string[]; requiredAbility?: string | string[];
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */
const NavigationContent: React.FC = () => { const NavigationContent: React.FC = () => {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const abilities = session?.user?.abilities ?? []; const abilities = session?.user?.abilities ?? [];
@@ -241,7 +243,7 @@ const NavigationContent: React.FC = () => {
icon: <Sync />, icon: <Sync />,
labelKey: "nav.m18Sync", labelKey: "nav.m18Sync",
path: "/m18Syn", path: "/m18Syn",
requiredAbility: [AUTH.ADMIN],
requiredAbility: [AUTH.M18_SYNC, AUTH.ADMIN],
isHidden: false, isHidden: false,
}, },
{ {
@@ -324,6 +326,12 @@ const NavigationContent: React.FC = () => {
labelKey: "nav.settings.items", labelKey: "nav.settings.items",
path: "/settings/items", path: "/settings/items",
}, },
{
id: "nav.settings.itemDefaultShelfLife",
icon: <EventAvailable />,
labelKey: "nav.settings.itemDefaultShelfLife",
path: "/settings/itemDefaultShelfLife",
},
{ {
id: "nav.settings.equipment", id: "nav.settings.equipment",
icon: <Build />, icon: <Build />,


+ 40
- 3
src/components/PickOrderSearch/WorkbenchPickExecution.tsx Wyświetl plik

@@ -228,6 +228,16 @@ const isCheckedStatus = (status: string | undefined): boolean =>
const isRejectedStatus = (status: string | undefined): boolean => const isRejectedStatus = (status: string | undefined): boolean =>
String(status || "").toLowerCase() === "rejected"; String(status || "").toLowerCase() === "rejected";


const isPendingSolStatus = (status: string | undefined): boolean => {
const s = String(status || "").toLowerCase();
return (
s === "pending" ||
s === "partially_completed" ||
s === "partially_complete" ||
s === ""
);
};

function safeDisplayTargetDate(targetDate: string | number[]): string { function safeDisplayTargetDate(targetDate: string | number[]): string {
try { try {
if (Array.isArray(targetDate) && targetDate.length >= 3) { if (Array.isArray(targetDate) && targetDate.length >= 3) {
@@ -346,7 +356,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] {
}); });
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.3 | 2026-08-13 */
const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder"); const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null }; const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -1221,8 +1231,35 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
releaseProcessedQr(latest); releaseProcessedQr(latest);
return; return;
} }
const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom;
let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0];
// Align DO/JO: when no active suggested lot, bind to pending noLot / unavailable
// rows instead of a completed row that still has lotNo.
let expectedRow: LotRow | undefined;
if (activeForUom.length > 0) {
expectedRow = pickExpectedRowForSubstitution(activeForUom) || allForUom[0];
} else {
const switchable = allForUom.find(
(r) =>
r.stockOutLineId > 0 &&
isPendingSolStatus(r.status) &&
!isCompletedStatus(r.status) &&
!isCheckedStatus(r.status) &&
(isNoLotWorkbenchRow(r) ||
isRejectedStatus(r.status) ||
isInventoryLotLineUnavailable(r) ||
isLotAvailabilityExpired(r)),
);
expectedRow =
switchable ||
allForUom.find(
(r) =>
r.stockOutLineId > 0 &&
isPendingSolStatus(r.status) &&
!isCompletedStatus(r.status) &&
!isCheckedStatus(r.status),
) ||
pickExpectedRowForSubstitution(allForUom) ||
allForUom[0];
}
if (!expectedRow) { if (!expectedRow) {
setError(t("Scanned item is not found in current line")); setError(t("Scanned item is not found in current line"));
startTransition(() => { startTransition(() => {


+ 59
- 34
src/components/PoDetail/PoDetail.tsx Wyświetl plik

@@ -60,6 +60,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
@@ -252,7 +253,7 @@ interface PolInputResult {
dnQty: string, dnQty: string,
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const cameras = useContext(CameraContext); const cameras = useContext(CameraContext);
const { data: session } = useSession(); const { data: session } = useSession();
@@ -302,16 +303,43 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const [selectedRow, setSelectedRow] = useState<PurchaseOrderLine | null>(null); const [selectedRow, setSelectedRow] = useState<PurchaseOrderLine | null>(null);
const [stockInLine, setStockInLine] = useState<StockInLine[]>([]); const [stockInLine, setStockInLine] = useState<StockInLine[]>([]);
const [processedQty, setProcessedQty] = useState(0); const [processedQty, setProcessedQty] = useState(0);
/** Tracks user/nav selection so query patches via history.replaceState stay authoritative. */
const selectedPolIdRef = useRef<number | null>(null);

/** Patch PO edit query without Next soft-navigation (avoids scroll-to-top). */
const patchPoEditQuery = useCallback(
(mutate: (params: URLSearchParams) => void) => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
mutate(params);
const qs = params.toString();
window.history.replaceState(
window.history.state,
"",
qs ? `${pathname}?${qs}` : pathname,
);
},
[pathname],
);


/** Keep selection + bottom stock-in grid in sync with selected / URL `polId`. */
useEffect(() => { useEffect(() => {
const polIdParam = searchParams.get("polId");
if (!polIdParam || rows.length === 0) return;
const match = rows.find((r) => r.id.toString() === polIdParam);
if (match) {
setSelectedRow(match);
setStockInLine(match.stockInLine);
setProcessedQty(match.processed);
}
if (rows.length === 0) return;
const urlPolId = searchParams.get("polId");
const preferredId =
selectedPolIdRef.current ??
(urlPolId != null ? Number(urlPolId) : null);
if (preferredId == null || Number.isNaN(preferredId)) return;
const match =
rows.find((r) => r.id === preferredId) ??
(urlPolId != null
? rows.find((r) => r.id.toString() === urlPolId)
: undefined);
if (!match) return;
selectedPolIdRef.current = match.id;
setSelectedRow(match);
setStockInLine(match.stockInLine ?? []);
setProcessedQty(match.processed);
}, [rows, searchParams]); }, [rows, searchParams]);


const router = useRouter(); const router = useRouter();
@@ -465,9 +493,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
}); });
setRows(result.pol || []); setRows(result.pol || []);
if (result.pol && result.pol.length > 0) { if (result.pol && result.pol.length > 0) {
const targetPolId = preferredPolId ?? selectedRow?.id;
const targetPolId = preferredPolId ?? selectedPolIdRef.current ?? selectedRow?.id;
const targetPol = const targetPol =
result.pol.find((p) => p.id === targetPolId) ?? result.pol[0]; result.pol.find((p) => p.id === targetPolId) ?? result.pol[0];
selectedPolIdRef.current = targetPol.id;
setSelectedRow(targetPol); setSelectedRow(targetPol);
setStockInLine(targetPol.stockInLine); setStockInLine(targetPol.stockInLine);
setProcessedQty(targetPol.processed); setProcessedQty(targetPol.processed);
@@ -482,6 +511,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const handlePoSelect = useCallback( const handlePoSelect = useCallback(
async (selectedPo: PoResult) => { async (selectedPo: PoResult) => {
if (selectedPo.id === selectedPoId) return; if (selectedPo.id === selectedPoId) return;
selectedPolIdRef.current = null;
setSelectedPoId(selectedPo.id); setSelectedPoId(selectedPo.id);
await fetchPoDetail(selectedPo.id.toString()); await fetchPoDetail(selectedPo.id.toString());
const newSelectedIds = selectedIdsParam || selectedPo.id.toString(); const newSelectedIds = selectedIdsParam || selectedPo.id.toString();
@@ -570,13 +600,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
() => returnWeightUnit(row.uom), () => returnWeightUnit(row.uom),
[row.uom], [row.uom],
); );
useEffect(() => {
const polId = searchParams.get("polId") != null ? parseInt(searchParams.get("polId")!) : null
if (polId) {
setStockInLine(rows.find((r) => r.id == polId)!.stockInLine)
}
}, []);

useEffect(() => { useEffect(() => {
// `processedQty` comes from putAwayLines (stock unit). // `processedQty` comes from putAwayLines (stock unit).
// After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand.
@@ -595,23 +618,22 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); setDnQtyInput(polInputList[row.id]?.dnQty ?? "");
}, [polInputList, row.id]); }, [polInputList, row.id]);


const handleRowSelect = () => {
// setSelectedRowId(row.id);
setSelectedRow(row);
setStockInLine(row.stockInLine);
setProcessedQty(row.processed);
};
const changeStockInLines = useCallback( const changeStockInLines = useCallback(
(id: number) => { (id: number) => {
//rows = purchaseOrderLine
const target = rows.find((r) => r.id === id)
const stockInLine = target!.stockInLine
setStockInLine(stockInLine)
setSelectedRow(target!)
// console.log(pathname)
// router.replace(`/po/edit?id=${item.poId}&polId=${item.polId}&stockInLineId=${item.stockInLineId}`);
const target = rows.find((r) => r.id === id);
if (!target) return;
selectedPolIdRef.current = id;
setSelectedRow(target);
setStockInLine(target.stockInLine ?? []);
setProcessedQty(target.processed);

// history.replaceState: keep URL in sync without scrolling to top
patchPoEditQuery((params) => {
params.set("polId", String(id));
params.delete("stockInLineId");
});
}, },
[rows]
[rows, patchPoEditQuery],
); );


const handleStart = useCallback( const handleStart = useCallback(
@@ -644,7 +666,12 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
...prev, ...prev,
[row.id]: { lotNo: "", dnQty: "" }, [row.id]: { lotNo: "", dnQty: "" },
})); }));
selectedPolIdRef.current = row.id;
setSelectedRow(row); setSelectedRow(row);
patchPoEditQuery((params) => {
params.set("polId", String(row.id));
params.delete("stockInLineId");
});
fetchPoDetail(selectedPoId.toString(), true, row.id); fetchPoDetail(selectedPoId.toString(), true, row.id);
} }
console.log(res); console.log(res);
@@ -662,7 +689,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
doSubmit(); doSubmit();
} }
}, },
[dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput],
[dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput, patchPoEditQuery],
); );


const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => {
@@ -746,8 +773,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
)} )}
<Radio <Radio
checked={selectedRow?.id === row.id} checked={selectedRow?.id === row.id}
// onChange={handleRowSelect}
// onClick={(e) => e.stopPropagation()}
/> />
</TableCell> </TableCell>
<TableCell align="left" sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemNo}> <TableCell align="left" sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemNo}>


+ 76
- 31
src/components/PoDetail/PoInputGrid.tsx Wyświetl plik

@@ -36,7 +36,7 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { PurchaseOrderLine } from "@/app/api/po"; import { PurchaseOrderLine } from "@/app/api/po";
import { StockInLine } from "@/app/api/stockIn"; import { StockInLine } from "@/app/api/stockIn";
import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions"; import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { import {
returnWeightUnit, returnWeightUnit,
calculateWeight, calculateWeight,
@@ -170,6 +170,7 @@ class ProcessRowUpdateError extends Error {
} }
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
function PoInputGrid({ function PoInputGrid({
// qc, // qc,
setRows, setRows,
@@ -204,7 +205,6 @@ function PoInputGrid({
StockInLine & { qcResult?: QcResult[] } & { escalationResult?: EscalationResult[] } StockInLine & { qcResult?: QcResult[] } & { escalationResult?: EscalationResult[] }
>(); >();
const pathname = usePathname() const pathname = usePathname()
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();


const [qcOpen, setQcOpen] = useState(false); const [qcOpen, setQcOpen] = useState(false);
@@ -384,15 +384,35 @@ function PoInputGrid({
// ); // );


const [newOpen, setNewOpen] = useState(false); const [newOpen, setNewOpen] = useState(false);
const stockInLineId = searchParams.get("stockInLineId");
const stockInLineIdFromNext = searchParams.get("stockInLineId");
const poLineId = searchParams.get("poLineId"); const poLineId = searchParams.get("poLineId");


const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => {
const newParams = new URLSearchParams(searchParams.toString());
newParams.delete("stockInLineId");
const patchQuery = useCallback(
(mutate: (params: URLSearchParams) => void) => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
mutate(params);
const qs = params.toString();
window.history.replaceState(
window.history.state,
"",
qs ? `${pathname}?${qs}` : pathname,
);
},
[pathname],
);

const getLiveStockInLineId = useCallback((): string | null => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`);
return new URLSearchParams(window.location.search).get("stockInLineId");
} }
return stockInLineIdFromNext;
}, [stockInLineIdFromNext]);

const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => {
patchQuery((params) => {
params.delete("stockInLineId");
});
setNewOpen(false); setNewOpen(false);


if (updatedStockInLine?.id != null) { if (updatedStockInLine?.id != null) {
@@ -403,7 +423,7 @@ function PoInputGrid({
(prev || []).map((p) => (p.id === updatedStockInLine.id ? { ...p, ...updatedStockInLine } : p)) (prev || []).map((p) => (p.id === updatedStockInLine.id ? { ...p, ...updatedStockInLine } : p))
); );
} }
}, [pathname, searchParams]);
}, [patchQuery, setStockInLine]);


// Open modal // Open modal
const openNewModal = useCallback(() => { const openNewModal = useCallback(() => {
@@ -413,42 +433,67 @@ function PoInputGrid({
// Button handler to update the URL and open the modal // Button handler to update the URL and open the modal
const handleNewQC = useCallback( const handleNewQC = useCallback(
(id: GridRowId, params: any) => async() => { (id: GridRowId, params: any) => async() => {
// setBtnIsLoading(true);
if (!params?.row) return;
setRowModesModel((prev) => ({ setRowModesModel((prev) => ({
...prev, ...prev,
[id]: { mode: GridRowModes.View }, [id]: { mode: GridRowModes.View },
})); }));
// const qcResult = await fetchQcDefaultValue(id);
// const escResult = await fetchEscalationLogsByStockInLines([Number(id)]);

setModalInfo(() => ({ setModalInfo(() => ({
...params.row, ...params.row,
// qcResult: qcResult,
// escResult: escResult,
receivedQty: itemDetail.receivedQty, receivedQty: itemDetail.receivedQty,
})); }));
const newParams = new URLSearchParams(searchParams.toString());
newParams.set("stockInLineId", id.toString()); // Ensure `set` to avoid duplicates
router.replace(`${pathname}?${newParams.toString()}`);
openNewModal()
// setTimeout(() => {
// }, 200);

// Avoid router.replace — it scrolls the page to top
patchQuery((params) => {
params.set("stockInLineId", id.toString());
});
openNewModal();
}, },
[openNewModal, pathname, router, searchParams]
[openNewModal, patchQuery, itemDetail.receivedQty],
); );


// Open modal if `stockInLineId` exists in the URL
const [firstCheckForSil, setFirstCheckForSil] = useState(false)
// Open modal if `stockInLineId` exists in the live URL (and belongs to current grid)
const [firstCheckForSil, setFirstCheckForSil] = useState(false);
useEffect(() => { useEffect(() => {
if (stockInLineId && itemDetail && !firstCheckForSil) {
// console.log(stockInLineId)
// console.log(apiRef.current.getRow(stockInLineId))
setFirstCheckForSil(true)
const fn = handleNewQC(stockInLineId, {row: apiRef.current.getRow(stockInLineId)});
fn();
setFirstCheckForSil(false);
}, [itemDetail.id]);
useEffect(() => {
if (!itemDetail || firstCheckForSil) return;

const liveStockInLineId = getLiveStockInLineId();
if (!liveStockInLineId) {
setFirstCheckForSil(true);
return;
}

const row = apiRef.current.getRow(Number(liveStockInLineId));
if (!row) {
// Stale query from another POL: drop it once current entries are known
if (
entries.length > 0 &&
!entries.some((e) => String(e.id) === String(liveStockInLineId))
) {
patchQuery((params) => {
params.delete("stockInLineId");
});
setFirstCheckForSil(true);
}
return;
} }
}, [stockInLineId, poLineId, itemDetail]);

setFirstCheckForSil(true);
void handleNewQC(liveStockInLineId, { row })();
}, [
stockInLineIdFromNext,
poLineId,
itemDetail,
firstCheckForSil,
entries,
handleNewQC,
getLiveStockInLineId,
patchQuery,
]);
const handleEscalation = useCallback( const handleEscalation = useCallback(
(id: GridRowId, params: any) => () => { (id: GridRowId, params: any) => () => {
// setBtnIsLoading(true); // setBtnIsLoading(true);


+ 1
- 1
src/components/PoDetail/QcStockInModal.tsx Wyświetl plik

@@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> {
interface Props extends CommonProps { interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
} }
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
const PoQcStockInModalVer2: React.FC<Props> = ({ const PoQcStockInModalVer2: React.FC<Props> = ({
open, open,
onClose, onClose,


+ 319
- 54
src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx Wyświetl plik

@@ -1,5 +1,5 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { import {
Box, Box,
Typography, Typography,
@@ -37,11 +37,17 @@ import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { import {
fetchDrinkProductionQty, fetchDrinkProductionQty,
fetchDrinkShipmentQty,
DrinkProductionQtyResponse, DrinkProductionQtyResponse,
DrinkProductionQtyJobOrderDetail, DrinkProductionQtyJobOrderDetail,
DrinkShipmentQtyResponse,
DrinkShipmentQtyDeliveryDetail,
} from "@/app/api/jo/actions"; } from "@/app/api/jo/actions";
import { arrayToDayjs } from "@/app/utils/formatUtil"; import { arrayToDayjs } from "@/app/utils/formatUtil";
import { exportDrinkProductionQtyXlsx } from "@/components/ProductionProcess/exportDrinkProductionQtyXlsx";
import {
exportDrinkProductionQtyXlsx,
type DrinkViewMode,
} from "@/components/ProductionProcess/exportDrinkProductionQtyXlsx";


const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘


@@ -55,8 +61,6 @@ const JO_STATUS_FILTER_VALUES = [
"completed", "completed",
] as const; ] as const;


type DrinkViewMode = "actual" | "planned";

const formatQty = (qty: number | null | undefined): string => { const formatQty = (qty: number | null | undefined): string => {
if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; if (qty === null || qty === undefined || Number.isNaN(qty)) return "-";
return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); return qty.toLocaleString(undefined, { maximumFractionDigits: 2 });
@@ -100,10 +104,16 @@ const ProcessSummaryTimeText: React.FC<{ value: unknown }> = ({ value }) => {
const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string =>
`${row.itemCode || "unknown"}-${idx}`; `${row.itemCode || "unknown"}-${idx}`;


const getShipmentRowKey = (row: DrinkShipmentQtyResponse, idx: number): string =>
`ship-${row.itemCode || "unknown"}-${idx}`;

/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
const DrinkProductionQtyDashboard: React.FC = () => { const DrinkProductionQtyDashboard: React.FC = () => {
const { t } = useTranslation(["common", "jo", "productionProcess"]);
const { t } = useTranslation(["common", "jo", "do", "productionProcess"]);
const [data, setData] = useState<DrinkProductionQtyResponse[]>([]); const [data, setData] = useState<DrinkProductionQtyResponse[]>([]);
const [shipmentData, setShipmentData] = useState<DrinkShipmentQtyResponse[]>(
[],
);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [joStatusFilter, setJoStatusFilter] = useState<string>(""); const [joStatusFilter, setJoStatusFilter] = useState<string>("");
@@ -117,21 +127,62 @@ const DrinkProductionQtyDashboard: React.FC = () => {
); );


const isPlanned = viewMode === "planned"; const isPlanned = viewMode === "planned";
const isShipment = viewMode === "shipment";

const qtyHeaders = ((): { left: string; right: string } => {
switch (viewMode) {
case "shipment":
return {
left: t("Shipment Order Qty"),
right: t("Shipped Qty"),
};
case "planned":
return {
left: t("Planned Output Qty"),
right: t("Actual Output Qty"),
};
case "actual":
return {
left: t("Stock Req. Qty"),
right: t("Production Qty"),
};
default: {
const _exhaustive: never = viewMode;
return _exhaustive;
}
}
})();


const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
const dateStr = selectedDate.format("YYYY-MM-DD");
try { try {
const result = await fetchDrinkProductionQty(
selectedDate.format("YYYY-MM-DD"),
viewMode,
);
setData(result || []);
switch (viewMode) {
case "shipment": {
const result = await fetchDrinkShipmentQty(dateStr);
setShipmentData(result || []);
setData([]);
break;
}
case "actual":
case "planned": {
const result = await fetchDrinkProductionQty(dateStr, viewMode);
setData(result || []);
setShipmentData([]);
break;
}
default: {
const _exhaustive: never = viewMode;
return _exhaustive;
}
}
setExpandedRowKeys(new Set()); setExpandedRowKeys(new Set());
setLastDataRefreshTime(dayjs()); setLastDataRefreshTime(dayjs());
refreshCountRef.current += 1; refreshCountRef.current += 1;
} catch (error) { } catch (error) {
console.error("Error fetching drink production qty:", error); console.error("Error fetching drink production qty:", error);
setData([]); setData([]);
setShipmentData([]);
setExpandedRowKeys(new Set()); setExpandedRowKeys(new Set());
} finally { } finally {
setLoading(false); setLoading(false);
@@ -158,12 +209,32 @@ const DrinkProductionQtyDashboard: React.FC = () => {
}); });
}; };


const filterJobOrders = (
jobOrders: DrinkProductionQtyJobOrderDetail[],
): DrinkProductionQtyJobOrderDetail[] => {
if (!joStatusFilter) return jobOrders;
return jobOrders.filter((jo) => jo.jobOrderStatus === joStatusFilter);
};
const filteredItemRows = useMemo(() => {
const rows: Array<{
row: DrinkProductionQtyResponse;
jobOrders: DrinkProductionQtyJobOrderDetail[];
totalReqQty: number;
totalQty: number;
}> = [];
for (const row of data) {
const allJobOrders = row.jobOrders ?? [];
const jobOrders = joStatusFilter
? allJobOrders.filter((jo) => jo.jobOrderStatus === joStatusFilter)
: allJobOrders;
if (joStatusFilter && jobOrders.length === 0) continue;
rows.push({
row,
jobOrders,
totalReqQty: joStatusFilter
? jobOrders.reduce((sum, jo) => sum + (jo.reqQty ?? 0), 0)
: row.totalReqQty,
totalQty: joStatusFilter
? jobOrders.reduce((sum, jo) => sum + (jo.productionQty ?? 0), 0)
: row.totalQty,
});
}
return rows;
}, [data, joStatusFilter]);


const renderActualEnd = (jo: DrinkProductionQtyJobOrderDetail) => { const renderActualEnd = (jo: DrinkProductionQtyJobOrderDetail) => {
const start = parseProcessTime(jo.startTime); const start = parseProcessTime(jo.startTime);
@@ -190,6 +261,33 @@ const DrinkProductionQtyDashboard: React.FC = () => {
); );
}; };


const renderDoStatusChip = (status: string | null | undefined) => {
if (!status) return <>—</>;
const normalized = status.toLowerCase();
let label: string;
switch (normalized) {
case "pending":
label = t("Drink do status pending");
break;
case "receiving":
label = t("Drink do status receiving");
break;
case "completed":
label = t("Drink do status completed");
break;
default:
label = t(status, { ns: "do", defaultValue: status });
break;
}
return (
<Chip
size="small"
label={label}
sx={{ height: 22 }}
/>
);
};

return ( return (
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
<CardContent> <CardContent>
@@ -216,29 +314,31 @@ const DrinkProductionQtyDashboard: React.FC = () => {
/> />
</LocalizationProvider> </LocalizationProvider>


<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="drink-jo-status-filter-label">
{t("Job Order Status")}
</InputLabel>
<Select
labelId="drink-jo-status-filter-label"
id="drink-jo-status-filter"
label={t("Job Order Status")}
value={joStatusFilter}
onChange={(e) => {
setJoStatusFilter(String(e.target.value));
}}
>
<MenuItem value="">
<em>{t("All")}</em>
</MenuItem>
{JO_STATUS_FILTER_VALUES.map((v) => (
<MenuItem key={v} value={v}>
{t(v, { ns: "jo" })}
{!isShipment && (
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="drink-jo-status-filter-label">
{t("Job Order Status")}
</InputLabel>
<Select
labelId="drink-jo-status-filter-label"
id="drink-jo-status-filter"
label={t("Job Order Status")}
value={joStatusFilter}
onChange={(e) => {
setJoStatusFilter(String(e.target.value));
}}
>
<MenuItem value="">
<em>{t("All")}</em>
</MenuItem> </MenuItem>
))}
</Select>
</FormControl>
{JO_STATUS_FILTER_VALUES.map((v) => (
<MenuItem key={v} value={v}>
{t(v, { ns: "jo" })}
</MenuItem>
))}
</Select>
</FormControl>
)}


<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />


@@ -246,11 +346,14 @@ const DrinkProductionQtyDashboard: React.FC = () => {
variant="outlined" variant="outlined"
size="small" size="small"
startIcon={<FileDownloadIcon />} startIcon={<FileDownloadIcon />}
disabled={loading || data.length === 0}
disabled={
loading || (isShipment ? shipmentData.length === 0 : data.length === 0)
}
sx={{ display: "none" }} sx={{ display: "none" }}
onClick={() => { onClick={() => {
exportDrinkProductionQtyXlsx({ exportDrinkProductionQtyXlsx({
data, data,
shipmentData,
viewMode, viewMode,
selectedDate: selectedDate.format("YYYY-MM-DD"), selectedDate: selectedDate.format("YYYY-MM-DD"),
statusFilter: joStatusFilter, statusFilter: joStatusFilter,
@@ -298,6 +401,9 @@ const DrinkProductionQtyDashboard: React.FC = () => {
<ToggleButton value="planned"> <ToggleButton value="planned">
{t("Drink detail mode: planned")} {t("Drink detail mode: planned")}
</ToggleButton> </ToggleButton>
<ToggleButton value="shipment">
{t("Drink detail mode: shipment")}
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
</Stack> </Stack>


@@ -347,22 +453,185 @@ const DrinkProductionQtyDashboard: React.FC = () => {
</TableCell> </TableCell>
<TableCell align="right" sx={{ width: 140 }}> <TableCell align="right" sx={{ width: 140 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{isPlanned
? t("Planned Output Qty")
: t("Stock Req. Qty")}
{qtyHeaders.left}
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell align="right" sx={{ width: 140 }}> <TableCell align="right" sx={{ width: 140 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{isPlanned
? t("Actual Output Qty")
: t("Production Qty")}
{qtyHeaders.right}
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{data.length === 0 ? (
{isShipment ? (
shipmentData.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography
variant="body2"
sx={{ py: 2, color: "text.secondary" }}
>
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
shipmentData.map((row, idx) => {
const rowKey = getShipmentRowKey(row, idx);
const deliveries: DrinkShipmentQtyDeliveryDetail[] =
row.deliveries ?? [];
const isExpanded = expandedRowKeys.has(rowKey);
const hasExpandable = deliveries.length > 0;

return (
<React.Fragment key={rowKey}>
<TableRow hover={hasExpandable}>
<TableCell padding="checkbox">
{hasExpandable ? (
<IconButton
size="small"
aria-label={
isExpanded
? t("Collapse delivery order details")
: t("Expand delivery order details")
}
onClick={() => toggleRowExpanded(rowKey)}
>
{isExpanded ? (
<ExpandLessIcon fontSize="small" />
) : (
<ExpandMoreIcon fontSize="small" />
)}
</IconButton>
) : null}
</TableCell>
<TableCell>
<Typography variant="body2">
{row.itemCode || "-"}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{row.itemName || "-"}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{row.uom || "-"}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{formatQty(row.totalOrderQty)}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{formatQty(row.totalShippedQty)}
</Typography>
</TableCell>
</TableRow>
{hasExpandable && (
<TableRow>
<TableCell
colSpan={6}
sx={{ py: 0, borderBottom: 0 }}
>
<Collapse
in={isExpanded}
timeout="auto"
unmountOnExit
>
<Box sx={{ py: 1.5, pl: 6, pr: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>
{t("Delivery Order Code", {
ns: "do",
})}
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>
{t("Delivery Order Status", {
ns: "do",
})}
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>
{t("Shop Name", { ns: "do" })}
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>
{t("Delivery Date", { ns: "do" })}
</TableCell>
<TableCell
align="right"
sx={{ fontWeight: 600 }}
>
{t("Shipment Order Qty")}
</TableCell>
<TableCell
align="right"
sx={{ fontWeight: 600 }}
>
{t("Shipped Qty")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{deliveries.map((delivery) => (
<TableRow
key={`${rowKey}-do-${delivery.deliveryOrderId}`}
>
<TableCell>
{delivery.deliveryOrderId > 0 ? (
<MuiLink
component={NextLink}
href={`/do/edit?id=${delivery.deliveryOrderId}`}
underline="hover"
>
{delivery.deliveryOrderCode ||
`DO-${delivery.deliveryOrderId}`}
</MuiLink>
) : (
delivery.deliveryOrderCode ||
"-"
)}
</TableCell>
<TableCell>
{renderDoStatusChip(
delivery.deliveryOrderStatus,
)}
</TableCell>
<TableCell>
{delivery.shopName ||
delivery.shopCode ||
"-"}
</TableCell>
<TableCell>
{formatProductionDate(
delivery.deliveryDate,
)}
</TableCell>
<TableCell align="right">
{formatQty(delivery.orderQty)}
</TableCell>
<TableCell align="right">
{formatQty(delivery.shippedQty)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)
) : filteredItemRows.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} align="center"> <TableCell colSpan={6} align="center">
<Typography <Typography
@@ -374,14 +643,10 @@ const DrinkProductionQtyDashboard: React.FC = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.map((row, idx) => {
filteredItemRows.map(({ row, jobOrders, totalReqQty, totalQty }, idx) => {
const rowKey = getRowKey(row, idx); const rowKey = getRowKey(row, idx);
const allJobOrders = row.jobOrders ?? [];
const jobOrders = filterJobOrders(allJobOrders);
const isExpanded = expandedRowKeys.has(rowKey); const isExpanded = expandedRowKeys.has(rowKey);
const hasExpandable =
allJobOrders.length > 0 &&
(joStatusFilter === "" || jobOrders.length > 0);
const hasExpandable = jobOrders.length > 0;


return ( return (
<React.Fragment key={rowKey}> <React.Fragment key={rowKey}>
@@ -422,12 +687,12 @@ const DrinkProductionQtyDashboard: React.FC = () => {
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
<Typography variant="body2"> <Typography variant="body2">
{formatQty(row.totalReqQty)}
{formatQty(totalReqQty)}
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
<Typography variant="body2"> <Typography variant="body2">
{formatQty(row.totalQty)}
{formatQty(totalQty)}
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>


+ 6
- 3
src/components/ProductionProcess/ProductionProcessDetail.tsx Wyświetl plik

@@ -66,7 +66,7 @@ interface ProductProcessDetailProps {
fromJosave?: boolean; fromJosave?: boolean;
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.0 | 2026-08-05 */
/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.1 | 2026-08-10 */
const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
jobOrderId, jobOrderId,
onBack, onBack,
@@ -77,7 +77,7 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
const { t } = useTranslation(["productionProcess", "common"]); const { t } = useTranslation(["productionProcess", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null }; const { data: session } = useSession() as { data: SessionWithTokens | null };
const abilities = session?.abilities ?? session?.user?.abilities ?? []; const abilities = session?.abilities ?? session?.user?.abilities ?? [];
/** 「已完成」(Just Pass):僅 ADMIN */
/** 「跳過」(Just Pass):僅 ADMIN */
const canAdminPass = hasAbility(abilities, AUTH.ADMIN); const canAdminPass = hasAbility(abilities, AUTH.ADMIN);
const currentUserId = session?.id ? parseInt(session.id) : undefined; const currentUserId = session?.id ? parseInt(session.id) : undefined;
const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext();
@@ -666,7 +666,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
const isPaused = statusLower === 'paused'; const isPaused = statusLower === 'paused';
const isPending = statusLower === 'pending' || status === ''; const isPending = statusLower === 'pending' || status === '';
const isPass = statusLower === 'pass'; const isPass = statusLower === 'pass';
const isPassDisabled = isCompleted || isPass || !canAdminPass;
const isAutoPass = statusLower === 'autopass' || statusLower === 'auto pass';
const isPassDisabled = isCompleted || isPass || isAutoPass || !canAdminPass;
return ( return (
<TableRow key={line.id}> <TableRow key={line.id}>
<TableCell> <TableCell>
@@ -773,6 +774,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
<Chip label={t("Pending")} color="default" size="small" /> <Chip label={t("Pending")} color="default" size="small" />
) : isPaused ? ( ) : isPaused ? (
<Chip label={t("Paused")} color="warning" size="small" /> <Chip label={t("Paused")} color="warning" size="small" />
) : isAutoPass ? (
<Chip label={t("Auto Pass")} color="default" size="small" />
) : isPass ? ( ) : isPass ? (
<Chip label={t("Just Pass")} color="success" size="small" /> <Chip label={t("Just Pass")} color="success" size="small" />
) : ( ) : (


+ 4
- 4
src/components/ProductionProcess/ProductionProcessList.tsx Wyświetl plik

@@ -178,7 +178,7 @@ function isWaitingQcPutAway(
return s !== "completed" && s !== "rejected"; return s !== "completed" && s !== "rejected";
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.8 | 2026-08-09 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.9 | 2026-08-10 */
const ProductProcessList: React.FC<ProductProcessListProps> = ({ const ProductProcessList: React.FC<ProductProcessListProps> = ({
onSelectProcess, onSelectProcess,
printerCombo, printerCombo,
@@ -447,7 +447,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
return productionCache; return productionCache;
}, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]); }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]);


// QC ready: same JO — all lines Completed/Pass (sibling 包裝 auto-Pass on backend) + has SIL
// QC ready: same JO — all lines Completed/Pass/autoPass (sibling 包裝 autoPass on backend) + has SIL
const jobOrderQcReadyById = useMemo(() => { const jobOrderQcReadyById = useMemo(() => {
const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>();
for (const p of tabProcesses) { for (const p of tabProcesses) {
@@ -459,8 +459,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({


const result = new Map<number, boolean>(); const result = new Map<number, boolean>();
const isDone = (status: unknown) => { const isDone = (status: unknown) => {
const s = String(status ?? "").trim().toLowerCase();
return s === "completed" || s === "pass";
const s = String(status ?? "").trim().toLowerCase().replace(/\s+/g, "");
return s === "completed" || s === "pass" || s === "autopass";
}; };


byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {


+ 39
- 17
src/components/ProductionProcess/ProductionProcessStepExecution.tsx Wyświetl plik

@@ -55,6 +55,7 @@ interface ProductionProcessStepExecutionProps {
jobOrderId?: number; // ✅ 添加 jobOrderId?: number; // ✅ 添加
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.0 | 2026-08-10 */
const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionProps> = ({ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionProps> = ({
lineId, lineId,
onBack, onBack,
@@ -62,9 +63,18 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
allLines, // ✅ 添加 allLines, // ✅ 添加
jobOrderId, // ✅ 添加 jobOrderId, // ✅ 添加
}) => { }) => {
const { t } = useTranslation( ["common","jo"]);
const { t } = useTranslation( ["common","jo","productionProcess"]);
const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null); const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null);
const isCompleted = lineDetail?.status === "Completed" || lineDetail?.status === "Pass";
const lineStatusNorm = String(lineDetail?.status ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, "");
const isCompleted =
lineStatusNorm === "completed" ||
lineStatusNorm === "pass" ||
lineStatusNorm === "autopass";
const isPassStatus = lineStatusNorm === "pass";
const isAutoPassStatus = lineStatusNorm === "autopass";
const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & { const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & {
byproductName: string; byproductName: string;
byproductQty: number; byproductQty: number;
@@ -161,8 +171,16 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
}, [lineId]); }, [lineId]);


useEffect(() => { useEffect(() => {
// Don't show time remaining if completed
if (lineDetail?.status === "Completed" || lineDetail?.status === "Pass") {
// Don't show time remaining if completed / pass / autoPass
const statusNorm = String(lineDetail?.status ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, "");
if (
statusNorm === "completed" ||
statusNorm === "pass" ||
statusNorm === "autopass"
) {
console.log("Line is completed"); console.log("Line is completed");
setRemainingTime(null); setRemainingTime(null);
setIsOverTime(false); setIsOverTime(false);
@@ -553,9 +571,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
{isCompleted ? ( {isCompleted ? (
<Card sx={{ bgcolor: 'success.50', border: '2px solid', borderColor: 'success.main', mb: 3 }}> <Card sx={{ bgcolor: 'success.50', border: '2px solid', borderColor: 'success.main', mb: 3 }}>
<CardContent> <CardContent>
{lineDetail?.status === "Pass" ? (
{isAutoPassStatus ? (
<Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold">
{t("Passed Step")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo})
{t("Auto Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo})
</Typography>
) : isPassStatus ? (
<Typography variant="h5" color="success.main" gutterBottom fontWeight="bold">
{t("Just Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo})
</Typography> </Typography>
) : ( ) : (
<Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold">
@@ -618,13 +640,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
<Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(1)")}</Typography> <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(1)")}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectQty}</Typography>
<Typography>{lineDetail?.defectQty}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectUom || "-"}</Typography>
<Typography>{lineDetail?.defectUom || "-"}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectDescription || "-"}</Typography>
<Typography>{lineDetail?.defectDescription || "-"}</Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow sx={{ bgcolor: 'warning.50' }}> <TableRow sx={{ bgcolor: 'warning.50' }}>
@@ -632,13 +654,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
<Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography> <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectQty2}</Typography>
<Typography>{lineDetail?.defectQty2}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectUom2 || "-"}</Typography>
<Typography>{lineDetail?.defectUom2 || "-"}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectDescription2 || "-"}</Typography>
<Typography>{lineDetail?.defectDescription2 || "-"}</Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow sx={{ bgcolor: 'warning.50' }}> <TableRow sx={{ bgcolor: 'warning.50' }}>
@@ -646,13 +668,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
<Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography> <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectQty3}</Typography>
<Typography>{lineDetail?.defectQty3}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectUom3 || "-"}</Typography>
<Typography>{lineDetail?.defectUom3 || "-"}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.defectDescription3 || "-"}</Typography>
<Typography>{lineDetail?.defectDescription3 || "-"}</Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow sx={{ bgcolor: 'error.50' }}> <TableRow sx={{ bgcolor: 'error.50' }}>
@@ -660,10 +682,10 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
<Typography fontWeight={500} color="error.dark">{t("Scrap")}</Typography> <Typography fontWeight={500} color="error.dark">{t("Scrap")}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.scrapQty}</Typography>
<Typography>{lineDetail?.scrapQty}</Typography>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{lineDetail.scrapUom || "-"}</Typography>
<Typography>{lineDetail?.scrapUom || "-"}</Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>


+ 80
- 5
src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts Wyświetl plik

@@ -4,9 +4,10 @@ import { exportMultiSheetToXlsx } from "@/app/(main)/chart/_components/exportCha
import type { import type {
DrinkProductionQtyJobOrderDetail, DrinkProductionQtyJobOrderDetail,
DrinkProductionQtyResponse, DrinkProductionQtyResponse,
DrinkShipmentQtyResponse,
} from "@/app/api/jo/actions"; } from "@/app/api/jo/actions";


type DrinkViewMode = "actual" | "planned";
export type DrinkViewMode = "actual" | "planned" | "shipment";


const formatDateTime = (value: unknown): string => { const formatDateTime = (value: unknown): string => {
if (value == null || value === "") return ""; if (value == null || value === "") return "";
@@ -27,28 +28,102 @@ const formatDate = (value: string | null | undefined): string => {


export type ExportDrinkProductionQtyParams = { export type ExportDrinkProductionQtyParams = {
data: DrinkProductionQtyResponse[]; data: DrinkProductionQtyResponse[];
shipmentData?: DrinkShipmentQtyResponse[];
viewMode: DrinkViewMode; viewMode: DrinkViewMode;
selectedDate: string; selectedDate: string;
statusFilter: string; statusFilter: string;
t: TFunction; t: TFunction;
}; };


const viewModeLabel = (viewMode: DrinkViewMode, t: TFunction): string => {
switch (viewMode) {
case "planned":
return t("Drink detail mode: planned");
case "shipment":
return t("Drink detail mode: shipment");
case "actual":
return t("Drink detail mode: actual");
default: {
const _exhaustive: never = viewMode;
return _exhaustive;
}
}
};

const formatDoStatus = (status: string | null | undefined, t: TFunction): string => {
if (!status) return "";
switch (status.toLowerCase()) {
case "pending":
return t("Drink do status pending");
case "receiving":
return t("Drink do status receiving");
case "completed":
return t("Drink do status completed");
default:
return t(status, { ns: "do", defaultValue: status });
}
};

export function exportDrinkProductionQtyXlsx({ export function exportDrinkProductionQtyXlsx({
data, data,
shipmentData = [],
viewMode, viewMode,
selectedDate, selectedDate,
statusFilter, statusFilter,
t, t,
}: ExportDrinkProductionQtyParams): void { }: ExportDrinkProductionQtyParams): void {
const viewLabel =
viewMode === "planned"
? t("Drink detail mode: planned")
: t("Drink detail mode: actual");
const viewLabel = viewModeLabel(viewMode, t);
const statusFilterLabel = statusFilter const statusFilterLabel = statusFilter
? t(statusFilter, { ns: "jo", defaultValue: statusFilter }) ? t(statusFilter, { ns: "jo", defaultValue: statusFilter })
: t("All"); : t("All");
const exportedAt = dayjs().format("YYYY-MM-DD HH:mm:ss"); const exportedAt = dayjs().format("YYYY-MM-DD HH:mm:ss");


if (viewMode === "shipment") {
const doRows: Record<string, unknown>[] = [];
const itemRows = shipmentData.map((item) => {
for (const delivery of item.deliveries ?? []) {
doRows.push({
[t("Export meta: exported at")]: exportedAt,
[t("Drink detail mode label")]: viewLabel,
[t("Date")]: selectedDate,
[t("Item Code")]: item.itemCode ?? "",
[t("Goods Name")]: item.itemName ?? "",
[t("Unit")]: item.uom ?? "",
[t("Delivery Order Code", { ns: "do" })]: delivery.deliveryOrderCode ?? "",
[t("Delivery Order Status", { ns: "do" })]: formatDoStatus(
delivery.deliveryOrderStatus,
t,
),
[t("Shop Name", { ns: "do" })]:
delivery.shopName || delivery.shopCode || "",
[t("Delivery Date", { ns: "do" })]: formatDate(delivery.deliveryDate),
[t("Shipment Order Qty")]: delivery.orderQty ?? 0,
[t("Shipped Qty")]: delivery.shippedQty ?? 0,
});
}
return {
[t("Export meta: exported at")]: exportedAt,
[t("Drink detail mode label")]: viewLabel,
[t("Date")]: selectedDate,
[t("Item Code")]: item.itemCode ?? "",
[t("Goods Name")]: item.itemName ?? "",
[t("Unit")]: item.uom ?? "",
[t("Shipment Order Qty")]: item.totalOrderQty ?? 0,
[t("Shipped Qty")]: item.totalShippedQty ?? 0,
[t("DO count")]: (item.deliveries ?? []).length,
};
});
const filename = `DrinkProductionQty_shipment_${selectedDate}_${dayjs().format("HHmm")}`;
exportMultiSheetToXlsx(
[
{ name: t("Excel sheet: DO detail"), rows: doRows },
{ name: t("Excel sheet: item summary"), rows: itemRows },
],
filename,
);
return;
}

const filterJos = ( const filterJos = (
jobOrders: DrinkProductionQtyJobOrderDetail[], jobOrders: DrinkProductionQtyJobOrderDetail[],
): DrinkProductionQtyJobOrderDetail[] => { ): DrinkProductionQtyJobOrderDetail[] => {


+ 1
- 1
src/components/Qc/QcStockInModal.tsx Wyświetl plik

@@ -72,7 +72,7 @@ interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
const QcStockInModal: React.FC<Props> = ({ const QcStockInModal: React.FC<Props> = ({
open, open,
onClose, onClose,


+ 23
- 1
src/components/UserSearch/UserExcelSheetView.tsx Wyświetl plik

@@ -328,7 +328,29 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
} catch (error) { } catch (error) {
console.error("Failed to save user authorities", error); console.error("Failed to save user authorities", error);
setAllUsers(cloneUserList(savedUsers)); setAllUsers(cloneUserList(savedUsers));
alert(t("Save failed. Please try again.", { defaultValue: "儲存失敗,請再試一次。" }));

const msg = error instanceof Error ? error.message : String(error);
let text = t("Save failed. Please try again.", {
defaultValue: "儲存失敗,請再試一次。",
});
if (msg.includes("USERNAME_NOT_AVAILABLE")) {
text = t("Username is already taken");
} else if (msg.includes("USER_WRONG_NEW_PWD")) {
text = t("New password does not meet the rules");
} else if (/\b400\b/.test(msg)) {
text = t("Invalid request. Please check your input");
} else if (
msg.includes("Unauthorized") ||
/\b401\b/.test(msg) ||
/\b403\b/.test(msg)
) {
text = t("Unauthorized or no permission");
} else if (/\b404\b/.test(msg)) {
text = t("User Not Found");
} else if (/\b500\b/.test(msg)) {
text = t("Server error. Please try again later");
}
alert(text);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
saveInFlightRef.current = false; saveInFlightRef.current = false;


+ 17
- 1
src/config/authConfig.ts Wyświetl plik

@@ -12,6 +12,7 @@ declare module "next-auth" {
id?: string; id?: string;
/** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */
exp?: number; exp?: number;
locale?: string;
} }


interface User { interface User {
@@ -19,6 +20,7 @@ declare module "next-auth" {
accessToken: string | null; accessToken: string | null;
refreshToken?: string; refreshToken?: string;
abilities: string[]; abilities: string[];
locale?: string;
} }
} }


@@ -28,6 +30,7 @@ declare module "next-auth/jwt" {
accessToken: string | null; accessToken: string | null;
refreshToken?: string; refreshToken?: string;
abilities: string[]; abilities: string[];
locale?: string;
} }
} }


@@ -70,13 +73,24 @@ export const authOptions: AuthOptions = {
}, },
callbacks: { callbacks: {
// Persist custom fields into the JWT token // Persist custom fields into the JWT token
async jwt({ token, user }) {
async jwt({ token, user, trigger, session }) {
// First sign-in: `user` is available // First sign-in: `user` is available
if (user) { if (user) {
token.id = user.id ?? token.sub; // fallback to sub if no id token.id = user.id ?? token.sub; // fallback to sub if no id
token.accessToken = user.accessToken; token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken; token.refreshToken = user.refreshToken;
token.abilities = user.abilities ?? []; token.abilities = user.abilities ?? [];
const loginLocale = (user as { locale?: string }).locale;
if (loginLocale) {
token.locale = loginLocale;
}
}

if (trigger === "update" && session && typeof session === "object" && "locale" in session) {
const next = (session as { locale?: string }).locale;
if (next === "zh" || next === "en") {
token.locale = next;
}
} }


// On subsequent calls (token refresh, session access), user is not present // On subsequent calls (token refresh, session access), user is not present
@@ -91,6 +105,7 @@ export const authOptions: AuthOptions = {
session.refreshToken = token.refreshToken as string | undefined; session.refreshToken = token.refreshToken as string | undefined;
session.abilities = token.abilities as string[]; session.abilities = token.abilities as string[];
session.exp = token.exp as number | undefined; session.exp = token.exp as number | undefined;
session.locale = token.locale as string | undefined;


// Also add abilities to session.user for easier client-side access // Also add abilities to session.user for easier client-side access
if (session.user) { if (session.user) {
@@ -107,5 +122,6 @@ export type SessionWithTokens = Session & {
abilities: string[]; abilities: string[];
/** Backend / JWT subject — often numeric string or number */ /** Backend / JWT subject — often numeric string or number */
id?: string | number; id?: string | number;
locale?: string;
}; };
export default authOptions; export default authOptions;

+ 165
- 6
src/config/reportConfig.ts Wyświetl plik

@@ -17,6 +17,10 @@ export interface ReportField {
allowInput?: boolean; // Allow user to input custom values (for select types) allowInput?: boolean; // Allow user to input custom values (for select types)
/** When checkbox is checked, disable these field names (by `name`) */ /** When checkbox is checked, disable these field names (by `name`) */
disablesFieldsWhenChecked?: string[]; disablesFieldsWhenChecked?: string[];
/** For date fields: restrict picker so value cannot be before today */
minDate?: 'today';
/** Disable the input (e.g. date locked to today) */
disabled?: boolean;
} }


export type ReportResponseType = 'pdf' | 'excel'; export type ReportResponseType = 'pdf' | 'excel';
@@ -30,6 +34,7 @@ export interface ReportDefinition {
fields: ReportField[]; fields: ReportField[];
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */
export const REPORTS: ReportDefinition[] = [ export const REPORTS: ReportDefinition[] = [
//{ //{
// id: "rep-001", // id: "rep-001",
@@ -79,10 +84,37 @@ export const REPORTS: ReportDefinition[] = [
title: "入倉追蹤報告", title: "入倉追蹤報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-in-traceability`, apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-in-traceability`,
fields: [ fields: [
{ label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false},
{
label: "樓層 Store ID",
name: "storeId",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "1F", value: "1F" },
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" },
],
},
{ label: "倉庫 Warehouse", name: "warehouse", type: "text", required: false, placeholder: "e.g. W201" },
{ label: "區域 Area", name: "area", type: "text", required: false, placeholder: "e.g. #A" },
{ label: "儲位 Slot", name: "slot", type: "text", required: false, placeholder: "e.g. 01" },
{ label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" },
{
label: "PP/PF 分類",
name: "poPrefix",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "PP", value: "PP" },
{ label: "PF", value: "PF" },
],
},
] ]
}, },
{ {
@@ -167,6 +199,80 @@ export const REPORTS: ReportDefinition[] = [
}, },
] ]
}, },
/* Hidden for now: 庫存批次結餘報告 (rep-019)
{
id: "rep-019",
title: "庫存批次結餘報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-balance`,
responseType: "excel",
fields: [
{ label: "庫存日期 Stock Date(僅今天)", name: "stockDate", type: "date", required: true, disabled: true },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false },
{ label: "倉位 Warehouse Code", name: "warehouseCode", type: "text", required: false, placeholder: "e.g. W200 or 2F-W200-#A-00" },
{
label: "樓層 Store ID",
name: "storeId",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "1F", value: "1F" },
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" },
],
},
{ label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" },
],
},
*/
{
id: "rep-021",
title: "庫存批次現況報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-onhand`,
responseType: "excel",
fields: [
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false },
{
label: "樓層 Store ID",
name: "storeId",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "1F", value: "1F" },
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" },
],
},
{ label: "倉庫 Warehouse", name: "warehouse", type: "text", required: false, placeholder: "e.g. W201" },
{ label: "區域 Area", name: "area", type: "text", required: false, placeholder: "e.g. #A" },
{ label: "儲位 Slot", name: "slot", type: "text", required: false, placeholder: "e.g. 02" },
{
label: "盤點區域說明 Stock Take Section",
name: "stockTakeSectionDescription",
type: "select",
required: false,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/warehouse/stockTakeSections`,
options: [{ label: "全部", value: "All" }],
},
{ label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" },
{
label: "來源 Lot Origin",
name: "lotOrigin",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "PP", value: "PP" },
{ label: "PF", value: "PF" },
{ label: "其他", value: "other" },
],
},
],
},
{ id: "rep-011", { id: "rep-011",
title: "庫存明細報告", title: "庫存明細報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-ledger`, apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-ledger`,
@@ -176,6 +282,19 @@ export const REPORTS: ReportDefinition[] = [
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false},
] ]
}, },
/* Hidden for now: 庫存流水帳報告 (rep-020)
{
id: "rep-020",
title: "庫存流水帳報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-ledger`,
responseType: "excel",
fields: [
{ label: "期間起 Period From(永遠該月1日)", name: "lastInDateStart", type: "date", required: true },
{ label: "期間迄 Period To", name: "lastInDateEnd", type: "date", required: true },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false },
],
},
*/
/* /*
{ {
id: "rep-007", id: "rep-007",
@@ -205,6 +324,7 @@ export const REPORTS: ReportDefinition[] = [
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false},
] ]
}, },


{ {
id: "rep-014", id: "rep-014",
@@ -233,19 +353,31 @@ export const REPORTS: ReportDefinition[] = [
] ]
}, },



{ id: "rep-010", { id: "rep-010",
/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
title: "庫存品質檢測報告", title: "庫存品質檢測報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-item-qc-fail`, apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-item-qc-fail`,
fields: [ fields: [
{ label: "QC 檢測日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "QC 檢測日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "QC 檢測日期:由 QC Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "QC 檢測日期:至 QC Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "QC 類型", name: "qcType", type: "select", required: false, { label: "QC 類型", name: "qcType", type: "select", required: false,
options: [ options: [
{ label: "全部", value: "" },
{ label: "IQC", value: "IQC" },
{ label: "EPQC", value: "EPQC" },
{ label: "全部", value: "all" },
{ label: "IQC(採購)", value: "IQC" },
{ label: "EPQC(工單)", value: "EPQC" },
] }, ] },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false},
{
label: "QC 項目範圍",
name: "qcItemScope",
type: "select",
required: false,
options: [
{ label: "全部 QC 項目", value: "all" },
{ label: "只包含溫度濕度", value: "measurable" },
],
},
] ]
}, },
{ id: "rep-013", { id: "rep-013",
@@ -383,4 +515,31 @@ export const REPORTS: ReportDefinition[] = [
{ label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" }, { label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" },
], ],
}, },
{
/** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */
id: "rep-018",
title: "送貨訂單與倉存單位不符報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-do-inventory-uom-mismatch`,
responseType: "excel",
fields: [
{
label: "預計送貨日期 Estimated Arrival Date",
name: "deliveryDate",
type: "date",
required: true,
minDate: "today",
},
{
label: "送貨訂單樓層 Floor",
name: "storeId",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "2F", value: "2F" },
{ label: "4F", value: "4F" },
],
},
],
},
] ]

+ 3
- 1
src/i18n/en/chart.json Wyświetl plik

@@ -7,6 +7,8 @@
"board_processLive": "Process Live Board", "board_processLive": "Process Live Board",
"dateRange_lastDays": "Last {{d}} days", "dateRange_lastDays": "Last {{d}} days",
"delivery_colAvgMin": "Avg Min/Order", "delivery_colAvgMin": "Avg Min/Order",
"delivery_colItemKindCount": "Item Kind Count",
"delivery_colItemQtyPicked": "Item Qty Picked",
"delivery_colPickCount": "Pick Count", "delivery_colPickCount": "Pick Count",
"delivery_colStaff": "Staff", "delivery_colStaff": "Staff",
"delivery_colTotalMin": "Total Min", "delivery_colTotalMin": "Total Min",
@@ -19,7 +21,7 @@
"delivery_ordersByDate": "Delivery Orders by Date", "delivery_ordersByDate": "Delivery Orders by Date",
"delivery_ordersByDate_export": "Delivery_Orders_By_Date", "delivery_ordersByDate_export": "Delivery_Orders_By_Date",
"delivery_staff": "Staff", "delivery_staff": "Staff",
"delivery_staffPerfCaption": "Per-person pick count & total duration for period",
"delivery_staffPerfCaption": "Per-person pick count, item kinds, picked qty & total duration for period",
"delivery_staffPerfDateError": "Staff performance start date cannot be later than end date", "delivery_staffPerfDateError": "Staff performance start date cannot be later than end date",
"delivery_staffPerformanceTitle": "Staff Delivery Performance (Daily Pick Count & Duration)", "delivery_staffPerformanceTitle": "Staff Delivery Performance (Daily Pick Count & Duration)",
"delivery_staffPlaceholder": "Leave empty for all", "delivery_staffPlaceholder": "Leave empty for all",


+ 1
- 0
src/i18n/en/common.json Wyświetl plik

@@ -87,6 +87,7 @@
"Select Date": "選擇日期", "Select Date": "選擇日期",
"Session expired or unauthorized.": "工作階段已過期或未經授權。", "Session expired or unauthorized.": "工作階段已過期或未經授權。",
"Sign out": "Sign out", "Sign out": "Sign out",
"Language": "Language",
"Status": "狀態", "Status": "狀態",
"Stock Qty": "庫存數量", "Stock Qty": "庫存數量",
"Supporting Document": "證明文件", "Supporting Document": "證明文件",


+ 38
- 0
src/i18n/en/itemDefaultShelfLife.json Wyświetl plik

@@ -0,0 +1,38 @@
{
"title": "Item default shelf life",
"Intro": "Manage default shelf-life days by item code for bag / OnPack expiry print. When “Print uses -18” is on, expiry uses -18 days; otherwise chilled days.",
"Search placeholder": "Search item code, name, or remarks",
"Add": "Add",
"Edit": "Edit",
"Delete": "Delete",
"Save": "Save",
"Saving": "Saving",
"Cancel": "Cancel",
"Saved": "Saved",
"Deleted": "Deleted",
"Add title": "Add shelf life",
"Edit title": "Edit shelf life",
"Delete title": "Delete shelf life",
"Delete confirm": "Delete the default shelf life for {{itemCode}}? Bag / OnPack print will no longer show an expiry for this item.",
"Col itemCode": "Item code",
"Col itemName": "Item name",
"Col defaultDays": "Chilled days",
"Col minus18Days": "-18 days",
"Col useMinus18": "Print uses -18",
"Col effectiveDays": "Print days",
"Col openedDays": "Opened days",
"Col storageC": "Storage °C",
"Col remarks": "Remarks",
"Col actions": "Actions",
"Empty": "No rows yet. Use Add to create a shelf-life record.",
"No match": "No rows match the search.",
"Showing": "Showing {{from}}–{{to}} of {{total}}",
"Item code required": "Item code is required.",
"Days invalid": "Days must be 0 or a positive integer.",
"Use minus18": "Print uses -18 days",
"Use minus18 help": "When checked, bag / OnPack expiry uses -18 days; otherwise chilled days.",
"Expiry preview": "Expiry if printed today: {{date}}",
"Expiry preview none": "Expiry if printed today: cannot compute (chosen days missing or not greater than 0)",
"Yes": "Yes",
"No": "No"
}

+ 2
- 1
src/i18n/en/itemTracing.json Wyświetl plik

@@ -286,12 +286,13 @@
"code.joStatus.storing": "Storing", "code.joStatus.storing": "Storing",
"code.joStatus.PARTIAL": "Partial", "code.joStatus.PARTIAL": "Partial",
"code.joStatus.partial": "Partial", "code.joStatus.partial": "Partial",
"code.productionStatus.Pass": "Pass",
"code.productionStatus.Pass": "Skip",
"code.productionStatus.Completed": "Completed", "code.productionStatus.Completed": "Completed",
"code.productionStatus.Pending": "Pending", "code.productionStatus.Pending": "Pending",
"code.productionStatus.Paused": "Paused", "code.productionStatus.Paused": "Paused",
"code.productionStatus.InProgress": "In progress", "code.productionStatus.InProgress": "In progress",
"code.productionStatus.Skip": "Skip", "code.productionStatus.Skip": "Skip",
"code.productionStatus.autoPass": "Auto Skipped",
"continuousScanBlocked": "Finish current scan first", "continuousScanBlocked": "Finish current scan first",
"nodeJoOut": "Job order material issue", "nodeJoOut": "Job order material issue",
"nodePoOut": "Purchase pick", "nodePoOut": "Purchase pick",


+ 3
- 1
src/i18n/en/jo.json Wyświetl plik

@@ -272,7 +272,9 @@
"Overview": "Overview", "Overview": "Overview",
"Packaging": "Packaging", "Packaging": "Packaging",
"Partial quantity submitted. Please submit more or complete the order.": "Partial quantity submitted. Please submit more or complete the order.", "Partial quantity submitted. Please submit more or complete the order.": "Partial quantity submitted. Please submit more or complete the order.",
"Pass": "Pass",
"Pass": "Skip",
"Just Pass": "Skip",
"Auto Pass": "Auto Skipped",
"Passed Step": "Passed Step", "Passed Step": "Passed Step",
"Pause": "Pause", "Pause": "Pause",
"Pause Reason": "Pause Reason", "Pause Reason": "Pause Reason",


+ 1
- 0
src/i18n/en/navigation.json Wyświetl plik

@@ -36,6 +36,7 @@
"nav.settings.user": "User", "nav.settings.user": "User",
"nav.settings.clientMonitor": "Device Connection Monitor", "nav.settings.clientMonitor": "Device Connection Monitor",
"nav.settings.items": "Items", "nav.settings.items": "Items",
"nav.settings.itemDefaultShelfLife": "Item default shelf life",
"nav.settings.equipment": "Equipment", "nav.settings.equipment": "Equipment",
"nav.settings.warehouse": "Warehouse", "nav.settings.warehouse": "Warehouse",
"nav.settings.printer": "Printer", "nav.settings.printer": "Printer",


+ 10
- 0
src/i18n/en/productionProcess.json Wyświetl plik

@@ -99,6 +99,14 @@
"Drink detail mode label": "Detail display", "Drink detail mode label": "Detail display",
"Drink detail mode: actual": "Actual production", "Drink detail mode: actual": "Actual production",
"Drink detail mode: planned": "Planned production", "Drink detail mode: planned": "Planned production",
"Drink detail mode: shipment": "Shipment qty",
"Shipment Order Qty": "Order qty",
"Shipped Qty": "Shipped today",
"Drink do status pending": "Pending",
"Drink do status receiving": "Released",
"Drink do status completed": "Completed",
"Expand delivery order details": "Expand delivery order details",
"Collapse delivery order details": "Collapse delivery order details",
"Planned Output Qty": "Planned output", "Planned Output Qty": "Planned output",
"Actual Output Qty": "Actual output", "Actual Output Qty": "Actual output",
"Latest Start By": "Latest start by", "Latest Start By": "Latest start by",
@@ -112,9 +120,11 @@
"QC users": "QC users", "QC users": "QC users",
"Put away users": "Put-away users", "Put away users": "Put-away users",
"Excel sheet: JO detail": "JO detail", "Excel sheet: JO detail": "JO detail",
"Excel sheet: DO detail": "DO detail",
"Excel sheet: process people": "Process people", "Excel sheet: process people": "Process people",
"Excel sheet: item summary": "Item summary", "Excel sheet: item summary": "Item summary",
"JO count": "JO count", "JO count": "JO count",
"DO count": "DO count",
"Process seq": "Process seq", "Process seq": "Process seq",
"Handler": "Handler", "Handler": "Handler",
"Goods Name": "Goods Name", "Goods Name": "Goods Name",


+ 327
- 2
src/i18n/en/report.json Wyświetl plik

@@ -1,13 +1,338 @@
{ {
"Report": "Report",
"title": "Report Management", "title": "Report Management",
"selectReport": "Select Report", "selectReport": "Select Report",
"reportList": "Report List", "reportList": "Report List",
"selectReportHelper": "Select a report", "selectReportHelper": "Select a report",
"searchCriteria": "Search Criteria", "searchCriteria": "Search Criteria",
"searchCriteriaWithTitle": "Search Criteria: {{title}}",
"downloadPdf": "Download Report (PDF)", "downloadPdf": "Download Report (PDF)",
"downloadExcel": "Download Report (Excel)", "downloadExcel": "Download Report (Excel)",
"generatingPdf": "Generating PDF...", "generatingPdf": "Generating PDF...",
"generatingExcel": "Generating Excel...", "generatingExcel": "Generating Excel...",
"generatingReport": "Generating report..."
"generatingReport": "Generating report...",
"generateError": "An error occurred while generating the report. Please try again.",
"noDataFound": "No data found",
"noDataFoundTitle": "No Data Found / 查無資料",
"noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.",
"ok": "OK",
"missingRequired": "Missing required fields:\n- {{fields}}",
"dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}",
"selectOrEnterItemCode": "Select or enter item code",
"cancel": "Cancel",
"confirmDownloadPdf": "Confirm download PDF",
"confirmDownloadExcel": "Confirm download Excel",
"semiFgConfirmTitle": "Selected item codes — FG / Semi-FG Production Analysis Report",
"semiFgConfirmHint": "Please confirm the selected item codes and their categories:",
"semiFgColItem": "Item code and name",
"semiFgColCategory": "Category",
"qcScopeHelpAll": "Export all QC inspection items (including temperature / humidity).",
"qcScopeHelpMeasurable": "Export temperature / humidity QC items only (same as the previous production default).",
"categories": {
"inventory": "Inventory Management",
"inbound-outbound": "Inbound / Outbound",
"production": "Production & Trends"
},
"options": {
"All": "All",
"all": "All",
"pending": "Pending",
"completed": "Approved",
"success": "Success",
"failed": "Failed",
"measurable": "Temperature & humidity only"
},
"reports": {
"rep-004": {
"title": "Stock-in Traceability Report",
"fields": {
"lastInDateStart": "Last In Date Start",
"lastInDateEnd": "Last In Date End",
"itemCode": "Item Code",
"storeId": "Floor",
"warehouse": "Warehouse",
"area": "Area",
"slot": "Slot",
"lotNo": "Lot No",
"poPrefix": "PP/PF"
},
"options": {
"storeId": {
"All": "All"
},
"poPrefix": {
"All": "All",
"PP": "PP",
"PF": "PF"
}
}
},
"rep-008": {
"title": "Finished Goods Delivery Report",
"fields": {
"lastOutDateStart": "Last Out Date Start",
"lastOutDateEnd": "Last Out Date End",
"year": "Year",
"itemCode": "Item Code"
}
},
"rep-012": {
"title": "Stock Take Report",
"fields": {
"stockTakeRoundId": "Stock Take Round (multi-select)",
"itemCode": "Item Code",
"store_id": "Warehouse Floor",
"status": "Status",
"type": "Type"
},
"options": {
"store_id": {
"All": "All"
},
"status": {
"All": "All",
"pending": "Pending",
"completed": "Approved"
},
"type": {
"All": "All",
"PP": "PP",
"PF": "PF",
"TOA": "TOA",
"工廠生產": "Factory production",
"倉存調整": "Inventory adjustment",
"期初存貨": "Opening inventory",
"採購入倉": "Purchase inbound",
"其他入倉": "Other inbound"
}
}
},
"rep-011": {
"title": "Stock Ledger Report",
"fields": {
"lastInDateStart": "Stock Date Start",
"lastInDateEnd": "Stock Date End",
"itemCode": "Item Code"
}
},
"rep-007": {
"title": "Stock Balance Report",
"fields": {
"stockDate": "Stock Date",
"itemCode": "Item Code"
}
},
"rep-014": {
"title": "PO Goods Receipt Report",
"fields": {
"receiptDateStart": "Receipt Date Start",
"receiptDateEnd": "Receipt Date End",
"itemCode": "Item Code"
}
},
"rep-009": {
"title": "Finished Goods Stock-out Traceability Report",
"fields": {
"lastOutDateStart": "Last Out Date Start",
"lastOutDateEnd": "Last Out Date End",
"itemCode": "Item Code",
"handler": "Handler"
}
},
"rep-010": {
"title": "Inventory QC Report",
"fields": {
"lastInDateStart": "QC Date Start",
"lastInDateEnd": "QC Date End",
"qcType": "QC Type",
"itemCode": "Item Code",
"qcItemScope": "QC Item Scope"
},
"options": {
"qcType": {
"all": "All",
"IQC": "IQC (Purchase)",
"EPQC": "EPQC (Job Order)"
},
"qcItemScope": {
"all": "All QC items",
"measurable": "Temperature & humidity only"
}
}
},
"rep-013": {
"title": "Material Stock-out Traceability Report",
"fields": {
"lastOutDateStart": "Last Out Date Start",
"lastOutDateEnd": "Last Out Date End",
"itemCode": "Item Code",
"handler": "Handler"
}
},
"rep-006": {
"title": "Stock Item Consumption Trend Report",
"fields": {
"lastOutDateStart": "Consumption Date Start",
"lastOutDateEnd": "Consumption Date End",
"year": "Year",
"stockCategory": "Category",
"itemCode": "Item Code"
}
},
"rep-005": {
"title": "FG / Semi-FG Production Analysis Report",
"fields": {
"lastOutDateStart": "Production Complete Date Start",
"lastOutDateEnd": "Production Complete Date End",
"year": "Year",
"stockCategory": "Category",
"itemCode": "Item Code"
}
},
"rep-015": {
"title": "M18 BOM Shop Sync History",
"fields": {
"syncDateStart": "Sync Date Start",
"syncDateEnd": "Sync Date End",
"finishedItemCode": "Finished Item Code",
"syncStatus": "Sync Status"
},
"options": {
"syncStatus": {
"all": "All",
"success": "Success",
"failed": "Failed"
}
}
},
"rep-016": {
"title": "FG Delivery Pick Compliance Report",
"fields": {
"dateStart": "Date",
"handler": "Handler",
"ticketNo": "Ticket No.",
"itemCode": "Item Code",
"storeId": "Floor"
}
},
"rep-017": {
"title": "Shop Order Replenishment Record",
"fields": {
"shopOrderDateStart": "Shop Order Date Start",
"shopOrderDateEnd": "Shop Order Date End",
"shopCode": "Shop Code"
}
},
"rep-018": {
"title": "Delivery Order vs Inventory UOM Mismatch Report",
"fields": {
"deliveryDate": "Estimated Arrival Date",
"storeId": "Delivery Order Floor"
},
"options": {
"storeId": {
"All": "All"
}
}
},
"rep-021": {
"title": "Stock Lot On-hand Report",
"fields": {
"itemCode": "Item Code",
"storeId": "Floor",
"warehouse": "Warehouse",
"area": "Area",
"slot": "Slot",
"stockTakeSectionDescription": "Stock Take Section",
"lotNo": "Lot No",
"lotOrigin": "Lot Origin"
},
"options": {
"storeId": {
"All": "All"
},
"stockTakeSectionDescription": {
"All": "All"
},
"lotOrigin": {
"All": "All",
"PP": "PP",
"PF": "PF",
"other": "Other"
}
}
}
},
"excel": {
"noData": "(No records in the selected range)",
"grn": {
"sheetDetail": "PO Goods Receipt",
"sheetListedPo": "Listed PO Amounts",
"noCompletedPo": "No completed PO lines in the selected range",
"categoryCurrencyTotal": "Currency total",
"categoryPo": "PO",
"poNo": "PO No.",
"deliveryNoteNo": "Delivery Note No.",
"receiptDate": "Receipt Date",
"itemCode": "Item Code",
"itemName": "Item Name",
"qty": "Qty",
"demandQty": "Demand Qty",
"uom": "UOM",
"supplierLotNo": "Supplier Lot No.",
"expiryDate": "Expiry Date",
"supplierCode": "Supplier Code",
"supplier": "Supplier",
"status": "Stock-in Status",
"unitPrice": "Unit Price",
"currency": "Currency",
"amount": "Amount",
"grnCode": "GRN Code / M18 Receipt No.",
"grnId": "GRN Id / M18 Record Id",
"poCreator": "PO creator (M18)",
"note": "Note",
"category": "Category",
"totalAmount": "Total Amount",
"grnCodes": "GRN Code(s) / M18 Receipt No."
},
"bomSync": {
"sheetSync": "BOM Sync Log",
"sheetMaterials": "BOM Material Lines",
"syncTime": "Sync Time",
"finishedItemCode": "Finished Item Code",
"finishedItemName": "Finished Item Name",
"bomRoutingCode": "BOM Routing Code",
"version": "Version",
"status": "Status",
"failureReason": "Failure Reason",
"message": "Message",
"lineNo": "Line No.",
"materialName": "Material Name",
"uom": "UOM",
"qty": "Qty",
"statusSuccess": "Success",
"statusSkipped": "Skipped (unchanged)",
"statusFailed": "Failed"
},
"shopReplenishment": {
"sheetName": "Shop Order Replenishment",
"shopCode": "Shop Code",
"shopName": "Shop Name",
"shopOrderDate": "Shop Order Date",
"shopOrderNo": "Shop Order No.",
"itemCode": "Item Code",
"itemName": "Item Name",
"firstOrderQty": "Original Order Qty",
"firstOrderActualPickQty": "Original Actual Pick Qty",
"firstOrderPicker": "Original Picker",
"reorderQty": "Replenish Qty",
"reorderDate": "Replenish Date",
"reason": "Replenish Reason",
"actualDeliveredQty": "Actual Replenish Qty",
"actualDeliveredHandler": "Actual Replenish Handler",
"deliveredDate": "Delivery Date",
"reasonQuality": "Quality issue",
"reasonOutOfStock": "Out of stock",
"reasonOther": "Other"
}
}
} }

+ 8
- 1
src/i18n/en/user.json Wyświetl plik

@@ -46,5 +46,12 @@
"Failed to search by name": "Failed to search by name", "Failed to search by name": "Failed to search by name",
"Failed to search by username": "Failed to search by username", "Failed to search by username": "Failed to search by username",
"Staff No is required": "Staff No is required", "Staff No is required": "Staff No is required",
"User Not Found": "User Not Found"
"User Not Found": "User Not Found",
"Username is already taken": "Username is already taken. Please choose another.",
"Name is already taken": "Name is already taken. Please choose another.",
"Staff No is already taken": "Staff No is already taken. Please choose another.",
"New password does not meet the rules": "New password does not meet the rules. Please try again.",
"Invalid request. Please check your input": "Invalid request. Please check your input.",
"Unauthorized or no permission": "Unauthorized or no permission.",
"Server error. Please try again later": "Server error. Please try again later."
} }

+ 12
- 8
src/i18n/index.tsx Wyświetl plik

@@ -2,12 +2,14 @@ import { cookies, headers } from "next/headers";
import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend"; import resourcesToBackend from "i18next-resources-to-backend";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/config/authConfig";
import { authOptions, SessionWithTokens } from "@/config/authConfig";
import I18nClientProvider from "./I18nClientProvider"; import I18nClientProvider from "./I18nClientProvider";
import universalLanguageDetect from "@unly/universal-language-detector"; import universalLanguageDetect from "@unly/universal-language-detector";

const FALLBACK_LANG = "zh";
const SUPPORTED_LANGUAGES = ["zh"];
import {
FALLBACK_LANG,
SUPPORTED_LANGUAGES,
normalizeAppLanguage,
} from "./locale";


export const detectLanguage = async (): Promise<string> => { export const detectLanguage = async (): Promise<string> => {
// Logic to get language preference from cookies/headers/session // Logic to get language preference from cookies/headers/session
@@ -21,11 +23,13 @@ export const detectLanguage = async (): Promise<string> => {
const headersList = headers(); const headersList = headers();
//console.time("[i18n] detectLanguage total"); //console.time("[i18n] detectLanguage total");
//console.time("[i18n] getServerSession"); //console.time("[i18n] getServerSession");
const session = await getServerSession(authOptions);
//console.timeEnd("[i18n] getServerSession");
//console.time("[i18n] universalLanguageDetect");
const session = (await getServerSession(authOptions)) as SessionWithTokens | null;
const fromSession = normalizeAppLanguage(session?.locale);
if (fromSession) {
return fromSession;
}
const lang = universalLanguageDetect({ const lang = universalLanguageDetect({
supportedLanguages: SUPPORTED_LANGUAGES,
supportedLanguages: [...SUPPORTED_LANGUAGES],
fallbackLanguage: FALLBACK_LANG, fallbackLanguage: FALLBACK_LANG,
acceptLanguageHeader: headersList.get("accept-language") || undefined, acceptLanguageHeader: headersList.get("accept-language") || undefined,
serverCookies: cookiesObj, serverCookies: cookiesObj,


+ 23
- 0
src/i18n/locale.ts Wyświetl plik

@@ -0,0 +1,23 @@
export const FALLBACK_LANG = "zh";
export const SUPPORTED_LANGUAGES = ["zh", "en"] as const;
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number];

/** Cookie name read by `@unly/universal-language-detector`. */
export const I18N_COOKIE_NAME = "i18next";

export function isAppLanguage(value: unknown): value is AppLanguage {
return value === "zh" || value === "en";
}

export function normalizeAppLanguage(value: unknown): AppLanguage | null {
if (typeof value !== "string") return null;
const lower = value.trim().toLowerCase();
if (lower === "zh" || lower.startsWith("zh-")) return "zh";
if (lower === "en" || lower.startsWith("en-")) return "en";
return null;
}

export function setLanguageCookie(lang: AppLanguage) {
if (typeof document === "undefined") return;
document.cookie = `${I18N_COOKIE_NAME}=${lang}; Path=/; SameSite=Lax; Max-Age=31536000`;
}

+ 3
- 1
src/i18n/zh/chart.json Wyświetl plik

@@ -7,6 +7,8 @@
"board_processLive": "工序即時看板", "board_processLive": "工序即時看板",
"dateRange_lastDays": "最近 {{d}} 天", "dateRange_lastDays": "最近 {{d}} 天",
"delivery_colAvgMin": "平均分鐘/單", "delivery_colAvgMin": "平均分鐘/單",
"delivery_colItemKindCount": "總揀貨款數",
"delivery_colItemQtyPicked": "揀貨數量",
"delivery_colPickCount": "揀單數", "delivery_colPickCount": "揀單數",
"delivery_colStaff": "員工", "delivery_colStaff": "員工",
"delivery_colTotalMin": "總分鐘", "delivery_colTotalMin": "總分鐘",
@@ -19,7 +21,7 @@
"delivery_ordersByDate": "按日期發貨單數量", "delivery_ordersByDate": "按日期發貨單數量",
"delivery_ordersByDate_export": "發貨單數量_按日期", "delivery_ordersByDate_export": "發貨單數量_按日期",
"delivery_staff": "員工", "delivery_staff": "員工",
"delivery_staffPerfCaption": "週期內每人揀單數及總耗時(首揀至完成)",
"delivery_staffPerfCaption": "週期內每人揀單數、總揀貨款數、揀貨數量及總耗時(首揀至完成)",
"delivery_staffPerfDateError": "員工發貨績效的起始日期不能晚於結束日期", "delivery_staffPerfDateError": "員工發貨績效的起始日期不能晚於結束日期",
"delivery_staffPerformanceTitle": "員工發貨績效(每日揀貨數量與耗時)", "delivery_staffPerformanceTitle": "員工發貨績效(每日揀貨數量與耗時)",
"delivery_staffPlaceholder": "不選則全部", "delivery_staffPlaceholder": "不選則全部",


+ 1
- 0
src/i18n/zh/common.json Wyświetl plik

@@ -90,6 +90,7 @@
"Select Date": "選擇日期", "Select Date": "選擇日期",
"Session expired or unauthorized.": "工作階段已過期或未經授權。", "Session expired or unauthorized.": "工作階段已過期或未經授權。",
"Sign out": "登出", "Sign out": "登出",
"Language": "語言",
"Status": "狀態", "Status": "狀態",
"Stock Qty": "庫存數量", "Stock Qty": "庫存數量",
"Supporting Document": "證明文件", "Supporting Document": "證明文件",


+ 38
- 0
src/i18n/zh/itemDefaultShelfLife.json Wyświetl plik

@@ -0,0 +1,38 @@
{
"title": "物品預設保質期",
"Intro": "設定各貨品編號的預設保質期,供打袋機/OnPack 列印到期日使用。勾選「列印使用 -18」時,到期日會用 -18 天數,否則用冷藏天數。",
"Search placeholder": "搜尋貨品編號、名稱或備註",
"Add": "新增",
"Edit": "編輯",
"Delete": "刪除",
"Save": "儲存",
"Saving": "儲存中",
"Cancel": "取消",
"Saved": "已儲存",
"Deleted": "已刪除",
"Add title": "新增保質期",
"Edit title": "編輯保質期",
"Delete title": "刪除保質期",
"Delete confirm": "確定刪除 {{itemCode}} 的預設保質期?列印將不再帶出此貨品的到期日。",
"Col itemCode": "貨品編號",
"Col itemName": "物品名稱",
"Col defaultDays": "冷藏天數",
"Col minus18Days": "-18 天數",
"Col useMinus18": "列印使用 -18",
"Col effectiveDays": "列印天數",
"Col openedDays": "開封後天數",
"Col storageC": "儲存溫度",
"Col remarks": "備註",
"Col actions": "操作",
"Empty": "尚無資料。請按「新增」加入貨品保質期。",
"No match": "沒有符合搜尋條件的資料。",
"Showing": "顯示 {{from}}–{{to}}/共 {{total}} 筆",
"Item code required": "請輸入貨品編號。",
"Days invalid": "天數必須為 0 或正整數。",
"Use minus18": "列印使用 -18 天數",
"Use minus18 help": "勾選後,打袋機/OnPack 到期日使用 -18 天數;未勾選則使用冷藏天數。",
"Expiry preview": "今日列印到期日:{{date}}",
"Expiry preview none": "今日列印到期日:無法計算(所選天數未填或不大於 0)",
"Yes": "是",
"No": "否"
}

+ 2
- 1
src/i18n/zh/itemTracing.json Wyświetl plik

@@ -286,12 +286,13 @@
"code.joStatus.storing": "待QC上架", "code.joStatus.storing": "待QC上架",
"code.joStatus.PARTIAL": "部分完成", "code.joStatus.PARTIAL": "部分完成",
"code.joStatus.partial": "部分完成", "code.joStatus.partial": "部分完成",
"code.productionStatus.Pass": "過",
"code.productionStatus.Pass": "過",
"code.productionStatus.Completed": "完成", "code.productionStatus.Completed": "完成",
"code.productionStatus.Pending": "待處理", "code.productionStatus.Pending": "待處理",
"code.productionStatus.Paused": "已暫停", "code.productionStatus.Paused": "已暫停",
"code.productionStatus.InProgress": "進行中", "code.productionStatus.InProgress": "進行中",
"code.productionStatus.Skip": "跳過", "code.productionStatus.Skip": "跳過",
"code.productionStatus.autoPass": "已自動跳過",
"continuousScanBlocked": "請先完成目前掃描", "continuousScanBlocked": "請先完成目前掃描",
"nodeJoOut": "工單提料", "nodeJoOut": "工單提料",
"nodePoOut": "採購提料", "nodePoOut": "採購提料",


+ 3
- 2
src/i18n/zh/jo.json Wyświetl plik

@@ -10,7 +10,8 @@
"Actual Pick Qty": "實際提料數量", "Actual Pick Qty": "實際提料數量",
"Add Bag": "新增包裝袋", "Add Bag": "新增包裝袋",
"Add Record": "添加記錄", "Add Record": "添加記錄",
"Just Pass": "通過",
"Just Pass": "跳過",
"Auto Pass": "已自動跳過",
"Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中",
"Add some entries!": "請添加條目", "Add some entries!": "請添加條目",
"All": "全部", "All": "全部",
@@ -283,7 +284,7 @@
"Overview": "總覽", "Overview": "總覽",
"Packaging": "提料中", "Packaging": "提料中",
"Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。", "Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。",
"Pass": "過",
"Pass": "過",
"Passed Step": "通過步驟", "Passed Step": "通過步驟",
"Pause": "暫停", "Pause": "暫停",
"Pause Reason": "暫停原因", "Pause Reason": "暫停原因",


+ 1
- 0
src/i18n/zh/navigation.json Wyświetl plik

@@ -79,6 +79,7 @@
"nav.settings.importExcel": "Excel 匯入", "nav.settings.importExcel": "Excel 匯入",
"nav.settings.importTesting": "匯入測試", "nav.settings.importTesting": "匯入測試",
"nav.settings.items": "物品", "nav.settings.items": "物品",
"nav.settings.itemDefaultShelfLife": "物品預設保質期",
"nav.settings.masterDataIssues": "BOM / 物料單位問題", "nav.settings.masterDataIssues": "BOM / 物料單位問題",
"nav.settings.priceInquiry": "價格查詢", "nav.settings.priceInquiry": "價格查詢",
"nav.settings.printer": "列印機", "nav.settings.printer": "列印機",


+ 10
- 0
src/i18n/zh/productionProcess.json Wyświetl plik

@@ -104,6 +104,14 @@
"Drink detail mode label": "明細顯示", "Drink detail mode label": "明細顯示",
"Drink detail mode: actual": "實際生產", "Drink detail mode: actual": "實際生產",
"Drink detail mode: planned": "預計生產", "Drink detail mode: planned": "預計生產",
"Drink detail mode: shipment": "出貨數量",
"Shipment Order Qty": "預期出貨數量",
"Shipped Qty": "當前出貨數量",
"Drink do status pending": "待處理",
"Drink do status receiving": "已放單",
"Drink do status completed": "已完成",
"Expand delivery order details": "展開出貨明細",
"Collapse delivery order details": "收合出貨明細",
"Planned Output Qty": "預計生產數量", "Planned Output Qty": "預計生產數量",
"Actual Output Qty": "實際生產數量", "Actual Output Qty": "實際生產數量",
"Latest Start By": "最晚開工時間", "Latest Start By": "最晚開工時間",
@@ -117,9 +125,11 @@
"QC users": "QC人員", "QC users": "QC人員",
"Put away users": "上架人員", "Put away users": "上架人員",
"Excel sheet: JO detail": "JO明細", "Excel sheet: JO detail": "JO明細",
"Excel sheet: DO detail": "DO明細",
"Excel sheet: process people": "工序人員", "Excel sheet: process people": "工序人員",
"Excel sheet: item summary": "貨品彙總", "Excel sheet: item summary": "貨品彙總",
"JO count": "工單筆數", "JO count": "工單筆數",
"DO count": "送貨單筆數",
"Process seq": "工序序號", "Process seq": "工序序號",
"Handler": "處理人", "Handler": "處理人",
"Goods Name": "貨品名稱", "Goods Name": "貨品名稱",


+ 327
- 2
src/i18n/zh/report.json Wyświetl plik

@@ -1,13 +1,338 @@
{ {
"Report": "報告",
"title": "報告管理", "title": "報告管理",
"selectReport": "選擇報告", "selectReport": "選擇報告",
"reportList": "報告列表", "reportList": "報告列表",
"selectReportHelper": "選擇報告", "selectReportHelper": "選擇報告",
"searchCriteria": "搜索條件", "searchCriteria": "搜索條件",
"searchCriteriaWithTitle": "搜索條件: {{title}}",
"downloadPdf": "下載報告 (PDF)", "downloadPdf": "下載報告 (PDF)",
"downloadExcel": "下載報告 (Excel)", "downloadExcel": "下載報告 (Excel)",
"generatingPdf": "生成 PDF...", "generatingPdf": "生成 PDF...",
"generatingExcel": "生成 Excel...", "generatingExcel": "生成 Excel...",
"generatingReport": "生成報告..."
"generatingReport": "生成報告...",
"generateError": "產生報告時發生錯誤,請再試一次。",
"noDataFound": "查無資料",
"noDataFoundTitle": "查無資料",
"noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。",
"ok": "確定",
"missingRequired": "缺少必填條件:\n- {{fields}}",
"dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}",
"selectOrEnterItemCode": "選擇或輸入物料編號",
"cancel": "取消",
"confirmDownloadPdf": "確認下載 PDF",
"confirmDownloadExcel": "確認下載 Excel",
"semiFgConfirmTitle": "已選擇的物料編號以及列印成品/半成品生產分析報告",
"semiFgConfirmHint": "請確認以下已選擇的物料編號及其類別:",
"semiFgColItem": "物料編號及名稱",
"semiFgColCategory": "類別",
"qcScopeHelpAll": "匯出全部 QC 檢驗項目(含溫度/濕度)。",
"qcScopeHelpMeasurable": "僅匯出溫度/濕度 QC 項目(與 production 舊預設相同)。",
"categories": {
"inventory": "庫存管理",
"inbound-outbound": "出入倉作業",
"production": "生產與趨勢"
},
"options": {
"All": "全部",
"all": "全部",
"pending": "待盤點",
"completed": "已審核",
"success": "成功",
"failed": "失敗",
"measurable": "只包含溫度濕度"
},
"reports": {
"rep-004": {
"title": "入倉追蹤報告",
"fields": {
"lastInDateStart": "入倉日期:由",
"lastInDateEnd": "入倉日期:至",
"itemCode": "貨品編號",
"storeId": "樓層",
"warehouse": "倉庫",
"area": "區域",
"slot": "儲位",
"lotNo": "批號",
"poPrefix": "PP/PF 分類"
},
"options": {
"storeId": {
"All": "全部"
},
"poPrefix": {
"All": "全部",
"PP": "PP",
"PF": "PF"
}
}
},
"rep-008": {
"title": "成品出倉報告",
"fields": {
"lastOutDateStart": "出貨日期:由",
"lastOutDateEnd": "出貨日期:至",
"year": "年份",
"itemCode": "貨品編號"
}
},
"rep-012": {
"title": "庫存盤點報告",
"fields": {
"stockTakeRoundId": "盤點輪次(可多選)",
"itemCode": "貨品編號",
"store_id": "倉庫樓層",
"status": "狀態",
"type": "類型"
},
"options": {
"store_id": {
"All": "全部"
},
"status": {
"All": "全部",
"pending": "待盤點",
"completed": "已審核"
},
"type": {
"All": "全部",
"PP": "PP",
"PF": "PF",
"TOA": "TOA",
"工廠生產": "工廠生產",
"倉存調整": "倉存調整",
"期初存貨": "期初存貨",
"採購入倉": "採購入倉",
"其他入倉": "其他入倉"
}
}
},
"rep-011": {
"title": "庫存明細報告",
"fields": {
"lastInDateStart": "庫存日期:由",
"lastInDateEnd": "庫存日期:至",
"itemCode": "貨品編號"
}
},
"rep-007": {
"title": "庫存結餘報告",
"fields": {
"stockDate": "庫存日期",
"itemCode": "貨品編號"
}
},
"rep-014": {
"title": "PO入倉記錄報告",
"fields": {
"receiptDateStart": "收貨日期:由",
"receiptDateEnd": "收貨日期:至",
"itemCode": "貨品編號"
}
},
"rep-009": {
"title": "成品出倉追蹤報告",
"fields": {
"lastOutDateStart": "出貨日期:由",
"lastOutDateEnd": "出貨日期:至",
"itemCode": "貨品編號",
"handler": "提料員"
}
},
"rep-010": {
"title": "庫存品質檢測報告",
"fields": {
"lastInDateStart": "QC 檢測日期:由",
"lastInDateEnd": "QC 檢測日期:至",
"qcType": "QC 類型",
"itemCode": "貨品編號",
"qcItemScope": "QC 項目範圍"
},
"options": {
"qcType": {
"all": "全部",
"IQC": "IQC(採購)",
"EPQC": "EPQC(工單)"
},
"qcItemScope": {
"all": "全部 QC 項目",
"measurable": "只包含溫度濕度"
}
}
},
"rep-013": {
"title": "貨品出倉追蹤報告",
"fields": {
"lastOutDateStart": "出倉日期:由",
"lastOutDateEnd": "出倉日期:至",
"itemCode": "貨品編號",
"handler": "提料人"
}
},
"rep-006": {
"title": "庫存材料消耗趨勢報告",
"fields": {
"lastOutDateStart": "材料消耗日期:由",
"lastOutDateEnd": "材料消耗日期:至",
"year": "年份",
"stockCategory": "類別",
"itemCode": "貨品編號"
}
},
"rep-005": {
"title": "成品/半成品生產分析報告",
"fields": {
"lastOutDateStart": "完成生產日期:由",
"lastOutDateEnd": "完成生產日期:至",
"year": "年份",
"stockCategory": "類別",
"itemCode": "貨品編號"
}
},
"rep-015": {
"title": "M18 BOM Shop 同步記錄",
"fields": {
"syncDateStart": "同步日期:由",
"syncDateEnd": "同步日期:至",
"finishedItemCode": "成品貨號",
"syncStatus": "同步狀態"
},
"options": {
"syncStatus": {
"all": "全部",
"success": "成功",
"failed": "失敗"
}
}
},
"rep-016": {
"title": "成品出倉揀貨合規報告",
"fields": {
"dateStart": "日期",
"handler": "提料人",
"ticketNo": "提票號碼",
"itemCode": "貨品編號",
"storeId": "樓層"
}
},
"rep-017": {
"title": "店鋪訂單補貨記錄",
"fields": {
"shopOrderDateStart": "店鋪訂單日期:由",
"shopOrderDateEnd": "店鋪訂單日期:至",
"shopCode": "店鋪編號"
}
},
"rep-018": {
"title": "送貨訂單與倉存單位不符報告",
"fields": {
"deliveryDate": "預計送貨日期",
"storeId": "送貨訂單樓層"
},
"options": {
"storeId": {
"All": "全部"
}
}
},
"rep-021": {
"title": "庫存批次現況報告",
"fields": {
"itemCode": "貨品編號",
"storeId": "樓層",
"warehouse": "倉庫",
"area": "區域",
"slot": "儲位",
"stockTakeSectionDescription": "盤點區域說明",
"lotNo": "批號",
"lotOrigin": "來源"
},
"options": {
"storeId": {
"All": "全部"
},
"stockTakeSectionDescription": {
"All": "全部"
},
"lotOrigin": {
"All": "全部",
"PP": "PP",
"PF": "PF",
"other": "其他"
}
}
}
},
"excel": {
"noData": "(篩選範圍內無資料)",
"grn": {
"sheetDetail": "PO入倉記錄",
"sheetListedPo": "已上架PO金額",
"noCompletedPo": "(篩選範圍內無已完成之 PO 行)",
"categoryCurrencyTotal": "貨幣小計",
"categoryPo": "訂單",
"poNo": "訂單編號",
"deliveryNoteNo": "送貨單編號",
"receiptDate": "收貨日期",
"itemCode": "物料編號",
"itemName": "物料名稱",
"qty": "數量",
"demandQty": "訂單數量",
"uom": "單位",
"supplierLotNo": "供應商批次",
"expiryDate": "到期日",
"supplierCode": "供應商編號",
"supplier": "供應商",
"status": "入倉狀態",
"unitPrice": "單價",
"currency": "貨幣",
"amount": "金額",
"grnCode": "M18 入倉單號",
"grnId": "M18 記錄編號",
"poCreator": "PO建立者(M18)",
"note": "備註",
"category": "類別",
"totalAmount": "金額",
"grnCodes": "M18 入倉單號"
},
"bomSync": {
"sheetSync": "BOM同步記錄",
"sheetMaterials": "BOM物料明細",
"syncTime": "同步時間",
"finishedItemCode": "成品貨號",
"finishedItemName": "成品名稱",
"bomRoutingCode": "BOM路由編號",
"version": "版本",
"status": "狀態",
"failureReason": "失敗原因",
"message": "訊息",
"lineNo": "行號",
"materialName": "物料名稱",
"uom": "單位",
"qty": "用量",
"statusSuccess": "成功",
"statusSkipped": "略過(內容未變)",
"statusFailed": "失敗"
},
"shopReplenishment": {
"sheetName": "店鋪訂單補貨記錄",
"shopCode": "店鋪編號",
"shopName": "店鋪名稱",
"shopOrderDate": "店鋪訂單日期",
"shopOrderNo": "店鋪訂單編號",
"itemCode": "貨品編號",
"itemName": "貨品名稱",
"firstOrderQty": "原訂單數量",
"firstOrderActualPickQty": "原單實際提料數量",
"firstOrderPicker": "原單提料人",
"reorderQty": "補貨數量",
"reorderDate": "補貨日期",
"reason": "補貨原因",
"actualDeliveredQty": "實際補貨數量",
"actualDeliveredHandler": "實際補貨提料人",
"deliveredDate": "送貨日期",
"reasonQuality": "質素問題",
"reasonOutOfStock": "缺貨",
"reasonOther": "其他"
}
}
} }

+ 8
- 1
src/i18n/zh/user.json Wyświetl plik

@@ -46,5 +46,12 @@
"Failed to search by name": "依名稱搜尋失敗", "Failed to search by name": "依名稱搜尋失敗",
"Failed to search by username": "依使用者名稱搜尋失敗", "Failed to search by username": "依使用者名稱搜尋失敗",
"Staff No is required": "員工編號必填", "Staff No is required": "員工編號必填",
"User Not Found": "用戶不存在"
"User Not Found": "用戶不存在",
"Username is already taken": "用戶名稱已被使用,請換一個。",
"Name is already taken": "姓名已被使用,請換一個。",
"Staff No is already taken": "員工編號已被使用,請換一個。",
"New password does not meet the rules": "新密碼不符合規則,請重新輸入。",
"Invalid request. Please check your input": "請求資料不正確,請檢查後再試。",
"Unauthorized or no permission": "未授權或沒有權限。",
"Server error. Please try again later": "伺服器錯誤,請稍後再試。"
} }

Ładowanie…
Anuluj
Zapisz