diff --git a/scripts/update-chart-i18n.js b/scripts/update-chart-i18n.js index 05fa7c08..2d915d52 100644 --- a/scripts/update-chart-i18n.js +++ b/scripts/update-chart-i18n.js @@ -42,9 +42,11 @@ const en = { "delivery_store": "Store", "delivery_staff": "Staff", "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_colPickCount": "Pick Count", + "delivery_colItemKindCount": "Item Kind Count", + "delivery_colItemQtyPicked": "Item Qty Picked", "delivery_colTotalMin": "Total Min", "delivery_colAvgMin": "Avg Min/Order", "delivery_dailyByStaff": "Daily by Staff", @@ -167,9 +169,11 @@ const zh = { "delivery_store": "倉別", "delivery_staff": "員工", "delivery_staffPlaceholder": "不選則全部", - "delivery_staffPerfCaption": "週期內每人揀單數及總耗時(首揀至完成)", + "delivery_staffPerfCaption": "週期內每人揀單數、品項數、揀貨數量及總耗時(首揀至完成)", "delivery_colStaff": "員工", "delivery_colPickCount": "揀單數", + "delivery_colItemKindCount": "品項數", + "delivery_colItemQtyPicked": "揀貨數量", "delivery_colTotalMin": "總分鐘", "delivery_colAvgMin": "平均分鐘/單", "delivery_dailyByStaff": "每日按員工單數", diff --git a/src/app/(main)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx index c8ba2a22..bbbf3bb8 100644 --- a/src/app/(main)/chart/delivery/page.tsx +++ b/src/app/(main)/chart/delivery/page.tsx @@ -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() { const [criteria, setCriteria] = useState(defaultCriteria); const [topItemsSelected, setTopItemsSelected] = useState([]); @@ -76,7 +77,14 @@ export default function DeliveryChartPage() { const [chartData, setChartData] = useState<{ delivery: { date: string; orderCount: number; 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: [] }); const [loadingCharts, setLoadingCharts] = useState>({}); @@ -146,6 +154,8 @@ export default function DeliveryChartPage() { staffName: string; orderCount: number; totalMinutes: number; + itemKindCount: number; + itemQtyPicked: number; }[], })) ) @@ -164,18 +174,30 @@ export default function DeliveryChartPage() { }, [criteria.topItems.rangeDays]); const staffPerfByStaff = useMemo(() => { - const map = new Map(); + const map = new Map< + string, + { orderCount: number; totalMinutes: number; itemKindCount: number; itemQtyPicked: number } + >(); for (const r of chartData.staffPerf) { 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, { orderCount: cur.orderCount + r.orderCount, totalMinutes: cur.totalMinutes + r.totalMinutes, + itemKindCount: cur.itemKindCount + r.itemKindCount, + itemQtyPicked: cur.itemQtyPicked + r.itemQtyPicked, }); } return Array.from(map.entries()).map(([staffName, v]) => ({ staffName, orderCount: v.orderCount, + itemKindCount: v.itemKindCount, + itemQtyPicked: v.itemQtyPicked, totalMinutes: v.totalMinutes, avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0, })); @@ -298,7 +320,14 @@ export default function DeliveryChartPage() { ({ 日期: 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={ <> - 週期內每人揀單數及總耗時(首揀至完成) + 週期內每人揀單數、總揀貨款數、總揀貨件數及總耗時(首揀至完成) 員工 揀單數 + 總揀貨款數 + 總揀貨件數 總分鐘 平均分鐘/單 @@ -416,13 +447,15 @@ export default function DeliveryChartPage() { {staffPerfByStaff.length === 0 ? ( - 無數據 + 無數據 ) : ( staffPerfByStaff.map((row) => ( {row.staffName} {row.orderCount} + {row.itemKindCount} + {row.itemQtyPicked} {row.totalMinutes} {row.avgMinutesPerOrder} diff --git a/src/app/(main)/m18Syn/layout.tsx b/src/app/(main)/m18Syn/layout.tsx index ecbed38e..32b50acb 100644 --- a/src/app/(main)/m18Syn/layout.tsx +++ b/src/app/(main)/m18Syn/layout.tsx @@ -1,10 +1,24 @@ 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: 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 ( {children} diff --git a/src/app/(main)/m18Syn/page.tsx b/src/app/(main)/m18Syn/page.tsx index d935a6d6..df97ff33 100644 --- a/src/app/(main)/m18Syn/page.tsx +++ b/src/app/(main)/m18Syn/page.tsx @@ -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() { const [tabValue, setTabValue] = useState(0); @@ -270,7 +271,7 @@ export default function M18SynPage() { M18 Sync (by code) - 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. setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> diff --git a/src/app/(main)/production/page.tsx b/src/app/(main)/production/page.tsx index 57081100..91f3a2fe 100644 --- a/src/app/(main)/production/page.tsx +++ b/src/app/(main)/production/page.tsx @@ -38,7 +38,7 @@ const production: React.FC = async () => { {t("Create Process")} */} - + {/* Use new component */} diff --git a/src/app/(main)/productionProcess/page.tsx b/src/app/(main)/productionProcess/page.tsx index 94dbf7f9..6e7b793d 100644 --- a/src/app/(main)/productionProcess/page.tsx +++ b/src/app/(main)/productionProcess/page.tsx @@ -36,7 +36,7 @@ const productionProcess: React.FC = async () => { {t("Create Process")} */} - + }> diff --git a/src/app/(main)/report/ItemQcReportFilters.tsx b/src/app/(main)/report/ItemQcReportFilters.tsx index c3838352..c50650a3 100644 --- a/src/app/(main)/report/ItemQcReportFilters.tsx +++ b/src/app/(main)/report/ItemQcReportFilters.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { useTranslation } from "react-i18next"; import { FormHelperText, Grid, @@ -8,10 +9,10 @@ import { TextField, } from "@mui/material"; -export type QcItemFilter = "measurable" | "non_measurable"; +export type QcItemFilter = "all" | "measurable"; export const REP010_DEFAULT_CRITERIA: Record = { - qcItemFilter: "measurable", + qcItemScope: "all", }; interface ItemQcReportFiltersProps { @@ -21,18 +22,24 @@ interface ItemQcReportFiltersProps { const gridSize = { xs: 12, sm: 6 }; +/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ export default function ItemQcReportFilters({ criteria, onFieldChange, }: 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 ( onFieldChange("lastInDateStart", e.target.value)} @@ -42,7 +49,7 @@ export default function ItemQcReportFilters({ onFieldChange("lastInDateEnd", e.target.value)} @@ -53,19 +60,19 @@ export default function ItemQcReportFilters({ onFieldChange("qcType", e.target.value)} > - 全部 - IQC - EPQC + {opt("qcType", "all", "全部")} + {opt("qcType", "IQC", "IQC(採購)")} + {opt("qcType", "EPQC", "EPQC(工單)")} onFieldChange("itemCode", e.target.value)} placeholder="e.g. MJ0364" @@ -75,17 +82,17 @@ export default function ItemQcReportFilters({ onFieldChange("qcItemFilter", e.target.value)} + label={field("qcItemScope", "QC 項目範圍")} + value={qcItemFilter === "measurable" ? "measurable" : "all"} + onChange={(e) => onFieldChange("qcItemScope", e.target.value)} > - 只包含溫度濕度 - 不包含溫度濕度 + {opt("qcItemScope", "all", "全部 QC 項目")} + {opt("qcItemScope", "measurable", "只包含溫度濕度")} {qcItemFilter === "measurable" - ? "僅匯出已填寫實測值的溫度/濕度 QC 項目。" - : "僅匯出非溫度/濕度之其他 QC 檢驗項目。"} + ? t("qcScopeHelpMeasurable") + : t("qcScopeHelpAll")} diff --git a/src/app/(main)/report/ReportSelectionDashboard.tsx b/src/app/(main)/report/ReportSelectionDashboard.tsx index bc256f73..50f5331a 100644 --- a/src/app/(main)/report/ReportSelectionDashboard.tsx +++ b/src/app/(main)/report/ReportSelectionDashboard.tsx @@ -19,17 +19,22 @@ import PieChartOutlineOutlinedIcon from "@mui/icons-material/PieChartOutlineOutl import type { SvgIconComponent } from "@mui/icons-material"; import { REPORTS } from "@/config/reportConfig"; import { REPORT_CATEGORIES, type ReportCategoryConfig } from "./reportCategories"; +import { useReportLabels } from "./reportI18n"; const REPORT_ICON_MAP: Record = { "rep-011": Inventory2OutlinedIcon, "rep-007": MonetizationOnOutlinedIcon, "rep-012": LayersOutlinedIcon, + "rep-021": Inventory2OutlinedIcon, "rep-010": SearchOutlinedIcon, "rep-004": LocalShippingOutlinedIcon, "rep-014": LocalShippingOutlinedIcon, "rep-008": OutboundOutlinedIcon, "rep-009": OutboundOutlinedIcon, "rep-013": LocalShippingOutlinedIcon, + "rep-016": OutboundOutlinedIcon, + "rep-017": LocalShippingOutlinedIcon, + "rep-018": SearchOutlinedIcon, "rep-006": BarChartOutlinedIcon, "rep-005": PieChartOutlineOutlinedIcon, "rep-015": LayersOutlinedIcon, @@ -113,6 +118,7 @@ function CategoryColumn({ selectedReportId: string; onSelectReport: (reportId: string) => void; }) { + const { reportTitle, categoryTitle } = useReportLabels(); const reports = category.reportIds .map((id) => reportById[id]) .filter(Boolean); @@ -137,7 +143,7 @@ function CategoryColumn({ }} > - {category.title} + {categoryTitle(category.id, category.title)} 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({ selectedReportId, onSelectReport, diff --git a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx index 1bc91169..5592a93c 100644 --- a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx +++ b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect } from 'react'; +import { useTranslation } from "react-i18next"; import { Dialog, DialogTitle, @@ -43,6 +44,7 @@ export default function SemiFGProductionAnalysisReport({ reportTitle = '成品/半成品生產分析報告', onExportSuccess, }: SemiFGProductionAnalysisReportProps) { + const { t } = useTranslation("report"); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState([]); const [itemCodesWithCategory, setItemCodesWithCategory] = useState>({}); @@ -70,7 +72,7 @@ export default function SemiFGProductionAnalysisReport({ setExportFormat(format); // Validate required fields if (requiredFieldLabels.length > 0) { - alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`); + alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') })); return; } @@ -107,7 +109,7 @@ export default function SemiFGProductionAnalysisReport({ setShowConfirmDialog(false); } catch (error) { console.error('Failed to generate report:', error); - alert('An error occurred while generating the report. Please try again.'); + alert(t('generateError')); } finally { setLoading(false); } @@ -124,7 +126,7 @@ export default function SemiFGProductionAnalysisReport({ disabled={loading} sx={{ px: 4 }} > - {loading ? '生成 PDF...' : '下載報告 (PDF)'} + {loading ? t('generatingPdf') : t('downloadPdf')} @@ -147,22 +149,22 @@ export default function SemiFGProductionAnalysisReport({ > - 已選擇的物料編號以及列印成品/半成品生產分析報告 + {t('semiFgConfirmTitle')} - 請確認以下已選擇的物料編號及其類別: + {t('semiFgConfirmHint')} - 物料編號及名稱 + {t('semiFgColItem')} - 類別 + {t('semiFgColCategory')} @@ -187,7 +189,7 @@ export default function SemiFGProductionAnalysisReport({ - + diff --git a/src/app/(main)/report/bomShopSyncReportApi.ts b/src/app/(main)/report/bomShopSyncReportApi.ts index 2af5688b..08b31c48 100644 --- a/src/app/(main)/report/bomShopSyncReportApi.ts +++ b/src/app/(main)/report/bomShopSyncReportApi.ts @@ -5,6 +5,8 @@ import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { exportMultiSheetToXlsx, } from "@/app/(main)/chart/_components/exportChartToXlsx"; +import { reportExcelT as tx } from "./reportI18n"; +import type { TFunction } from "i18next"; export interface BomShopSyncReportSummary { totalAttempts?: number; @@ -55,43 +57,64 @@ export interface BomShopSyncReportResponse { 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 { +function emptySyncSheetRow( + L: ReturnType, + note?: string, +): Record { return { - 同步時間: note, - 成品貨號: "", - 成品名稱: "", - BOM路由編號: "", + [L.syncTime]: note ?? L.noData, + [L.finishedItemCode]: "", + [L.finishedItemName]: "", + [L.bomRoutingCode]: "", "M18 BOM Code": "", - 版本: "", + [L.version]: "", "M18 Record Id": "", - 狀態: "", - 失敗原因: "", - 訊息: "", + [L.status]: "", + [L.failureReason]: "", + [L.message]: "", "BOM 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 { +function emptyMaterialSheetRow( + L: ReturnType, + note?: string, +): Record { return { - 同步時間: note, - 成品貨號: "", + [L.syncTime]: note ?? L.noData, + [L.finishedItemCode]: "", "M18 BOM Code": "", - 版本: "", - 狀態: "", - 行號: "", - 物料名稱: "", + [L.version]: "", + [L.status]: "", + [L.lineNo]: "", + [L.materialName]: "", "M18 Product Id": "", - 單位: "", - 用量: "", + [L.uom]: "", + [L.qty]: "", "M18 Supplier Id": "", "M18 Purchase Unit Id": "", "Sync Log Id": "", @@ -117,52 +140,59 @@ export async function fetchBomShopSyncReportData( return (await response.json()) as BomShopSyncReportResponse; } -function syncStatusLabel(status: string | undefined): string { +function syncStatusLabel( + status: string | undefined, + L: ReturnType, +): string { switch (status) { case "SUCCESS": - return "成功"; + return L.statusSuccess; case "SKIPPED_UNCHANGED": - return "略過(內容未變)"; + return L.statusSkipped; case "FAILED": - return "失敗"; + return L.statusFailed; default: return status ?? ""; } } -function toSyncExcelRow(r: BomShopSyncRow): Record { - const base = emptySyncSheetRow(""); +function toSyncExcelRow( + r: BomShopSyncRow, + L: ReturnType, +): Record { 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 ?? "", - 版本: r.version ?? "", + [L.version]: r.version ?? "", "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 ?? "", "Sync Log Id": r.syncLogId ?? "", }; } -function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record { - const base = emptyMaterialSheetRow(""); +function toMaterialExcelRow( + r: BomShopSyncMaterialRow, + L: ReturnType, +): Record { return { - ...base, - 同步時間: r.syncDateTime ?? "", - 成品貨號: r.finishedItemCode ?? "", + ...emptyMaterialSheetRow(L, ""), + [L.syncTime]: r.syncDateTime ?? "", + [L.finishedItemCode]: r.finishedItemCode ?? "", "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 ?? "", - 單位: r.udfBaseUnit ?? "", - 用量: r.udfQty ?? "", + [L.uom]: r.udfBaseUnit ?? "", + [L.qty]: r.udfQty ?? "", "M18 Supplier Id": r.udfSupplierM18Id ?? "", "M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "", "Sync Log Id": r.syncLogId ?? "", @@ -172,16 +202,18 @@ function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record export async function generateBomShopSyncReportExcel( criteria: Record, reportTitle: string = "M18 BOM Shop 同步記錄", + t?: TFunction, ): Promise { + const L = bomSyncLabels(t); const data = await fetchBomShopSyncReportData(criteria); const syncRows = (data.syncRows ?? []).length > 0 - ? (data.syncRows ?? []).map(toSyncExcelRow) - : [emptySyncSheetRow()]; + ? (data.syncRows ?? []).map((r) => toSyncExcelRow(r, L)) + : [emptySyncSheetRow(L)]; const materialRows = (data.materialRows ?? []).length > 0 - ? (data.materialRows ?? []).map(toMaterialExcelRow) - : [emptyMaterialSheetRow()]; + ? (data.materialRows ?? []).map((r) => toMaterialExcelRow(r, L)) + : [emptyMaterialSheetRow(L)]; const start = criteria.syncDateStart; const end = criteria.syncDateEnd; @@ -197,8 +229,8 @@ export async function generateBomShopSyncReportExcel( exportMultiSheetToXlsx( [ - { name: SHEET_SYNC, rows: syncRows }, - { name: SHEET_MATERIALS, rows: materialRows }, + { name: L.sheetSync, rows: syncRows }, + { name: L.sheetMaterials, rows: materialRows }, ], filename, ); diff --git a/src/app/(main)/report/grnReportApi.ts b/src/app/(main)/report/grnReportApi.ts index 4e4eec73..a8972cec 100644 --- a/src/app/(main)/report/grnReportApi.ts +++ b/src/app/(main)/report/grnReportApi.ts @@ -6,6 +6,8 @@ import { exportChartToXlsx, exportMultiSheetToXlsx, } from "@/app/(main)/chart/_components/exportChartToXlsx"; +import { reportExcelT as tx } from "./reportI18n"; +import type { TFunction } from "i18next"; export interface GrnReportRow { poCode?: string; @@ -122,77 +124,102 @@ const formatQty = (n: number | undefined | null): string => { }).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( r: GrnReportRow, - includeFinancialColumns: boolean + includeFinancialColumns: boolean, + t?: TFunction, ): Record { + const L = grnLabels(t); const base: Record = { - "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) { - 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; } -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( - listed: ListedPoAmounts | undefined + listed: ListedPoAmounts | undefined, + t?: TFunction, ): Record[] { + const L = grnLabels(t); if ( !listed || (listed.currencyTotals.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[] = []; for (const c of listed.currencyTotals) { 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) { 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; @@ -206,10 +233,11 @@ export async function generateGrnReportExcel( criteria: Record, reportTitle: string = "PO 入倉記錄", /** Only users with ADMIN authority should pass true (must match backend). */ - includeFinancialColumns: boolean = false + includeFinancialColumns: boolean = false, + t?: TFunction, ): Promise { 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 end = criteria.receiptDateEnd; let datePart: string; @@ -222,17 +250,18 @@ export async function generateGrnReportExcel( } const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); const filename = `${reportTitle}_${safeDatePart}`; + const L = grnLabels(t); if (includeFinancialColumns) { - const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts); + const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts, t); exportMultiSheetToXlsx( [ - { name: GRN_SHEET_DETAIL, rows: excelRows as Record[] }, - { name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record[] }, + { name: L.sheetDetail, rows: excelRows as Record[] }, + { name: L.sheetListedPo, rows: sheet2 as Record[] }, ], filename ); } else { - exportChartToXlsx(excelRows as Record[], filename, GRN_SHEET_DETAIL); + exportChartToXlsx(excelRows as Record[], filename, L.sheetDetail); } } diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 266020a5..639f62d0 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { AUTH } from "@/authorities"; @@ -18,13 +18,24 @@ import { Autocomplete, Checkbox, FormControlLabel, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from '@mui/material'; 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 { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; 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 { fetchSemiFGItemCodes, 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. 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() { 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 = session?.abilities?.includes(AUTH.ADMIN) ?? false; const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); const [loading, setLoading] = useState(false); + const excelInFlightRef = useRef(false); const [dynamicOptions, setDynamicOptions] = useState>({}); const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showNoDataDialog, setShowNoDataDialog] = useState(false); // Find the configuration for the currently selected report const rep012RoundIds = useMemo(() => { @@ -73,12 +91,27 @@ export default function ReportPage() { const handleSelectReport = (reportId: string) => { if (reportId === selectedReportId) return; 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 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 (name === 'stockCategory' && currentReport) { @@ -135,8 +168,27 @@ export default function ReportPage() { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 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(); + 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 })); @@ -194,7 +246,9 @@ export default function ReportPage() { if (currentReport.id === 'rep-012') { if (rep012RoundIds.length === 0) { - alert('缺少必填條件:\n- 盤點輪次'); + alert(t('missingRequired', { + fields: fieldLabel('rep-012', { name: 'stockTakeRoundId', label: '盤點輪次' }), + })); return false; } return true; @@ -206,10 +260,26 @@ export default function ReportPage() { if (!field.required) return false; return !criteria[field.name]; }) - .map(field => field.label); + .map((field) => fieldLabel(currentReport.id, field)); 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; } @@ -235,6 +305,28 @@ export default function ReportPage() { 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 () => { if (!currentReport) return; if (!validateRequiredFields()) return; @@ -259,23 +351,28 @@ export default function ReportPage() { const executeExcelReport = async () => { if (!currentReport) return; + if (excelInFlightRef.current) return; + excelInFlightRef.current = true; setLoading(true); try { if (currentReport.id === 'rep-014') { await generateGrnReportExcel( criteria, - currentReport.title, - includeGrnFinancialColumns + reportTitle(currentReport), + includeGrnFinancialColumns, + t, ); } else if (currentReport.id === 'rep-015') { - await generateBomShopSyncReportExcel(criteria, currentReport.title); + await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t); } else if (currentReport.id === 'rep-017') { - await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title); + await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. let queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() + : currentReport.id === 'rep-010' + ? buildRep010QueryString() : new URLSearchParams(criteria).toString(); // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. if (currentReport.id === 'rep-016') { @@ -295,6 +392,10 @@ export default function ReportPage() { }); if (response.status === 401 || response.status === 403) return; + if (response.status === 204) { + setShowNoDataDialog(true); + return; + } if (!response.ok) { const errorText = await response.text(); console.error("Response error:", errorText); @@ -307,7 +408,7 @@ export default function ReportPage() { link.href = downloadUrl; const contentDisposition = response.headers.get('Content-Disposition'); - let fileName = `${currentReport.title}.xlsx`; + let fileName = `${reportTitle(currentReport)}.xlsx`; if (contentDisposition?.includes('filename=')) { fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); } @@ -328,9 +429,10 @@ export default function ReportPage() { setShowConfirmDialog(false); } catch (error) { console.error("Failed to generate Excel report:", error); - alert("An error occurred while generating the report. Please try again."); + alert(t('generateError')); } finally { setLoading(false); + excelInFlightRef.current = false; } }; @@ -339,9 +441,11 @@ export default function ReportPage() { setLoading(true); try { - const queryParams = + let queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() + : currentReport.id === 'rep-010' + ? buildRep010QueryString() : new URLSearchParams(criteria).toString(); const url = `${currentReport.apiEndpoint}?${queryParams}`; @@ -363,7 +467,7 @@ export default function ReportPage() { link.href = downloadUrl; const contentDisposition = response.headers.get('Content-Disposition'); - let fileName = `${currentReport.title}.pdf`; + let fileName = `${reportTitle(currentReport)}.pdf`; if (contentDisposition?.includes('filename=')) { fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); } @@ -383,16 +487,17 @@ export default function ReportPage() { setShowConfirmDialog(false); } catch (error) { console.error("Failed to generate report:", error); - alert("An error occurred while generating the report. Please try again."); + alert(t('generateError')); } finally { setLoading(false); } }; return ( + <> - 報告管理 + {t('title')} - 搜索條件: {currentReport.title} + {t('searchCriteriaWithTitle', { title: reportTitle(currentReport) })} + '日', + fieldMonthPlaceholder: () => '月', + fieldYearPlaceholder: () => '年', + } + : undefined + } + > {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 || []); + const options = rawOptions.map((opt) => ({ + ...opt, + label: optionLabel(currentReport.id, field.name, opt), + })); const currentValue = criteria[field.name] || ''; const valueForSelect = field.multiple ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) @@ -430,6 +553,41 @@ export default function ReportPage() { field.name === 'status' && rep012MultiRound; + if (field.type === 'date') { + const parsed = currentValue ? dayjs(currentValue) : null; + return ( + + { + 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' + } + } : {}, + }, + }} + /> + + ); + } + if (field.type === 'checkbox') { return ( @@ -442,7 +600,7 @@ export default function ReportPage() { } /> } - label={field.label} + label={translatedLabel} /> ); @@ -498,8 +656,8 @@ export default function ReportPage() { + {currentReport.id === 'rep-005' ? ( 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} setLoading={setLoading} - reportTitle={currentReport.title} + reportTitle={reportTitle(currentReport)} onExportSuccess={(format) => { logFeatureUsage( FEATURE_USAGE.REPORT_MANAGEMENT, @@ -651,7 +809,7 @@ export default function ReportPage() { disabled={loading} sx={{ px: 4 }} > - {loading ? "生成 PDF..." : "下載報告 (PDF)"} + {loading ? t('generatingPdf') : t('downloadPdf')} ) : currentReport.id === 'rep-006' || currentReport.id === 'rep-010' ? ( @@ -674,7 +832,7 @@ export default function ReportPage() { disabled={loading} sx={{ px: 4 }} > - {loading ? "生成 PDF..." : "下載報告 (PDF)"} + {loading ? t('generatingPdf') : t('downloadPdf')} ) : currentReport.responseType === 'excel' ? ( @@ -696,7 +854,7 @@ export default function ReportPage() { disabled={loading} sx={{ px: 4 }} > - {loading ? "生成 Excel..." : "下載報告 (Excel)"} + {loading ? t('generatingExcel') : t('downloadExcel')} ) : ( )} @@ -715,5 +873,49 @@ export default function ReportPage() { )} + setShowNoDataDialog(false)} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + borderRadius: 3, + px: 1, + }, + }} + > + + + + + + {t('noDataFoundTitle')} + + + + + {t('noDataFoundHint')} + + + + + + + ); } \ No newline at end of file diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts index 1c86308b..543e6186 100644 --- a/src/app/(main)/report/reportCategories.ts +++ b/src/app/(main)/report/reportCategories.ts @@ -9,7 +9,7 @@ export interface ReportCategoryConfig { 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[] = [ { id: "inventory", @@ -17,7 +17,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ headerBg: "#b8e0b8", bodyBg: "#eef8ee", 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", @@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ headerBg: "#b3d4f0", bodyBg: "#eef5fc", 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", diff --git a/src/app/(main)/report/reportI18n.ts b/src/app/(main)/report/reportI18n.ts new file mode 100644 index 00000000..86daf4e0 --- /dev/null +++ b/src/app/(main)/report/reportI18n.ts @@ -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) => + t(`reports.${report.id}.title`, { defaultValue: report.title }); + + const fieldLabel = ( + reportId: string, + field: Pick, + ) => + 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 })); +} diff --git a/src/app/(main)/report/shopOrderReplenishmentReportApi.ts b/src/app/(main)/report/shopOrderReplenishmentReportApi.ts index bca78f71..eeb2bcd5 100644 --- a/src/app/(main)/report/shopOrderReplenishmentReportApi.ts +++ b/src/app/(main)/report/shopOrderReplenishmentReportApi.ts @@ -3,6 +3,8 @@ import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; +import { reportExcelT as tx } from "./reportI18n"; +import type { TFunction } from "i18next"; export interface ShopOrderReplenishmentReportRow { shopNo?: string; @@ -13,10 +15,12 @@ export interface ShopOrderReplenishmentReportRow { itemName?: string; firstOrderQty?: number | string; firstOrderActualPickQty?: number | string; + firstOrderPickerHandler?: string; reorderQty?: number | string; reorderDate?: string; reason?: string; actualDeliveredQty?: number | string; + actualDeliveredHandler?: string; deliveredDate?: string; [key: string]: unknown; } @@ -25,37 +29,75 @@ export interface ShopOrderReplenishmentReportResponse { 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; -function emptySheetRow(note: string = NO_DATA_NOTE): Record { +function emptySheetRow( + L: ShopReplenishmentLabels, + note?: string, +): Record { 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()) { case "quality_issue": - return "質素問題"; + return L.reasonQuality; case "out_of_stock": - return "缺貨"; + return L.reasonOutOfStock; case "other": - return "其他"; + return L.reasonOther; default: return reason ?? ""; } @@ -88,23 +130,27 @@ function formatQty(value: unknown): string | number { return n; } -function toExcelRow(r: ShopOrderReplenishmentReportRow): Record { - const base = emptySheetRow(""); +function toExcelRow( + r: ShopOrderReplenishmentReportRow, + L: ShopReplenishmentLabels, +): Record { 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. */ export async function generateShopOrderReplenishmentReportExcel( criteria: Record, reportTitle: string = "店鋪訂單補貨記錄", + t?: TFunction, ): Promise { + const L = shopReplenishmentLabels(t); const rows = await fetchShopOrderReplenishmentReportData(criteria); const excelRows = - rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()]; + rows.length > 0 ? rows.map((r) => toExcelRow(r, L)) : [emptySheetRow(L)]; const dateCandidates = [ criteria.reorderDateStart, @@ -157,5 +206,5 @@ export async function generateShopOrderReplenishmentReportExcel( const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); const filename = `${reportTitle}_${safeDatePart}`; - exportChartToXlsx(excelRows, filename, SHEET_NAME); + exportChartToXlsx(excelRows, filename, L.sheetName); } diff --git a/src/app/(main)/settings/itemDefaultShelfLife/page.tsx b/src/app/(main)/settings/itemDefaultShelfLife/page.tsx new file mode 100644 index 00000000..e4d11586 --- /dev/null +++ b/src/app/(main)/settings/itemDefaultShelfLife/page.tsx @@ -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 ( + + + {t("title")} + + + + ); +} diff --git a/src/app/api/bagPrint/actions.ts b/src/app/api/bagPrint/actions.ts index 3749ee30..ad637f2e 100644 --- a/src/app/api/bagPrint/actions.ts +++ b/src/app/api/bagPrint/actions.ts @@ -13,6 +13,12 @@ export interface JobOrderListItem { stockInLineId: number | null; itemId: number | 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 */ bagPrintedQty?: number; /** 標簽機 cumulative printed qty */ @@ -37,6 +43,8 @@ export interface OnPackQrDownloadRequest { jobOrderId: number; 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. */ @@ -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). */ async function zipDownloadError(res: Response): Promise { + 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 { const text = await res.text(); const ct = res.headers.get("content-type") ?? ""; if (ct.includes("application/json")) { 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) { return new Error(j.message); } if (typeof j.error === "string" && j.error.length > 0) { 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 { /* ignore parse */ } @@ -98,7 +116,7 @@ async function zipDownloadError(res: Response): Promise { if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { 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(); } +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 { + 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 */ export async function downloadOnPackTextQrZip( request: OnPackQrDownloadRequest, @@ -166,3 +218,167 @@ export async function downloadOnPackTextQrZip( return res.blob(); } + +/** OnPack2023 檸檬機 — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */ +export async function downloadOnPackTextQrZipWithExpiry( + request: OnPackQrDownloadRequest, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 模板"); + } +} diff --git a/src/app/api/chart/client.ts b/src/app/api/chart/client.ts index defade07..96823649 100644 --- a/src/app/api/chart/client.ts +++ b/src/app/api/chart/client.ts @@ -131,6 +131,8 @@ export interface StaffDeliveryPerformanceRow { staffName: string; orderCount: number; totalMinutes: number; + itemKindCount: number; + itemQtyPicked: number; } 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). */ 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( startDate?: string, endDate?: string, @@ -604,6 +607,8 @@ export async function fetchStaffDeliveryPerformance( staffName: String(row.staffName ?? row.staffname ?? ""), orderCount: Number(row.orderCount ?? row.ordercount ?? 0), totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0), + itemKindCount: Number(row.itemKindCount ?? row.itemkindcount ?? 0), + itemQtyPicked: Number(row.itemQtyPicked ?? row.itemqtypicked ?? 0), }; }); } diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 36cf2286..92793384 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -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(url, { + method: "GET", + next: { tags: ["drinkShipmentQty"] }, + }); +}); + // ===== Equipment Status Dashboard ===== export interface EquipmentStatusProcessInfo { diff --git a/src/app/api/laserPrint/actions.ts b/src/app/api/laserPrint/actions.ts index 183b0c5b..ffde3afb 100644 --- a/src/app/api/laserPrint/actions.ts +++ b/src/app/api/laserPrint/actions.ts @@ -13,6 +13,10 @@ export interface JobOrderListItem { stockInLineId: number | null; itemId: number | 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; labelPrintedQty?: number; laserPrintedQty?: number; @@ -48,6 +52,8 @@ export interface LaserBag2SendRequest { jobOrderId?: number | null; jobOrderNo?: 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; } @@ -109,6 +115,39 @@ export async function fetchLaserBag2Settings(): Promise { return res.json() as Promise; } +/** 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, 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 { const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; const res = await clientAuthFetch(url, { @@ -116,11 +155,29 @@ export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise = {}; + try { + const text = await res.text(); + data = text ? (JSON.parse(text) as Record) : {}; + } 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 { diff --git a/src/app/api/settings/itemDefaultShelfLife/client.ts b/src/app/api/settings/itemDefaultShelfLife/client.ts new file mode 100644 index 00000000..03f99b35 --- /dev/null +++ b/src/app/api/settings/itemDefaultShelfLife/client.ts @@ -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(res: Response): Promise { + if (!res.ok) { + throw new Error(await readError(res)); + } + return res.json() as Promise; +} + +async function readError(res: Response): Promise { + 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 { + 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(res); +} + +export async function createItemDefaultShelfLife( + data: ItemDefaultShelfLifeInput, +): Promise { + const res = await clientAuthFetch(`${base}/itemDefaultShelfLives`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return parseJson(res); +} + +export async function updateItemDefaultShelfLife( + id: number, + data: ItemDefaultShelfLifeInput, +): Promise { + const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return parseJson(res); +} + +export async function deleteItemDefaultShelfLife( + id: number, +): Promise { + const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, { + method: "DELETE", + }); + return parseJson(res); +} diff --git a/src/app/api/user/client.ts b/src/app/api/user/client.ts index 2040da72..648e8d95 100644 --- a/src/app/api/user/client.ts +++ b/src/app/api/user/client.ts @@ -164,6 +164,15 @@ export const updateUser = async ( if (response.status === 401) { 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}` : ""}`, + ); } }; \ No newline at end of file diff --git a/src/authorities.ts b/src/authorities.ts index 0ce7f019..ad4aeb9e 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -25,6 +25,8 @@ export const AUTH = { */ PRODUCT_PROCESS: "PRODUCT_PROCESS", 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; /** diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 8fa9d955..4da56d55 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -1,6 +1,7 @@ import MUIAppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import React from "react"; +import LanguageSwitcher from "./LanguageSwitcher"; import Profile from "./Profile"; import Box from "@mui/material/Box"; import NavigationToggle from "./NavigationToggle"; @@ -35,6 +36,7 @@ const AppBar: React.FC = ({ avatarImageSrc, profileName }) => { gap: 1, }} > + { + 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, 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 ( + + + EN + + ); +}; + +export default LanguageSwitcher; diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx index 63e7579b..64ed9fe6 100644 --- a/src/components/BagPrint/BagPrintSearch.tsx +++ b/src/components/BagPrint/BagPrintSearch.tsx @@ -1,9 +1,11 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Alert, Box, Button, + Chip, FormControl, InputLabel, MenuItem, @@ -19,6 +21,15 @@ import { DialogActions, TextField, Snackbar, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + Tooltip, } from "@mui/material"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronRight from "@mui/icons-material/ChevronRight"; @@ -29,11 +40,21 @@ import { buildOnPackJobOrdersPayload, checkPrinterStatus, downloadOnPackQrZip, + downloadOnPackQrZipWithExpiry, downloadOnPackTextQrZip, + downloadOnPackTextQrZipWithExpiry, fetchJobOrders, + fetchOnPackExpiryCodes, + addOnPackExpiryCode, + updateOnPackExpiryCode, + deleteOnPackExpiryCode, + fetchOnPackSupportedCatalog, JobOrderListItem, + OnPackExpiryItemCodeDto, } from "@/app/api/bagPrint/actions"; import dayjs from "dayjs"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; @@ -56,6 +77,28 @@ const REFRESH_MS = 60 * 1000; const PRINTER_CHECK_MS = 60 * 1000; const PRINTER_RETRY_MS = 30 * 1000; 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 = { dabag_ip: "", @@ -95,7 +138,91 @@ function getBatch(jo: JobOrderListItem): string { return (jo.lotNo || "—").trim() || "—"; } +function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set { + 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 { 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 [jobOrders, setJobOrders] = useState([]); const [loading, setLoading] = useState(true); @@ -109,12 +236,35 @@ const BagPrintSearch: React.FC = () => { const [printContinuous, setPrintContinuous] = useState(false); const [printing, setPrinting] = 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([]); + const [expiryCodeInput, setExpiryCodeInput] = useState(""); + const [expiryCodesLoading, setExpiryCodesLoading] = useState(false); + const [nameDrafts, setNameDrafts] = useState>({}); + const [expirySortKey, setExpirySortKey] = useState("itemCode"); + const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc"); + const [lemonCodeSet, setLemonCodeSet] = useState>(() => new Set()); + const expiryAddRef = useRef(false); + const expiryDeleteRef = useRef(false); + const expirySaveRef = useRef>(new Set()); + const expiryToggleRef = useRef>(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 [printerConnected, setPrinterConnected] = useState(false); const [printerMessage, setPrinterMessage] = useState("列印機未連接"); const [downloadingOnPack, setDownloadingOnPack] = useState(false); + const [downloadingOnPackExp, setDownloadingOnPackExp] = 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(() => { setSettings(loadSettings()); @@ -275,6 +425,7 @@ const BagPrintSearch: React.FC = () => { }; const handleDownloadOnPackQr = async () => { + if (downloadingOnPackRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { @@ -282,6 +433,7 @@ const BagPrintSearch: React.FC = () => { return; } + downloadingOnPackRef.current = true; setDownloadingOnPack(true); try { const blob = await downloadOnPackQrZip({ @@ -306,10 +458,51 @@ const BagPrintSearch: React.FC = () => { }); } finally { 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 () => { + if (downloadingOnPackTextRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { @@ -317,6 +510,7 @@ const BagPrintSearch: React.FC = () => { return; } + downloadingOnPackTextRef.current = true; setDownloadingOnPackText(true); try { const blob = await downloadOnPackTextQrZip({ @@ -341,6 +535,209 @@ const BagPrintSearch: React.FC = () => { }); } finally { 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 = () => { + {canSeeOnPackAdmin && ( + + )} { variant="contained" startIcon={} onClick={handleDownloadOnPackQr} - disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} + disabled={ + loading || + downloadingOnPack || + downloadingOnPackExp || + downloadingOnPackText || + downloadingOnPackTextExp || + jobOrders.length === 0 + } > {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} + + {canSeeOnPackAdmin && ( + + )} @@ -436,6 +885,9 @@ const BagPrintSearch: React.FC = () => { const batch = getBatch(jo); const qtyStr = formatQty(jo.reqQty); const isSelected = selectedId === jo.id; + const codeKey = (jo.itemCode || "").trim().toUpperCase(); + const juiceExpiryOk = juiceExpiryCodeSet.has(codeKey); + const lemonOk = lemonCodeSet.has(codeKey); return ( { {jo.itemCode || "—"} + + {juiceExpiryOk ? : null} + {lemonOk ? : null} + @@ -632,13 +1088,254 @@ const BagPrintSearch: React.FC = () => { + setTemplatesOpen(false)} + maxWidth="xl" + fullWidth + scroll="paper" + PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} + > + OnPack 到期日 ZIP 品號 + + + + 汁水機({expiryCodes.length}) + + + 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。 + 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。 + + + setExpiryCodeInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAddExpiryCode(); + } + }} + sx={{ minWidth: 180 }} + /> + + + + {expiryCodesLoading ? ( + + + + ) : expiryCodes.length === 0 ? ( + 清單空白 + ) : ( + +
+ + + + onExpirySort("itemCode")} + > + 品號 + + + + onExpirySort("name")} + > + 中文名稱+單位 + + + + onExpirySort("defaultDays")} + > + 冷藏 + + + 天 + + + + onExpirySort("minus18Days")} + > + -18 + + + 天 + + + + onExpirySort("useMinus18")} + > + 用 -18 + + + + onExpirySort("effectiveDays")} + > + 列印 + + + 天 + + + + 操作 + + + + + {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 ( + + + {row.itemCode} + + + + + setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) + } + placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"} + inputProps={{ maxLength: 255 }} + fullWidth + /> + + + + + + {daysLabel(row.defaultDays)} + + + + + {daysLabel(row.minus18Days)} + + + + + + void handleToggleUseMinus18(row.itemCode, e.target.checked)} + inputProps={{ "aria-label": `${row.itemCode} 用 -18` }} + /> + + + + + + {daysLabel(row.effectiveDays)} + + {!hasShelf && ( + + 未設定 + + )} + + + + + + ); + })} + +
+
+ )} + + 檸檬機到期日 ZIP 品號稍後加入。 + +
+ + + + + setSnackbar((s) => ({ ...s, open: false }))} - message={snackbar.message} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - /> + > + setSnackbar((s) => ({ ...s, open: false }))} + severity={snackbar.severity ?? "info"} + variant="filled" + sx={{ width: "100%", maxWidth: 720 }} + > + {snackbar.message} + +
); }; diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 20e30a7a..a5804a95 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -24,6 +24,7 @@ const pathToLabelKey: { [path: string]: string } = { "/settings/user": "nav.settings.user", "/settings/clientMonitor": "nav.settings.clientMonitor", "/settings/items": "nav.settings.items", + "/settings/itemDefaultShelfLife": "nav.settings.itemDefaultShelfLife", "/settings/warehouse": "nav.settings.warehouse", "/settings/qcCategory": "nav.settings.qcCategory", "/settings/bomWeighting": "nav.settings.bomWeighting", diff --git a/src/components/CreateUser/CreateUser.tsx b/src/components/CreateUser/CreateUser.tsx index 2ad4c6b0..3166fed0 100644 --- a/src/components/CreateUser/CreateUser.tsx +++ b/src/components/CreateUser/CreateUser.tsx @@ -29,7 +29,7 @@ import { useForm, useFormContext, } 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 { UserInputs, adminChangePassword, @@ -46,8 +46,9 @@ interface Props { auths: auth[]; } +/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ const CreateUser: React.FC = ({ rules, auths }) => { - console.log(auths); + // console.log(auths); const { t } = useTranslation("user"); const formProps = useForm(); const searchParams = useSearchParams(); @@ -172,7 +173,33 @@ const CreateUser: React.FC = ({ rules, auths }) => { router.replace("/settings/user"); } catch (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], @@ -212,7 +239,7 @@ const CreateUser: React.FC = ({ rules, auths }) => { label={t("User Detail")} icon={ hasErrorsInTab(0, errors) ? ( - + ) : undefined } iconPosition="end" diff --git a/src/components/CreateUser/UserDetail.tsx b/src/components/CreateUser/UserDetail.tsx index d8f5c133..6778d505 100644 --- a/src/components/CreateUser/UserDetail.tsx +++ b/src/components/CreateUser/UserDetail.tsx @@ -35,6 +35,11 @@ const UserDetail: React.FC = () => { required: "username required!", })} error={Boolean(errors.username)} + helperText={ + Boolean(errors.username) && errors.username?.message + ? t(errors.username.message) + : "" + } /> diff --git a/src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx b/src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx new file mode 100644 index 00000000..763a54ad --- /dev/null +++ b/src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx @@ -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(null); + const [success, setSuccess] = useState(null); + const [rows, setRows] = useState([]); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyForm); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + const [deleteTarget, setDeleteTarget] = useState(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 ( + + + {t("Intro")} + + {error && {error}} + {success && ( + setSuccess(null)}> + {success} + + )} + + setQuery(e.target.value)} + placeholder={t("Search placeholder")} + sx={{ minWidth: 260, flex: 1 }} + /> + + + {loading ? ( + + + + ) : ( + <> + + + + + {t("Col itemCode")} + {t("Col itemName")} + {t("Col defaultDays")} + {t("Col minus18Days")} + {t("Col useMinus18")} + {t("Col effectiveDays")} + {t("Col openedDays")} + {t("Col storageC")} + {t("Col remarks")} + {t("Col actions")} + + + + {paged.length === 0 ? ( + + + + {rows.length === 0 ? t("Empty") : t("No match")} + + + + ) : ( + paged.map((row) => ( + + {row.itemCode} + {row.itemName || "—"} + {row.defaultDays ?? "—"} + {row.minus18Days ?? "—"} + + + + {row.effectiveDays ?? "—"} + {row.openedDays ?? "—"} + {row.storageC || "—"} + {row.remarks || "—"} + + openEdit(row)}> + + + setDeleteTarget(row)} + > + + + + + )) + )} + +
+
+ + + {t("Showing", { from, to, total: filtered.length })} + + setPage(next)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[25, 50, 100]} + /> + + + )} + + + {editing ? t("Edit title") : t("Add title")} + + + {formError && {formError}} + setForm((s) => ({ ...s, itemCode: e.target.value }))} + disabled={saving} + autoFocus={!editing} + /> + + setForm((s) => ({ ...s, defaultDays: e.target.value }))} + disabled={saving} + fullWidth + /> + setForm((s) => ({ ...s, minus18Days: e.target.value }))} + disabled={saving} + fullWidth + /> + + + setForm((s) => ({ ...s, useMinus18: e.target.checked }))} + disabled={saving} + /> + } + label={t("Use minus18")} + /> + {t("Use minus18 help")} + + + {previewDate + ? t("Expiry preview", { date: previewDate }) + : t("Expiry preview none")} + + + setForm((s) => ({ ...s, openedDays: e.target.value }))} + disabled={saving} + fullWidth + /> + setForm((s) => ({ ...s, storageC: e.target.value }))} + disabled={saving} + inputProps={{ maxLength: 20 }} + fullWidth + /> + + setForm((s) => ({ ...s, remarks: e.target.value }))} + disabled={saving} + inputProps={{ maxLength: 255 }} + multiline + minRows={2} + /> + + + + + + + + + !deleting && setDeleteTarget(null)}> + {t("Delete title")} + + + {t("Delete confirm", { itemCode: deleteTarget?.itemCode ?? "" })} + + + + + + + +
+ ); +}; + +export default ItemDefaultShelfLifeSettings; diff --git a/src/components/ItemTracing/buildJoPreludeGraphNodes.ts b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts index 76016595..9aa147f8 100644 --- a/src/components/ItemTracing/buildJoPreludeGraphNodes.ts +++ b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts @@ -1,4 +1,8 @@ -import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; +import { + ItemLotTraceJoPickLine, + ItemLotTraceJoPrelude, + ItemLotTraceMaterialInput, +} from "@/app/api/itemTracing"; import { TraceGraphDetailField, TraceGraphDetailLabels, diff --git a/src/components/ItemTracing/buildTraceGraphNodes.ts b/src/components/ItemTracing/buildTraceGraphNodes.ts index b79ab868..bd878aba 100644 --- a/src/components/ItemTracing/buildTraceGraphNodes.ts +++ b/src/components/ItemTracing/buildTraceGraphNodes.ts @@ -168,6 +168,11 @@ export interface TraceGraphNode { /** Raw status codes (for coloring). */ processingStatus?: string; matchStatus?: string; + /** Material-pick stock summary chips (JO pick cards). */ + stockAvailableLabel?: string; + stockStatusLabel?: string; + bomReqQtyLabel?: string; + stockReqQtyLabel?: string; } export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { @@ -255,6 +260,16 @@ export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { categoryTerminal: string; processingStatus: 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 => { @@ -873,10 +888,7 @@ export const buildTraceGraphNodes = ( fieldIf(labels.detailRemarks, m.remarks), ) : 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.detailSourceDoc, m.refCode, { linkKind, diff --git a/src/components/JoSearch/JoCreateFormModal.tsx b/src/components/JoSearch/JoCreateFormModal.tsx index 882d6137..80d957bb 100644 --- a/src/components/JoSearch/JoCreateFormModal.tsx +++ b/src/components/JoSearch/JoCreateFormModal.tsx @@ -60,7 +60,7 @@ const JoCreateFormModal: React.FC = ({ /* const handleAutoCompleteChange = useCallback( (event: SyntheticEvent, value: BomCombo, onChange: (...event: any[]) => void) => { - console.log("BOM changed to:", value); + // console.log("BOM changed to:", value); onChange(value.id); // 重置倍数为 1 @@ -101,7 +101,7 @@ const JoCreateFormModal: React.FC = ({ }, [bomCombo]); const handleAutoCompleteChange = useCallback( (event: SyntheticEvent, value: BomCombo, onChange: (...event: any[]) => void) => { - console.log("BOM changed to:", value); + // console.log("BOM changed to:", value); onChange(value.id); if (value.outputQty != null) { diff --git a/src/components/JoSearch/JoSearch.tsx b/src/components/JoSearch/JoSearch.tsx index 35bee07e..f10a067a 100644 --- a/src/components/JoSearch/JoSearch.tsx +++ b/src/components/JoSearch/JoSearch.tsx @@ -272,13 +272,13 @@ const JoSearch: React.FC = ({ pageSize: pagingController.pageSize, }; 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) { - console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); + // console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); setTotalCount(response.total); 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 { console.warn("newPageFetch - no response or no records"); setFilteredJos([]); diff --git a/src/components/JoWorkbench/JoWorkbenchSearch.tsx b/src/components/JoWorkbench/JoWorkbenchSearch.tsx index 97f08d4c..ae8955e7 100644 --- a/src/components/JoWorkbench/JoWorkbenchSearch.tsx +++ b/src/components/JoWorkbench/JoWorkbenchSearch.tsx @@ -273,13 +273,13 @@ const JoWorkbenchSearch: React.FC = ({ pageSize: pagingController.pageSize, }; 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) { - console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); + // console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); setTotalCount(response.total); 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 { console.warn("newPageFetch - no response or no records"); setFilteredJos([]); diff --git a/src/components/JoWorkbench/newJobPickExecution.tsx b/src/components/JoWorkbench/newJobPickExecution.tsx index 10a4c661..e62f9d5a 100644 --- a/src/components/JoWorkbench/newJobPickExecution.tsx +++ b/src/components/JoWorkbench/newJobPickExecution.tsx @@ -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 = ({ filterArgs, onBackToList, printerCombo = [] }) => { const workbenchMode = true; const { t } = useTranslation("jo"); @@ -896,10 +896,8 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo data.pickOrderLines.forEach((line) => { // 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次) const lotIdSet = new Set(); - /** 已由有批次建議分配的量(加總後與 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) { const lotMap = new Map(); @@ -916,7 +914,6 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo }); lotMap.forEach((lot: any) => { - lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; if (lot.lotId != null) lotIdSet.add(lot.lotId); allLots.push({ @@ -945,20 +942,8 @@ const JobPickExecution: React.FC = ({ 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 闭环 + // 批號需求數:對齊 DO Workbench——用後端 stockout/SPL qty,不前端推 gap if (line.stockouts && line.stockouts.length > 0) { line.stockouts.forEach((stockout: any) => { const hasLot = stockout.lotId != null; @@ -970,6 +955,17 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo return; } + const stockoutRequiredQty = Number( + stockout?.requiredQty ?? + stockout?.suggestedPickQty ?? + stockout?.suggestedPickLotQty, + ); + const effectiveStockoutRequiredQty = Number.isFinite( + stockoutRequiredQty, + ) + ? stockoutRequiredQty + : Number(line.requiredQty) || 0; + allLots.push({ pickOrderLineId: line.id, itemId: line.itemId, @@ -996,19 +992,13 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo expiryDate: null, location: stockout.location || null, 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, processingStatus: stockout.status || "pending", lotAvailability: stockout.noLot ? "insufficient_stock" : "available", - suggestedPickLotId: null, + suggestedPickLotId: stockout.suggestedPickLotId ?? null, stockOutLineId: stockout.id || null, stockOutLineQty: stockout.qty ?? 0, stockOutLineStatus: stockout.status || null, @@ -3655,6 +3645,22 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo const isNoLotTailRow = (lot: any) => 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 efA = effectiveFloorOrder(a); const efB = effectiveFloorOrder(b); @@ -3674,6 +3680,10 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo const bName = String(b.itemName || ""); 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 tailB = isNoLotTailRow(b) ? 1 : 0; if (tailA !== tailB) return tailA - tailB; diff --git a/src/components/LaserPrint/LaserPrintSearch.tsx b/src/components/LaserPrint/LaserPrintSearch.tsx index df143b62..8aec53df 100644 --- a/src/components/LaserPrint/LaserPrintSearch.tsx +++ b/src/components/LaserPrint/LaserPrintSearch.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Box, @@ -26,6 +26,7 @@ import { type LaserLastReceiveSuccess, JobOrderListItem, patchSetting, + expiryDateForLaserSend, sendLaserBag2Job, } from "@/app/api/laserPrint/actions"; import dayjs from "dayjs"; @@ -89,6 +90,7 @@ const LaserPrintSearch: React.FC = () => { const [settingsLoaded, setSettingsLoaded] = useState(false); const [printerConnected, setPrinterConnected] = useState(false); const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); + const sendInFlightRef = useRef(false); const loadSystemSettings = useCallback(async () => { try { @@ -191,31 +193,38 @@ const LaserPrintSearch: React.FC = () => { jobOrderId: jo.id, jobOrderNo: jo.code, lotNo: jo.lotNo, + expiryDate: expiryDateForLaserSend(jo.expiryDate), source: "MANUAL", }); const handleRowClick = async (jo: JobOrderListItem) => { - if (sendingJobId !== null) return; + if (sendInFlightRef.current || sendingJobId !== null) return; if (!laserHost.trim()) { setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" }); return; } + sendInFlightRef.current = true; setSelectedId(jo.id); setSendingJobId(jo.id); try { let lastAck: string | undefined; let anyReceiveAck = false; + let sentOk = 0; + let laterFail: string | null = null; for (let i = 0; i < LASER_SEND_COUNT; i++) { const r = await sendOne(jo); 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.receiveAcknowledged) anyReceiveAck = true; if (i < LASER_SEND_COUNT - 1) { @@ -228,7 +237,11 @@ const LaserPrintSearch: React.FC = () => { : lastAck ? `(最後回覆:${lastAck})` : ""; - setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)${ackHint}`); + setSuccessSignal( + laterFail + ? `已送出 ${sentOk} 次至檸檬機(激光機)${ackHint}(後續重送失敗:${laterFail})` + : `已送出 ${sentOk} 次至檸檬機(激光機)${ackHint}`, + ); await loadSystemSettings(); } catch (e) { setErrorSnackbar({ @@ -237,6 +250,7 @@ const LaserPrintSearch: React.FC = () => { }); } finally { setSendingJobId(null); + sendInFlightRef.current = false; } }; @@ -268,7 +282,12 @@ const LaserPrintSearch: React.FC = () => { {settingsLoaded && lastLaserReceive && ( - 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} {formatHongKongDateTime(lastLaserReceive.sentAt)} + 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"}  + {formatHongKongDateTime(lastLaserReceive.sentAt)} + {lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""} + + + 此時間只會在檸檬機回覆 receive 時更新。之後送出失敗不會改這裡。 )} diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 314e4310..957cb95b 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -40,6 +40,7 @@ import UploadFile from "@mui/icons-material/UploadFile"; import Sync from "@mui/icons-material/Sync"; import Layers from "@mui/icons-material/Layers"; import Devices from "@mui/icons-material/Devices"; +import EventAvailable from "@mui/icons-material/EventAvailable"; import { useTranslation } from "react-i18next"; import { usePathname } from "next/navigation"; import Link from "next/link"; @@ -61,6 +62,7 @@ interface NavigationItem { requiredAbility?: string | string[]; } +/** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ const NavigationContent: React.FC = () => { const { data: session, status } = useSession(); const abilities = session?.user?.abilities ?? []; @@ -241,7 +243,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.m18Sync", path: "/m18Syn", - requiredAbility: [AUTH.ADMIN], + requiredAbility: [AUTH.M18_SYNC, AUTH.ADMIN], isHidden: false, }, { @@ -324,6 +326,12 @@ const NavigationContent: React.FC = () => { labelKey: "nav.settings.items", path: "/settings/items", }, + { + id: "nav.settings.itemDefaultShelfLife", + icon: , + labelKey: "nav.settings.itemDefaultShelfLife", + path: "/settings/itemDefaultShelfLife", + }, { id: "nav.settings.equipment", icon: , diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index 98d3442c..03b91061 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -228,6 +228,16 @@ const isCheckedStatus = (status: string | undefined): boolean => const isRejectedStatus = (status: string | undefined): boolean => 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 { try { 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 = ({ filterArgs }) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -1221,8 +1231,35 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { releaseProcessedQr(latest); 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) { setError(t("Scanned item is not found in current line")); startTransition(() => { diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index 40bfbe2f..1b1fb889 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -60,6 +60,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; @@ -252,7 +253,7 @@ interface PolInputResult { 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 = ({ po, warehouse, printerCombo }) => { const cameras = useContext(CameraContext); const { data: session } = useSession(); @@ -302,16 +303,43 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { const [selectedRow, setSelectedRow] = useState(null); const [stockInLine, setStockInLine] = useState([]); const [processedQty, setProcessedQty] = useState(0); + /** Tracks user/nav selection so query patches via history.replaceState stay authoritative. */ + const selectedPolIdRef = useRef(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(() => { - 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]); const router = useRouter(); @@ -465,9 +493,10 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { }); setRows(result.pol || []); if (result.pol && result.pol.length > 0) { - const targetPolId = preferredPolId ?? selectedRow?.id; + const targetPolId = preferredPolId ?? selectedPolIdRef.current ?? selectedRow?.id; const targetPol = result.pol.find((p) => p.id === targetPolId) ?? result.pol[0]; + selectedPolIdRef.current = targetPol.id; setSelectedRow(targetPol); setStockInLine(targetPol.stockInLine); setProcessedQty(targetPol.processed); @@ -482,6 +511,7 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { const handlePoSelect = useCallback( async (selectedPo: PoResult) => { if (selectedPo.id === selectedPoId) return; + selectedPolIdRef.current = null; setSelectedPoId(selectedPo.id); await fetchPoDetail(selectedPo.id.toString()); const newSelectedIds = selectedIdsParam || selectedPo.id.toString(); @@ -570,13 +600,6 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { () => returnWeightUnit(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(() => { // `processedQty` comes from putAwayLines (stock unit). // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. @@ -595,23 +618,22 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); }, [polInputList, row.id]); - const handleRowSelect = () => { - // setSelectedRowId(row.id); - setSelectedRow(row); - setStockInLine(row.stockInLine); - setProcessedQty(row.processed); - }; const changeStockInLines = useCallback( (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( @@ -644,7 +666,12 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { ...prev, [row.id]: { lotNo: "", dnQty: "" }, })); + selectedPolIdRef.current = row.id; setSelectedRow(row); + patchPoEditQuery((params) => { + params.set("polId", String(row.id)); + params.delete("stockInLineId"); + }); fetchPoDetail(selectedPoId.toString(), true, row.id); } console.log(res); @@ -662,7 +689,7 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { doSubmit(); } }, - [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput], + [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput, patchPoEditQuery], ); const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { @@ -746,8 +773,6 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { )} e.stopPropagation()} /> diff --git a/src/components/PoDetail/PoInputGrid.tsx b/src/components/PoDetail/PoInputGrid.tsx index e2771b79..bd67ae59 100644 --- a/src/components/PoDetail/PoInputGrid.tsx +++ b/src/components/PoDetail/PoInputGrid.tsx @@ -36,7 +36,7 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import { PurchaseOrderLine } from "@/app/api/po"; import { StockInLine } from "@/app/api/stockIn"; import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import { returnWeightUnit, 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({ // qc, setRows, @@ -204,7 +205,6 @@ function PoInputGrid({ StockInLine & { qcResult?: QcResult[] } & { escalationResult?: EscalationResult[] } >(); const pathname = usePathname() - const router = useRouter(); const searchParams = useSearchParams(); const [qcOpen, setQcOpen] = useState(false); @@ -384,15 +384,35 @@ function PoInputGrid({ // ); const [newOpen, setNewOpen] = useState(false); - const stockInLineId = searchParams.get("stockInLineId"); + const stockInLineIdFromNext = searchParams.get("stockInLineId"); 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") { - 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); if (updatedStockInLine?.id != null) { @@ -403,7 +423,7 @@ function PoInputGrid({ (prev || []).map((p) => (p.id === updatedStockInLine.id ? { ...p, ...updatedStockInLine } : p)) ); } - }, [pathname, searchParams]); + }, [patchQuery, setStockInLine]); // Open modal const openNewModal = useCallback(() => { @@ -413,42 +433,67 @@ function PoInputGrid({ // Button handler to update the URL and open the modal const handleNewQC = useCallback( (id: GridRowId, params: any) => async() => { - // setBtnIsLoading(true); + if (!params?.row) return; setRowModesModel((prev) => ({ ...prev, [id]: { mode: GridRowModes.View }, })); - - // const qcResult = await fetchQcDefaultValue(id); - // const escResult = await fetchEscalationLogsByStockInLines([Number(id)]); + setModalInfo(() => ({ ...params.row, - // qcResult: qcResult, - // escResult: escResult, 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(() => { - 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( (id: GridRowId, params: any) => () => { // setBtnIsLoading(true); diff --git a/src/components/PoDetail/QcStockInModal.tsx b/src/components/PoDetail/QcStockInModal.tsx index 45b361ea..d817a603 100644 --- a/src/components/PoDetail/QcStockInModal.tsx +++ b/src/components/PoDetail/QcStockInModal.tsx @@ -71,7 +71,7 @@ interface CommonProps extends Omit { interface Props extends CommonProps { // 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 = ({ open, onClose, diff --git a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx index 2ae0d900..c6bb8d31 100644 --- a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx +++ b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Box, Typography, @@ -37,11 +37,17 @@ import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { fetchDrinkProductionQty, + fetchDrinkShipmentQty, DrinkProductionQtyResponse, DrinkProductionQtyJobOrderDetail, + DrinkShipmentQtyResponse, + DrinkShipmentQtyDeliveryDetail, } from "@/app/api/jo/actions"; 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 分鐘 @@ -55,8 +61,6 @@ const JO_STATUS_FILTER_VALUES = [ "completed", ] as const; -type DrinkViewMode = "actual" | "planned"; - const formatQty = (qty: number | null | undefined): string => { if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); @@ -100,10 +104,16 @@ const ProcessSummaryTimeText: React.FC<{ value: unknown }> = ({ value }) => { const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => `${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 */ const DrinkProductionQtyDashboard: React.FC = () => { - const { t } = useTranslation(["common", "jo", "productionProcess"]); + const { t } = useTranslation(["common", "jo", "do", "productionProcess"]); const [data, setData] = useState([]); + const [shipmentData, setShipmentData] = useState( + [], + ); const [loading, setLoading] = useState(true); const [selectedDate, setSelectedDate] = useState(dayjs()); const [joStatusFilter, setJoStatusFilter] = useState(""); @@ -117,21 +127,62 @@ const DrinkProductionQtyDashboard: React.FC = () => { ); 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 () => { setLoading(true); + const dateStr = selectedDate.format("YYYY-MM-DD"); 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()); setLastDataRefreshTime(dayjs()); refreshCountRef.current += 1; } catch (error) { console.error("Error fetching drink production qty:", error); setData([]); + setShipmentData([]); setExpandedRowKeys(new Set()); } finally { 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 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 ( + + ); + }; + return ( @@ -216,29 +314,31 @@ const DrinkProductionQtyDashboard: React.FC = () => { /> - - - {t("Job Order Status")} - - { + setJoStatusFilter(String(e.target.value)); + }} + > + + {t("All")} - ))} - - + {JO_STATUS_FILTER_VALUES.map((v) => ( + + {t(v, { ns: "jo" })} + + ))} + + + )} @@ -246,11 +346,14 @@ const DrinkProductionQtyDashboard: React.FC = () => { variant="outlined" size="small" startIcon={} - disabled={loading || data.length === 0} + disabled={ + loading || (isShipment ? shipmentData.length === 0 : data.length === 0) + } sx={{ display: "none" }} onClick={() => { exportDrinkProductionQtyXlsx({ data, + shipmentData, viewMode, selectedDate: selectedDate.format("YYYY-MM-DD"), statusFilter: joStatusFilter, @@ -298,6 +401,9 @@ const DrinkProductionQtyDashboard: React.FC = () => { {t("Drink detail mode: planned")} + + {t("Drink detail mode: shipment")} + @@ -347,22 +453,185 @@ const DrinkProductionQtyDashboard: React.FC = () => { - {isPlanned - ? t("Planned Output Qty") - : t("Stock Req. Qty")} + {qtyHeaders.left} - {isPlanned - ? t("Actual Output Qty") - : t("Production Qty")} + {qtyHeaders.right} - {data.length === 0 ? ( + {isShipment ? ( + shipmentData.length === 0 ? ( + + + + {t("No data available")} + + + + ) : ( + 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 ( + + + + {hasExpandable ? ( + toggleRowExpanded(rowKey)} + > + {isExpanded ? ( + + ) : ( + + )} + + ) : null} + + + + {row.itemCode || "-"} + + + + + {row.itemName || "-"} + + + + + {row.uom || "-"} + + + + + {formatQty(row.totalOrderQty)} + + + + + {formatQty(row.totalShippedQty)} + + + + {hasExpandable && ( + + + + + + + + + {t("Delivery Order Code", { + ns: "do", + })} + + + {t("Delivery Order Status", { + ns: "do", + })} + + + {t("Shop Name", { ns: "do" })} + + + {t("Delivery Date", { ns: "do" })} + + + {t("Shipment Order Qty")} + + + {t("Shipped Qty")} + + + + + {deliveries.map((delivery) => ( + + + {delivery.deliveryOrderId > 0 ? ( + + {delivery.deliveryOrderCode || + `DO-${delivery.deliveryOrderId}`} + + ) : ( + delivery.deliveryOrderCode || + "-" + )} + + + {renderDoStatusChip( + delivery.deliveryOrderStatus, + )} + + + {delivery.shopName || + delivery.shopCode || + "-"} + + + {formatProductionDate( + delivery.deliveryDate, + )} + + + {formatQty(delivery.orderQty)} + + + {formatQty(delivery.shippedQty)} + + + ))} + +
+
+
+
+
+ )} +
+ ); + }) + ) + ) : filteredItemRows.length === 0 ? ( { ) : ( - data.map((row, idx) => { + filteredItemRows.map(({ row, jobOrders, totalReqQty, totalQty }, idx) => { const rowKey = getRowKey(row, idx); - const allJobOrders = row.jobOrders ?? []; - const jobOrders = filterJobOrders(allJobOrders); const isExpanded = expandedRowKeys.has(rowKey); - const hasExpandable = - allJobOrders.length > 0 && - (joStatusFilter === "" || jobOrders.length > 0); + const hasExpandable = jobOrders.length > 0; return ( @@ -422,12 +687,12 @@ const DrinkProductionQtyDashboard: React.FC = () => { - {formatQty(row.totalReqQty)} + {formatQty(totalReqQty)} - {formatQty(row.totalQty)} + {formatQty(totalQty)} diff --git a/src/components/ProductionProcess/ProductionProcessDetail.tsx b/src/components/ProductionProcess/ProductionProcessDetail.tsx index 87b13fa5..7700330f 100644 --- a/src/components/ProductionProcess/ProductionProcessDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessDetail.tsx @@ -66,7 +66,7 @@ interface ProductProcessDetailProps { 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 = ({ jobOrderId, onBack, @@ -77,7 +77,7 @@ const ProductionProcessDetail: React.FC = ({ const { t } = useTranslation(["productionProcess", "common"]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const abilities = session?.abilities ?? session?.user?.abilities ?? []; - /** 「已完成」(Just Pass):僅 ADMIN */ + /** 「跳過」(Just Pass):僅 ADMIN */ const canAdminPass = hasAbility(abilities, AUTH.ADMIN); const currentUserId = session?.id ? parseInt(session.id) : undefined; const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); @@ -666,7 +666,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { const isPaused = statusLower === 'paused'; const isPending = statusLower === 'pending' || status === ''; const isPass = statusLower === 'pass'; - const isPassDisabled = isCompleted || isPass || !canAdminPass; + const isAutoPass = statusLower === 'autopass' || statusLower === 'auto pass'; + const isPassDisabled = isCompleted || isPass || isAutoPass || !canAdminPass; return ( @@ -773,6 +774,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { ) : isPaused ? ( + ) : isAutoPass ? ( + ) : isPass ? ( ) : ( diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index e8a85f9a..665d6ba9 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -178,7 +178,7 @@ function isWaitingQcPutAway( 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 = ({ onSelectProcess, printerCombo, @@ -447,7 +447,7 @@ const ProductProcessList: React.FC = ({ return productionCache; }, [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 byJobOrder = new Map(); for (const p of tabProcesses) { @@ -459,8 +459,8 @@ const ProductProcessList: React.FC = ({ const result = new Map(); 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) => { diff --git a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx index 954dc843..096acbb5 100644 --- a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx +++ b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx @@ -55,6 +55,7 @@ interface ProductionProcessStepExecutionProps { jobOrderId?: number; // ✅ 添加 } +/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.0 | 2026-08-10 */ const ProductionProcessStepExecution: React.FC = ({ lineId, onBack, @@ -62,9 +63,18 @@ const ProductionProcessStepExecution: React.FC { - const { t } = useTranslation( ["common","jo"]); + const { t } = useTranslation( ["common","jo","productionProcess"]); const [lineDetail, setLineDetail] = useState(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 { - // 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"); setRemainingTime(null); setIsOverTime(false); @@ -553,9 +571,13 @@ const ProductionProcessStepExecution: React.FC - {lineDetail?.status === "Pass" ? ( + {isAutoPassStatus ? ( - {t("Passed Step")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) + {t("Auto Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) + + ) : isPassStatus ? ( + + {t("Just Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) ) : ( @@ -618,13 +640,13 @@ const ProductionProcessStepExecution: React.FC{t("Defect")}{t("(1)")} - {lineDetail.defectQty} + {lineDetail?.defectQty} - {lineDetail.defectUom || "-"} + {lineDetail?.defectUom || "-"} - {lineDetail.defectDescription || "-"} + {lineDetail?.defectDescription || "-"} @@ -632,13 +654,13 @@ const ProductionProcessStepExecution: React.FC{t("Defect")}{t("(2)")} - {lineDetail.defectQty2} + {lineDetail?.defectQty2} - {lineDetail.defectUom2 || "-"} + {lineDetail?.defectUom2 || "-"} - {lineDetail.defectDescription2 || "-"} + {lineDetail?.defectDescription2 || "-"} @@ -646,13 +668,13 @@ const ProductionProcessStepExecution: React.FC{t("Defect")}{t("(3)")} - {lineDetail.defectQty3} + {lineDetail?.defectQty3} - {lineDetail.defectUom3 || "-"} + {lineDetail?.defectUom3 || "-"} - {lineDetail.defectDescription3 || "-"} + {lineDetail?.defectDescription3 || "-"} @@ -660,10 +682,10 @@ const ProductionProcessStepExecution: React.FC{t("Scrap")} - {lineDetail.scrapQty} + {lineDetail?.scrapQty} - {lineDetail.scrapUom || "-"} + {lineDetail?.scrapUom || "-"}
diff --git a/src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts b/src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts index 8584a884..4a3abeca 100644 --- a/src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts +++ b/src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts @@ -4,9 +4,10 @@ import { exportMultiSheetToXlsx } from "@/app/(main)/chart/_components/exportCha import type { DrinkProductionQtyJobOrderDetail, DrinkProductionQtyResponse, + DrinkShipmentQtyResponse, } from "@/app/api/jo/actions"; -type DrinkViewMode = "actual" | "planned"; +export type DrinkViewMode = "actual" | "planned" | "shipment"; const formatDateTime = (value: unknown): string => { if (value == null || value === "") return ""; @@ -27,28 +28,102 @@ const formatDate = (value: string | null | undefined): string => { export type ExportDrinkProductionQtyParams = { data: DrinkProductionQtyResponse[]; + shipmentData?: DrinkShipmentQtyResponse[]; viewMode: DrinkViewMode; selectedDate: string; statusFilter: string; 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({ data, + shipmentData = [], viewMode, selectedDate, statusFilter, t, }: ExportDrinkProductionQtyParams): void { - const viewLabel = - viewMode === "planned" - ? t("Drink detail mode: planned") - : t("Drink detail mode: actual"); + const viewLabel = viewModeLabel(viewMode, t); const statusFilterLabel = statusFilter ? t(statusFilter, { ns: "jo", defaultValue: statusFilter }) : t("All"); const exportedAt = dayjs().format("YYYY-MM-DD HH:mm:ss"); + if (viewMode === "shipment") { + const doRows: Record[] = []; + 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 = ( jobOrders: DrinkProductionQtyJobOrderDetail[], ): DrinkProductionQtyJobOrderDetail[] => { diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index 9f093c2e..8607d15c 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -72,7 +72,7 @@ interface Props extends CommonProps { // 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 = ({ open, onClose, diff --git a/src/components/UserSearch/UserExcelSheetView.tsx b/src/components/UserSearch/UserExcelSheetView.tsx index 23107c30..86ceae45 100644 --- a/src/components/UserSearch/UserExcelSheetView.tsx +++ b/src/components/UserSearch/UserExcelSheetView.tsx @@ -328,7 +328,29 @@ const UserExcelSheetView: React.FC = ({ users }) => { } catch (error) { console.error("Failed to save user authorities", error); 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 { setIsSaving(false); saveInFlightRef.current = false; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index cb8b2e3e..d55433c5 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -12,6 +12,7 @@ declare module "next-auth" { id?: string; /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ exp?: number; + locale?: string; } interface User { @@ -19,6 +20,7 @@ declare module "next-auth" { accessToken: string | null; refreshToken?: string; abilities: string[]; + locale?: string; } } @@ -28,6 +30,7 @@ declare module "next-auth/jwt" { accessToken: string | null; refreshToken?: string; abilities: string[]; + locale?: string; } } @@ -70,13 +73,24 @@ export const authOptions: AuthOptions = { }, callbacks: { // Persist custom fields into the JWT token - async jwt({ token, user }) { + async jwt({ token, user, trigger, session }) { // First sign-in: `user` is available if (user) { token.id = user.id ?? token.sub; // fallback to sub if no id token.accessToken = user.accessToken; token.refreshToken = user.refreshToken; 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 @@ -91,6 +105,7 @@ export const authOptions: AuthOptions = { session.refreshToken = token.refreshToken as string | undefined; session.abilities = token.abilities as string[]; 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 if (session.user) { @@ -107,5 +122,6 @@ export type SessionWithTokens = Session & { abilities: string[]; /** Backend / JWT subject — often numeric string or number */ id?: string | number; + locale?: string; }; export default authOptions; \ No newline at end of file diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index 47dd05f7..fcf74b84 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -17,6 +17,10 @@ export interface ReportField { allowInput?: boolean; // Allow user to input custom values (for select types) /** When checkbox is checked, disable these field names (by `name`) */ 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'; @@ -30,6 +34,7 @@ export interface ReportDefinition { fields: ReportField[]; } +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ export const REPORTS: ReportDefinition[] = [ //{ // id: "rep-001", @@ -79,10 +84,37 @@ export const REPORTS: ReportDefinition[] = [ title: "入倉追蹤報告", apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-in-traceability`, fields: [ - { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", 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: "樓層 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", title: "庫存明細報告", 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}, ] }, + /* 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", @@ -205,6 +324,7 @@ export const REPORTS: ReportDefinition[] = [ { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, ] }, + { id: "rep-014", @@ -233,19 +353,31 @@ export const REPORTS: ReportDefinition[] = [ ] }, + { id: "rep-010", + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ title: "庫存品質檢測報告", apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-item-qc-fail`, 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, 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: "QC 項目範圍", + name: "qcItemScope", + type: "select", + required: false, + options: [ + { label: "全部 QC 項目", value: "all" }, + { label: "只包含溫度濕度", value: "measurable" }, + ], + }, ] }, { id: "rep-013", @@ -383,4 +515,31 @@ export const REPORTS: ReportDefinition[] = [ { 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" }, + ], + }, + ], + }, ] \ No newline at end of file diff --git a/src/i18n/en/chart.json b/src/i18n/en/chart.json index 0eb11e4d..189903a2 100644 --- a/src/i18n/en/chart.json +++ b/src/i18n/en/chart.json @@ -7,6 +7,8 @@ "board_processLive": "Process Live Board", "dateRange_lastDays": "Last {{d}} days", "delivery_colAvgMin": "Avg Min/Order", + "delivery_colItemKindCount": "Item Kind Count", + "delivery_colItemQtyPicked": "Item Qty Picked", "delivery_colPickCount": "Pick Count", "delivery_colStaff": "Staff", "delivery_colTotalMin": "Total Min", @@ -19,7 +21,7 @@ "delivery_ordersByDate": "Delivery Orders by Date", "delivery_ordersByDate_export": "Delivery_Orders_By_Date", "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_staffPerformanceTitle": "Staff Delivery Performance (Daily Pick Count & Duration)", "delivery_staffPlaceholder": "Leave empty for all", diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index da54bee9..61a464d3 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -87,6 +87,7 @@ "Select Date": "選擇日期", "Session expired or unauthorized.": "工作階段已過期或未經授權。", "Sign out": "Sign out", + "Language": "Language", "Status": "狀態", "Stock Qty": "庫存數量", "Supporting Document": "證明文件", diff --git a/src/i18n/en/itemDefaultShelfLife.json b/src/i18n/en/itemDefaultShelfLife.json new file mode 100644 index 00000000..7f90cf3c --- /dev/null +++ b/src/i18n/en/itemDefaultShelfLife.json @@ -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" +} diff --git a/src/i18n/en/itemTracing.json b/src/i18n/en/itemTracing.json index 281b85ac..5a218103 100644 --- a/src/i18n/en/itemTracing.json +++ b/src/i18n/en/itemTracing.json @@ -286,12 +286,13 @@ "code.joStatus.storing": "Storing", "code.joStatus.PARTIAL": "Partial", "code.joStatus.partial": "Partial", - "code.productionStatus.Pass": "Pass", + "code.productionStatus.Pass": "Skip", "code.productionStatus.Completed": "Completed", "code.productionStatus.Pending": "Pending", "code.productionStatus.Paused": "Paused", "code.productionStatus.InProgress": "In progress", "code.productionStatus.Skip": "Skip", + "code.productionStatus.autoPass": "Auto Skipped", "continuousScanBlocked": "Finish current scan first", "nodeJoOut": "Job order material issue", "nodePoOut": "Purchase pick", diff --git a/src/i18n/en/jo.json b/src/i18n/en/jo.json index d8f38ccb..0d6b2128 100644 --- a/src/i18n/en/jo.json +++ b/src/i18n/en/jo.json @@ -272,7 +272,9 @@ "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", + "Pass": "Skip", + "Just Pass": "Skip", + "Auto Pass": "Auto Skipped", "Passed Step": "Passed Step", "Pause": "Pause", "Pause Reason": "Pause Reason", diff --git a/src/i18n/en/navigation.json b/src/i18n/en/navigation.json index 7d852f0e..cb72caca 100644 --- a/src/i18n/en/navigation.json +++ b/src/i18n/en/navigation.json @@ -36,6 +36,7 @@ "nav.settings.user": "User", "nav.settings.clientMonitor": "Device Connection Monitor", "nav.settings.items": "Items", + "nav.settings.itemDefaultShelfLife": "Item default shelf life", "nav.settings.equipment": "Equipment", "nav.settings.warehouse": "Warehouse", "nav.settings.printer": "Printer", diff --git a/src/i18n/en/productionProcess.json b/src/i18n/en/productionProcess.json index bed5ec43..cf4c91dc 100644 --- a/src/i18n/en/productionProcess.json +++ b/src/i18n/en/productionProcess.json @@ -99,6 +99,14 @@ "Drink detail mode label": "Detail display", "Drink detail mode: actual": "Actual 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", "Actual Output Qty": "Actual output", "Latest Start By": "Latest start by", @@ -112,9 +120,11 @@ "QC users": "QC users", "Put away users": "Put-away users", "Excel sheet: JO detail": "JO detail", + "Excel sheet: DO detail": "DO detail", "Excel sheet: process people": "Process people", "Excel sheet: item summary": "Item summary", "JO count": "JO count", + "DO count": "DO count", "Process seq": "Process seq", "Handler": "Handler", "Goods Name": "Goods Name", diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index c13c6113..c80ad1a9 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -1,13 +1,338 @@ { - "Report": "Report", "title": "Report Management", "selectReport": "Select Report", "reportList": "Report List", "selectReportHelper": "Select a report", "searchCriteria": "Search Criteria", + "searchCriteriaWithTitle": "Search Criteria: {{title}}", "downloadPdf": "Download Report (PDF)", "downloadExcel": "Download Report (Excel)", "generatingPdf": "Generating PDF...", "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" + } + } } diff --git a/src/i18n/en/user.json b/src/i18n/en/user.json index ff056e65..4ebea06d 100644 --- a/src/i18n/en/user.json +++ b/src/i18n/en/user.json @@ -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" + "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." } diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 39c92419..a93475bd 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -2,12 +2,14 @@ import { cookies, headers } from "next/headers"; import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; import resourcesToBackend from "i18next-resources-to-backend"; import { getServerSession } from "next-auth"; -import { authOptions } from "@/config/authConfig"; +import { authOptions, SessionWithTokens } from "@/config/authConfig"; import I18nClientProvider from "./I18nClientProvider"; 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 => { // Logic to get language preference from cookies/headers/session @@ -21,11 +23,13 @@ export const detectLanguage = async (): Promise => { const headersList = headers(); //console.time("[i18n] detectLanguage total"); //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({ - supportedLanguages: SUPPORTED_LANGUAGES, + supportedLanguages: [...SUPPORTED_LANGUAGES], fallbackLanguage: FALLBACK_LANG, acceptLanguageHeader: headersList.get("accept-language") || undefined, serverCookies: cookiesObj, diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts new file mode 100644 index 00000000..cd846963 --- /dev/null +++ b/src/i18n/locale.ts @@ -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`; +} diff --git a/src/i18n/zh/chart.json b/src/i18n/zh/chart.json index d0fc2ddb..1c964bb7 100644 --- a/src/i18n/zh/chart.json +++ b/src/i18n/zh/chart.json @@ -7,6 +7,8 @@ "board_processLive": "工序即時看板", "dateRange_lastDays": "最近 {{d}} 天", "delivery_colAvgMin": "平均分鐘/單", + "delivery_colItemKindCount": "總揀貨款數", + "delivery_colItemQtyPicked": "揀貨數量", "delivery_colPickCount": "揀單數", "delivery_colStaff": "員工", "delivery_colTotalMin": "總分鐘", @@ -19,7 +21,7 @@ "delivery_ordersByDate": "按日期發貨單數量", "delivery_ordersByDate_export": "發貨單數量_按日期", "delivery_staff": "員工", - "delivery_staffPerfCaption": "週期內每人揀單數及總耗時(首揀至完成)", + "delivery_staffPerfCaption": "週期內每人揀單數、總揀貨款數、揀貨數量及總耗時(首揀至完成)", "delivery_staffPerfDateError": "員工發貨績效的起始日期不能晚於結束日期", "delivery_staffPerformanceTitle": "員工發貨績效(每日揀貨數量與耗時)", "delivery_staffPlaceholder": "不選則全部", diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index a9da7006..6753daf4 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -90,6 +90,7 @@ "Select Date": "選擇日期", "Session expired or unauthorized.": "工作階段已過期或未經授權。", "Sign out": "登出", + "Language": "語言", "Status": "狀態", "Stock Qty": "庫存數量", "Supporting Document": "證明文件", diff --git a/src/i18n/zh/itemDefaultShelfLife.json b/src/i18n/zh/itemDefaultShelfLife.json new file mode 100644 index 00000000..e54c8923 --- /dev/null +++ b/src/i18n/zh/itemDefaultShelfLife.json @@ -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": "否" +} diff --git a/src/i18n/zh/itemTracing.json b/src/i18n/zh/itemTracing.json index 031dee0a..d2a81834 100644 --- a/src/i18n/zh/itemTracing.json +++ b/src/i18n/zh/itemTracing.json @@ -286,12 +286,13 @@ "code.joStatus.storing": "待QC上架", "code.joStatus.PARTIAL": "部分完成", "code.joStatus.partial": "部分完成", - "code.productionStatus.Pass": "通過", + "code.productionStatus.Pass": "跳過", "code.productionStatus.Completed": "完成", "code.productionStatus.Pending": "待處理", "code.productionStatus.Paused": "已暫停", "code.productionStatus.InProgress": "進行中", "code.productionStatus.Skip": "跳過", + "code.productionStatus.autoPass": "已自動跳過", "continuousScanBlocked": "請先完成目前掃描", "nodeJoOut": "工單提料", "nodePoOut": "採購提料", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index 8615825c..e814a4b4 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -10,7 +10,8 @@ "Actual Pick Qty": "實際提料數量", "Add Bag": "新增包裝袋", "Add Record": "添加記錄", - "Just Pass": "通過", + "Just Pass": "跳過", + "Auto Pass": "已自動跳過", "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", "Add some entries!": "請添加條目", "All": "全部", @@ -283,7 +284,7 @@ "Overview": "總覽", "Packaging": "提料中", "Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。", - "Pass": "通過", + "Pass": "跳過", "Passed Step": "通過步驟", "Pause": "暫停", "Pause Reason": "暫停原因", diff --git a/src/i18n/zh/navigation.json b/src/i18n/zh/navigation.json index cd01b472..afbc1d79 100644 --- a/src/i18n/zh/navigation.json +++ b/src/i18n/zh/navigation.json @@ -79,6 +79,7 @@ "nav.settings.importExcel": "Excel 匯入", "nav.settings.importTesting": "匯入測試", "nav.settings.items": "物品", + "nav.settings.itemDefaultShelfLife": "物品預設保質期", "nav.settings.masterDataIssues": "BOM / 物料單位問題", "nav.settings.priceInquiry": "價格查詢", "nav.settings.printer": "列印機", diff --git a/src/i18n/zh/productionProcess.json b/src/i18n/zh/productionProcess.json index d41d58f6..2a841a6e 100644 --- a/src/i18n/zh/productionProcess.json +++ b/src/i18n/zh/productionProcess.json @@ -104,6 +104,14 @@ "Drink detail mode label": "明細顯示", "Drink detail mode: actual": "實際生產", "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": "預計生產數量", "Actual Output Qty": "實際生產數量", "Latest Start By": "最晚開工時間", @@ -117,9 +125,11 @@ "QC users": "QC人員", "Put away users": "上架人員", "Excel sheet: JO detail": "JO明細", + "Excel sheet: DO detail": "DO明細", "Excel sheet: process people": "工序人員", "Excel sheet: item summary": "貨品彙總", "JO count": "工單筆數", + "DO count": "送貨單筆數", "Process seq": "工序序號", "Handler": "處理人", "Goods Name": "貨品名稱", diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index b3d5b8e0..3812c6a3 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -1,13 +1,338 @@ { - "Report": "報告", "title": "報告管理", "selectReport": "選擇報告", "reportList": "報告列表", "selectReportHelper": "選擇報告", "searchCriteria": "搜索條件", + "searchCriteriaWithTitle": "搜索條件: {{title}}", "downloadPdf": "下載報告 (PDF)", "downloadExcel": "下載報告 (Excel)", "generatingPdf": "生成 PDF...", "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": "其他" + } + } } diff --git a/src/i18n/zh/user.json b/src/i18n/zh/user.json index 2f93027c..3eb62410 100644 --- a/src/i18n/zh/user.json +++ b/src/i18n/zh/user.json @@ -46,5 +46,12 @@ "Failed to search by name": "依名稱搜尋失敗", "Failed to search by username": "依使用者名稱搜尋失敗", "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": "伺服器錯誤,請稍後再試。" }