| @@ -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": "每日按員工單數", | |||
| @@ -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<Criteria>(defaultCriteria); | |||
| const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | |||
| @@ -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<Record<string, boolean>>({}); | |||
| @@ -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<string, { orderCount: number; totalMinutes: number }>(); | |||
| 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() { | |||
| <ChartCard | |||
| title="員工發貨績效(每日揀貨數量與耗時)" | |||
| exportFilename="員工發貨績效" | |||
| exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))} | |||
| exportData={chartData.staffPerf.map((r) => ({ | |||
| 日期: r.date, | |||
| 員工: r.staffName, | |||
| 揀單數: r.orderCount, | |||
| 總揀貨款數: r.itemKindCount, | |||
| 總揀貨件數: r.itemQtyPicked, | |||
| 總分鐘: r.totalMinutes, | |||
| }))} | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| @@ -388,7 +417,7 @@ export default function DeliveryChartPage() { | |||
| <> | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | |||
| 週期內每人揀單數及總耗時(首揀至完成) | |||
| 週期內每人揀單數、總揀貨款數、總揀貨件數及總耗時(首揀至完成) | |||
| </Typography> | |||
| <Box | |||
| component="table" | |||
| @@ -409,6 +438,8 @@ export default function DeliveryChartPage() { | |||
| <tr> | |||
| <th>員工</th> | |||
| <th>揀單數</th> | |||
| <th>總揀貨款數</th> | |||
| <th>總揀貨件數</th> | |||
| <th>總分鐘</th> | |||
| <th>平均分鐘/單</th> | |||
| </tr> | |||
| @@ -416,13 +447,15 @@ export default function DeliveryChartPage() { | |||
| <tbody> | |||
| {staffPerfByStaff.length === 0 ? ( | |||
| <tr> | |||
| <td colSpan={4}>無數據</td> | |||
| <td colSpan={6}>無數據</td> | |||
| </tr> | |||
| ) : ( | |||
| staffPerfByStaff.map((row) => ( | |||
| <tr key={row.staffName}> | |||
| <td>{row.staffName}</td> | |||
| <td>{row.orderCount}</td> | |||
| <td>{row.itemKindCount}</td> | |||
| <td>{row.itemQtyPicked}</td> | |||
| <td>{row.totalMinutes}</td> | |||
| <td>{row.avgMinutesPerOrder}</td> | |||
| </tr> | |||
| @@ -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 ( | |||
| <I18nProvider namespaces={["m18Sync", "navigation", "common"]}> | |||
| {children} | |||
| @@ -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) | |||
| </Typography> | |||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | |||
| ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||
| Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||
| </Typography> | |||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | |||
| @@ -38,7 +38,7 @@ const production: React.FC = async () => { | |||
| {t("Create Process")} | |||
| </Button> */} | |||
| </Stack> | |||
| <I18nProvider namespaces={["production","productionProcess","navigation","common","purchaseOrder","jo","dashboard"]}> | |||
| <I18nProvider namespaces={["production","productionProcess","navigation","common","purchaseOrder","jo","do","dashboard"]}> | |||
| <ProductionProcessPage printerCombo={printerCombo} /> {/* Use new component */} | |||
| </I18nProvider> | |||
| </> | |||
| @@ -36,7 +36,7 @@ const productionProcess: React.FC = async () => { | |||
| {t("Create Process")} | |||
| </Button> */} | |||
| </Stack> | |||
| <I18nProvider namespaces={["productionProcess","navigation","common","purchaseOrder","jo","dashboard"]}> | |||
| <I18nProvider namespaces={["productionProcess","navigation","common","purchaseOrder","jo","do","dashboard"]}> | |||
| <Suspense fallback={<ProductionProcessLoading />}> | |||
| <ProductionProcessPage printerCombo={printerCombo} /> | |||
| </Suspense> | |||
| @@ -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<string, string> = { | |||
| 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 ( | |||
| <Grid container spacing={3}> | |||
| <Grid item {...gridSize}> | |||
| <TextField | |||
| fullWidth | |||
| label="QC 檢測日期:由 Last In Date Start" | |||
| label={field("lastInDateStart", "QC 檢測日期:由 QC Date Start")} | |||
| type="date" | |||
| value={criteria.lastInDateStart || ""} | |||
| onChange={(e) => onFieldChange("lastInDateStart", e.target.value)} | |||
| @@ -42,7 +49,7 @@ export default function ItemQcReportFilters({ | |||
| <Grid item {...gridSize}> | |||
| <TextField | |||
| fullWidth | |||
| label="QC 檢測日期:至 Last In Date End" | |||
| label={field("lastInDateEnd", "QC 檢測日期:至 QC Date End")} | |||
| type="date" | |||
| value={criteria.lastInDateEnd || ""} | |||
| onChange={(e) => onFieldChange("lastInDateEnd", e.target.value)} | |||
| @@ -53,19 +60,19 @@ export default function ItemQcReportFilters({ | |||
| <TextField | |||
| fullWidth | |||
| select | |||
| label="QC 類型" | |||
| value={criteria.qcType || ""} | |||
| label={field("qcType", "QC 類型")} | |||
| value={criteria.qcType || "all"} | |||
| onChange={(e) => onFieldChange("qcType", e.target.value)} | |||
| > | |||
| <MenuItem value="">全部</MenuItem> | |||
| <MenuItem value="IQC">IQC</MenuItem> | |||
| <MenuItem value="EPQC">EPQC</MenuItem> | |||
| <MenuItem value="all">{opt("qcType", "all", "全部")}</MenuItem> | |||
| <MenuItem value="IQC">{opt("qcType", "IQC", "IQC(採購)")}</MenuItem> | |||
| <MenuItem value="EPQC">{opt("qcType", "EPQC", "EPQC(工單)")}</MenuItem> | |||
| </TextField> | |||
| </Grid> | |||
| <Grid item {...gridSize}> | |||
| <TextField | |||
| fullWidth | |||
| label="貨品編號 Item Code" | |||
| label={field("itemCode", "貨品編號 Item Code")} | |||
| value={criteria.itemCode || ""} | |||
| onChange={(e) => onFieldChange("itemCode", e.target.value)} | |||
| placeholder="e.g. MJ0364" | |||
| @@ -75,17 +82,17 @@ export default function ItemQcReportFilters({ | |||
| <TextField | |||
| fullWidth | |||
| select | |||
| label="溫濕度數據篩選" | |||
| value={qcItemFilter} | |||
| onChange={(e) => onFieldChange("qcItemFilter", e.target.value)} | |||
| label={field("qcItemScope", "QC 項目範圍")} | |||
| value={qcItemFilter === "measurable" ? "measurable" : "all"} | |||
| onChange={(e) => onFieldChange("qcItemScope", e.target.value)} | |||
| > | |||
| <MenuItem value="measurable">只包含溫度濕度</MenuItem> | |||
| <MenuItem value="non_measurable">不包含溫度濕度</MenuItem> | |||
| <MenuItem value="all">{opt("qcItemScope", "all", "全部 QC 項目")}</MenuItem> | |||
| <MenuItem value="measurable">{opt("qcItemScope", "measurable", "只包含溫度濕度")}</MenuItem> | |||
| </TextField> | |||
| <FormHelperText> | |||
| {qcItemFilter === "measurable" | |||
| ? "僅匯出已填寫實測值的溫度/濕度 QC 項目。" | |||
| : "僅匯出非溫度/濕度之其他 QC 檢驗項目。"} | |||
| ? t("qcScopeHelpMeasurable") | |||
| : t("qcScopeHelpAll")} | |||
| </FormHelperText> | |||
| </Grid> | |||
| </Grid> | |||
| @@ -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<string, SvgIconComponent> = { | |||
| "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({ | |||
| }} | |||
| > | |||
| <Typography variant="subtitle1" fontWeight="bold"> | |||
| {category.title} | |||
| {categoryTitle(category.id, category.title)} | |||
| </Typography> | |||
| </Box> | |||
| <Box | |||
| @@ -152,7 +158,7 @@ function CategoryColumn({ | |||
| <Grid item xs={6} key={report.id}> | |||
| <ReportCard | |||
| reportId={report.id} | |||
| title={report.title} | |||
| title={reportTitle(report)} | |||
| accent={category.accent} | |||
| selected={selectedReportId === report.id} | |||
| onClick={() => onSelectReport(report.id)} | |||
| @@ -165,6 +171,7 @@ function CategoryColumn({ | |||
| ); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||
| export default function ReportSelectionDashboard({ | |||
| selectedReportId, | |||
| onSelectReport, | |||
| @@ -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<ItemCodeWithCategory[]>([]); | |||
| const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | |||
| @@ -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')} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -134,7 +136,7 @@ export default function SemiFGProductionAnalysisReport({ | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? '生成 Excel...' : '下載報告 (Excel)'} | |||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||
| </Button> | |||
| </div> | |||
| @@ -147,22 +149,22 @@ export default function SemiFGProductionAnalysisReport({ | |||
| > | |||
| <DialogTitle> | |||
| <Typography variant="h6" fontWeight="bold"> | |||
| 已選擇的物料編號以及列印成品/半成品生產分析報告 | |||
| {t('semiFgConfirmTitle')} | |||
| </Typography> | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| 請確認以下已選擇的物料編號及其類別: | |||
| {t('semiFgConfirmHint')} | |||
| </Typography> | |||
| <TableContainer component={Paper} variant="outlined"> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell> | |||
| <strong>物料編號及名稱</strong> | |||
| <strong>{t('semiFgColItem')}</strong> | |||
| </TableCell> | |||
| <TableCell> | |||
| <strong>類別</strong> | |||
| <strong>{t('semiFgColCategory')}</strong> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| @@ -187,7 +189,7 @@ export default function SemiFGProductionAnalysisReport({ | |||
| </TableContainer> | |||
| </DialogContent> | |||
| <DialogActions sx={{ p: 2 }}> | |||
| <Button onClick={() => setShowConfirmDialog(false)}>取消</Button> | |||
| <Button onClick={() => setShowConfirmDialog(false)}>{t('cancel')}</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => executeExport()} | |||
| @@ -196,11 +198,11 @@ export default function SemiFGProductionAnalysisReport({ | |||
| > | |||
| {loading | |||
| ? exportFormat === 'excel' | |||
| ? '生成 Excel...' | |||
| : '生成 PDF...' | |||
| ? t('generatingExcel') | |||
| : t('generatingPdf') | |||
| : exportFormat === 'excel' | |||
| ? '確認下載 Excel' | |||
| : '確認下載 PDF'} | |||
| ? t('confirmDownloadExcel') | |||
| : t('confirmDownloadPdf')} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| @@ -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<string, unknown> { | |||
| function emptySyncSheetRow( | |||
| L: ReturnType<typeof bomSyncLabels>, | |||
| note?: string, | |||
| ): Record<string, unknown> { | |||
| 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<string, unknown> { | |||
| function emptyMaterialSheetRow( | |||
| L: ReturnType<typeof bomSyncLabels>, | |||
| note?: string, | |||
| ): Record<string, unknown> { | |||
| 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<typeof bomSyncLabels>, | |||
| ): 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<string, unknown> { | |||
| const base = emptySyncSheetRow(""); | |||
| function toSyncExcelRow( | |||
| r: BomShopSyncRow, | |||
| L: ReturnType<typeof bomSyncLabels>, | |||
| ): Record<string, unknown> { | |||
| 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<string, unknown> { | |||
| const base = emptyMaterialSheetRow(""); | |||
| function toMaterialExcelRow( | |||
| r: BomShopSyncMaterialRow, | |||
| L: ReturnType<typeof bomSyncLabels>, | |||
| ): Record<string, unknown> { | |||
| 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<string, unknown> | |||
| export async function generateBomShopSyncReportExcel( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = "M18 BOM Shop 同步記錄", | |||
| t?: TFunction, | |||
| ): Promise<void> { | |||
| 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, | |||
| ); | |||
| @@ -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<string, string | number | undefined> { | |||
| const L = grnLabels(t); | |||
| const base: Record<string, string | number | undefined> = { | |||
| "PO No. / 訂單編號": r.poCode ?? "", | |||
| "Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "", | |||
| "Receipt Date / 收貨日期": r.receiptDate ?? "", | |||
| "Item Code / 物料編號": r.itemCode ?? "", | |||
| "Item Name / 物料名稱": r.itemName ?? "", | |||
| "Qty / 數量": formatQty( | |||
| r.acceptedQty ?? r.receivedQty ?? undefined | |||
| ), | |||
| "Demand Qty / 訂單數量": formatQty(r.demandQty), | |||
| "UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||
| "Supplier Lot No. 供應商批次": r.productLotNo ?? "", | |||
| "Expiry Date / 到期日": r.expiryDate ?? "", | |||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||
| "Supplier / 供應商": r.supplier ?? "", | |||
| "入倉狀態": r.status ?? "", | |||
| [L.poNo]: r.poCode ?? "", | |||
| [L.deliveryNoteNo]: r.deliveryNoteNo ?? "", | |||
| [L.receiptDate]: r.receiptDate ?? "", | |||
| [L.itemCode]: r.itemCode ?? "", | |||
| [L.itemName]: r.itemName ?? "", | |||
| [L.qty]: formatQty(r.acceptedQty ?? r.receivedQty ?? undefined), | |||
| [L.demandQty]: formatQty(r.demandQty), | |||
| [L.uom]: r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||
| [L.supplierLotNo]: r.productLotNo ?? "", | |||
| [L.expiryDate]: r.expiryDate ?? "", | |||
| [L.supplierCode]: r.supplierCode ?? "", | |||
| [L.supplier]: r.supplier ?? "", | |||
| [L.status]: r.status ?? "", | |||
| }; | |||
| if (includeFinancialColumns) { | |||
| 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<string, string | number | undefined>[] { | |||
| 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<string, string | number | undefined>[] = []; | |||
| 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<string, string>, | |||
| reportTitle: string = "PO 入倉記錄", | |||
| /** Only users with ADMIN authority should pass true (must match backend). */ | |||
| includeFinancialColumns: boolean = false | |||
| includeFinancialColumns: boolean = false, | |||
| t?: TFunction, | |||
| ): Promise<void> { | |||
| 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<string, unknown>[] }, | |||
| { name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record<string, unknown>[] }, | |||
| { name: L.sheetDetail, rows: excelRows as Record<string, unknown>[] }, | |||
| { name: L.sheetListedPo, rows: sheet2 as Record<string, unknown>[] }, | |||
| ], | |||
| filename | |||
| ); | |||
| } else { | |||
| exportChartToXlsx(excelRows as Record<string, unknown>[], filename, GRN_SHEET_DETAIL); | |||
| exportChartToXlsx(excelRows as Record<string, unknown>[], filename, L.sheetDetail); | |||
| } | |||
| } | |||
| @@ -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<string>(''); | |||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | |||
| const [loading, setLoading] = useState(false); | |||
| const excelInFlightRef = useRef(false); | |||
| const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | |||
| 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<string>(); | |||
| const mapped: { label: string; value: string }[] = [{ label: '全部', value: 'All' }]; | |||
| data.forEach((item: { stockTakeSectionDescription?: string; stockTakeSection?: string }) => { | |||
| const desc = (item.stockTakeSectionDescription || '').trim(); | |||
| if (!desc || seen.has(desc)) return; | |||
| seen.add(desc); | |||
| const section = (item.stockTakeSection || '').trim(); | |||
| mapped.push({ | |||
| label: section ? `${desc} (${section})` : desc, | |||
| value: desc, | |||
| }); | |||
| }); | |||
| return mapped; | |||
| })() | |||
| : data.map((item: any) => ({ | |||
| label: item.label || item.name || item.code || String(item), | |||
| value: item.value || item.code || String(item), | |||
| })) | |||
| : []; | |||
| setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); | |||
| @@ -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 ( | |||
| <> | |||
| <Box sx={{ p: 4, maxWidth: 1280, margin: '0 auto' }}> | |||
| <Typography variant="h4" gutterBottom fontWeight="bold"> | |||
| 報告管理 | |||
| {t('title')} | |||
| </Typography> | |||
| <ReportSelectionDashboard | |||
| @@ -404,15 +509,33 @@ export default function ReportPage() { | |||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | |||
| <CardContent> | |||
| <Typography variant="h6" color="primary" gutterBottom> | |||
| 搜索條件: {currentReport.title} | |||
| {t('searchCriteriaWithTitle', { title: reportTitle(currentReport) })} | |||
| </Typography> | |||
| <Divider sx={{ mb: 3 }} /> | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={isZh ? 'zh-hk' : 'en'} | |||
| localeText={ | |||
| isZh | |||
| ? { | |||
| fieldDayPlaceholder: () => '日', | |||
| fieldMonthPlaceholder: () => '月', | |||
| fieldYearPlaceholder: () => '年', | |||
| } | |||
| : undefined | |||
| } | |||
| > | |||
| <Grid container spacing={3}> | |||
| {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 ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <DatePicker | |||
| label={translatedLabel} | |||
| format={dateDisplayFormat} | |||
| value={parsed?.isValid() ? parsed : null} | |||
| minDate={field.minDate === 'today' ? dayjs().startOf('day') : undefined} | |||
| disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | |||
| onChange={(date) => { | |||
| handleFieldChange( | |||
| field.name, | |||
| date?.isValid() ? date.format(OUTPUT_DATE_FORMAT) : '', | |||
| ); | |||
| }} | |||
| slotProps={{ | |||
| textField: { | |||
| fullWidth: true, | |||
| sx: currentReport.id === 'rep-005' ? { | |||
| '& .MuiOutlinedInput-root': { | |||
| minHeight: '64px', | |||
| fontSize: '1rem' | |||
| }, | |||
| '& .MuiInputLabel-root': { | |||
| fontSize: '1rem' | |||
| } | |||
| } : {}, | |||
| }, | |||
| }} | |||
| /> | |||
| </Grid> | |||
| ); | |||
| } | |||
| if (field.type === 'checkbox') { | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| @@ -442,7 +600,7 @@ export default function ReportPage() { | |||
| } | |||
| /> | |||
| } | |||
| label={field.label} | |||
| label={translatedLabel} | |||
| /> | |||
| </Grid> | |||
| ); | |||
| @@ -498,8 +656,8 @@ export default function ReportPage() { | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| label={field.label} | |||
| placeholder={field.placeholder || "選擇或輸入物料編號"} | |||
| label={translatedLabel} | |||
| placeholder={field.placeholder || t('selectOrEnterItemCode')} | |||
| sx={currentReport.id === 'rep-005' ? { | |||
| '& .MuiOutlinedInput-root': { | |||
| minHeight: '64px', | |||
| @@ -541,11 +699,10 @@ export default function ReportPage() { | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <TextField | |||
| fullWidth | |||
| label={field.label} | |||
| label={translatedLabel} | |||
| type={field.type} | |||
| placeholder={field.placeholder} | |||
| disabled={disabledByCheckedCheckbox || disabledRep012Status} | |||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | |||
| disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | |||
| sx={currentReport.id === 'rep-005' ? { | |||
| '& .MuiOutlinedInput-root': { | |||
| minHeight: '64px', | |||
| @@ -624,15 +781,16 @@ export default function ReportPage() { | |||
| ); | |||
| })} | |||
| </Grid> | |||
| </LocalizationProvider> | |||
| <Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}> | |||
| {currentReport.id === 'rep-005' ? ( | |||
| <SemiFGProductionAnalysisReport | |||
| criteria={criteria} | |||
| requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)} | |||
| requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))} | |||
| loading={loading} | |||
| 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')} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -661,7 +819,7 @@ export default function ReportPage() { | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||
| </Button> | |||
| </> | |||
| ) : 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')} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -684,7 +842,7 @@ export default function ReportPage() { | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||
| </Button> | |||
| </> | |||
| ) : currentReport.responseType === 'excel' ? ( | |||
| @@ -696,7 +854,7 @@ export default function ReportPage() { | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| @@ -707,7 +865,7 @@ export default function ReportPage() { | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成報告..." : "下載報告 (PDF)"} | |||
| {loading ? t('generatingReport') : t('downloadPdf')} | |||
| </Button> | |||
| )} | |||
| </Box> | |||
| @@ -715,5 +873,49 @@ export default function ReportPage() { | |||
| </Card> | |||
| )} | |||
| </Box> | |||
| <Dialog | |||
| open={showNoDataDialog} | |||
| onClose={() => setShowNoDataDialog(false)} | |||
| maxWidth="sm" | |||
| fullWidth | |||
| PaperProps={{ | |||
| sx: { | |||
| borderRadius: 3, | |||
| px: 1, | |||
| }, | |||
| }} | |||
| > | |||
| <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pt: 3, px: 4, pb: 1.5 }}> | |||
| <Box | |||
| sx={{ | |||
| width: 36, | |||
| height: 36, | |||
| borderRadius: '50%', | |||
| bgcolor: 'warning.light', | |||
| color: 'warning.dark', | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| justifyContent: 'center', | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| <InfoOutlinedIcon fontSize="small" /> | |||
| </Box> | |||
| <Typography component="span" variant="h6" fontWeight="bold"> | |||
| {t('noDataFoundTitle')} | |||
| </Typography> | |||
| </DialogTitle> | |||
| <DialogContent sx={{ px: 4, pt: 0.5 }}> | |||
| <Typography color="text.secondary" sx={{ pl: 6.5 }}> | |||
| {t('noDataFoundHint')} | |||
| </Typography> | |||
| </DialogContent> | |||
| <DialogActions sx={{ justifyContent: 'center', px: 4, pb: 3, pt: 2 }}> | |||
| <Button variant="contained" onClick={() => setShowNoDataDialog(false)} autoFocus sx={{ minWidth: 96 }}> | |||
| {t('ok')} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| } | |||
| @@ -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", | |||
| @@ -0,0 +1,48 @@ | |||
| "use client"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import type { TFunction } from "i18next"; | |||
| import type { ReportDefinition, ReportField } from "@/config/reportConfig"; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||
| export function useReportLabels() { | |||
| const { t, i18n } = useTranslation("report"); | |||
| const reportTitle = (report: Pick<ReportDefinition, "id" | "title">) => | |||
| t(`reports.${report.id}.title`, { defaultValue: report.title }); | |||
| const fieldLabel = ( | |||
| reportId: string, | |||
| field: Pick<ReportField, "name" | "label">, | |||
| ) => | |||
| t(`reports.${reportId}.fields.${field.name}`, { | |||
| defaultValue: field.label, | |||
| }); | |||
| const optionLabel = ( | |||
| reportId: string, | |||
| fieldName: string, | |||
| opt: { label: string; value: string }, | |||
| ) => | |||
| t( | |||
| [ | |||
| `reports.${reportId}.options.${fieldName}.${opt.value}`, | |||
| `options.${opt.value}`, | |||
| ], | |||
| { defaultValue: opt.label }, | |||
| ); | |||
| const categoryTitle = (id: string, fallback: string) => | |||
| t(`categories.${id}`, { defaultValue: fallback }); | |||
| return { t, i18n, reportTitle, fieldLabel, optionLabel, categoryTitle }; | |||
| } | |||
| export function reportExcelT( | |||
| t: TFunction | undefined, | |||
| key: string, | |||
| fallback: string, | |||
| ): string { | |||
| if (!t) return fallback; | |||
| return String(t(key, { defaultValue: fallback })); | |||
| } | |||
| @@ -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<typeof shopReplenishmentLabels>; | |||
| function emptySheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> { | |||
| function emptySheetRow( | |||
| L: ShopReplenishmentLabels, | |||
| note?: string, | |||
| ): Record<string, unknown> { | |||
| 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<string, unknown> { | |||
| const base = emptySheetRow(""); | |||
| function toExcelRow( | |||
| r: ShopOrderReplenishmentReportRow, | |||
| L: ShopReplenishmentLabels, | |||
| ): Record<string, unknown> { | |||
| 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<string, string>, | |||
| reportTitle: string = "店鋪訂單補貨記錄", | |||
| t?: TFunction, | |||
| ): Promise<void> { | |||
| 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); | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| import ItemDefaultShelfLifeSettings from "@/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| import { Metadata } from "next"; | |||
| export const metadata: Metadata = { | |||
| title: "Item default shelf life", | |||
| }; | |||
| export default async function ItemDefaultShelfLifePage() { | |||
| const { t } = await getServerI18n("itemDefaultShelfLife"); | |||
| return ( | |||
| <I18nProvider namespaces={["itemDefaultShelfLife", "navigation", "common"]}> | |||
| <Stack spacing={2}> | |||
| <Typography variant="h4">{t("title")}</Typography> | |||
| <ItemDefaultShelfLifeSettings /> | |||
| </Stack> | |||
| </I18nProvider> | |||
| ); | |||
| } | |||
| @@ -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<Error> { | |||
| return parseBagPrintApiError(res, "下載"); | |||
| } | |||
| /** Backend ErrorRes is `{ timestamp, traceId }` with no message; avoid calling that a ZIP download failure. */ | |||
| async function parseBagPrintApiError(res: Response, action: string): Promise<Error> { | |||
| const text = await res.text(); | |||
| const 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<Error> { | |||
| 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<OnPackZipDownload> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`; | |||
| const res = await clientAuthFetch(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(request), | |||
| }); | |||
| if (!res.ok) { | |||
| throw await zipDownloadError(res); | |||
| } | |||
| return { | |||
| blob: await res.blob(), | |||
| skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res), | |||
| }; | |||
| } | |||
| /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ | |||
| 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<OnPackZipDownload> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`; | |||
| const res = await clientAuthFetch(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(request), | |||
| }); | |||
| if (!res.ok) { | |||
| throw await zipDownloadError(res); | |||
| } | |||
| return { | |||
| blob: await res.blob(), | |||
| skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res), | |||
| }; | |||
| } | |||
| export type OnPackMachine = "juice" | "lemon"; | |||
| export interface OnPackTemplateFileDto { | |||
| id: number; | |||
| machine: OnPackMachine | string; | |||
| itemCode: string; | |||
| fileName: string; | |||
| byteSize: number; | |||
| modified?: string | null; | |||
| } | |||
| export interface OnPackTemplateUploadResponse { | |||
| machine: string; | |||
| itemCode: string; | |||
| saved: string[]; | |||
| } | |||
| export interface OnPackSupportedItemDto { | |||
| itemCode: string; | |||
| printable: boolean; | |||
| inDatabase: boolean; | |||
| builtin: boolean; | |||
| registered: boolean; | |||
| } | |||
| export interface OnPackSupportedCatalogDto { | |||
| juice: OnPackSupportedItemDto[]; | |||
| lemon: OnPackSupportedItemDto[]; | |||
| } | |||
| export interface OnPackExpiryItemCodeDto { | |||
| machine: string; | |||
| itemCode: string; | |||
| printName?: string | null; | |||
| defaultPrintName?: string | null; | |||
| defaultDays?: number | null; | |||
| minus18Days?: number | null; | |||
| useMinus18?: boolean; | |||
| effectiveDays?: number | null; | |||
| } | |||
| export type OnPackExpiryItemCodeUpdate = { | |||
| itemCode: string; | |||
| machine?: OnPackMachine; | |||
| printName?: string | null; | |||
| useMinus18?: boolean; | |||
| }; | |||
| export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice"): Promise<OnPackExpiryItemCodeDto[]> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}`; | |||
| const res = await clientAuthFetch(url, { method: "GET" }); | |||
| if (!res.ok) { | |||
| throw await parseBagPrintApiError(res, "讀取到期日 ZIP 品號"); | |||
| } | |||
| return (await res.json()) as OnPackExpiryItemCodeDto[]; | |||
| } | |||
| export async function addOnPackExpiryCode( | |||
| itemCode: string, | |||
| machine: OnPackMachine = "juice", | |||
| ): Promise<OnPackExpiryItemCodeDto> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | |||
| const res = await clientAuthFetch(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ itemCode, machine }), | |||
| }); | |||
| if (!res.ok) { | |||
| throw await parseBagPrintApiError(res, "新增到期日 ZIP 品號"); | |||
| } | |||
| return (await res.json()) as OnPackExpiryItemCodeDto; | |||
| } | |||
| export async function updateOnPackExpiryCode( | |||
| body: OnPackExpiryItemCodeUpdate, | |||
| ): Promise<OnPackExpiryItemCodeDto> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | |||
| const res = await clientAuthFetch(url, { | |||
| method: "PUT", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(body), | |||
| }); | |||
| if (!res.ok) { | |||
| throw await parseBagPrintApiError(res, "更新到期日 ZIP 品號"); | |||
| } | |||
| return (await res.json()) as OnPackExpiryItemCodeDto; | |||
| } | |||
| export async function deleteOnPackExpiryCode( | |||
| itemCode: string, | |||
| machine: OnPackMachine = "juice", | |||
| ): Promise<void> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}&itemCode=${encodeURIComponent(itemCode)}`; | |||
| const res = await clientAuthFetch(url, { method: "DELETE" }); | |||
| if (!res.ok && res.status !== 204) { | |||
| throw await parseBagPrintApiError(res, "刪除到期日 ZIP 品號"); | |||
| } | |||
| } | |||
| export async function fetchOnPackSupportedCatalog(): Promise<OnPackSupportedCatalogDto> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`; | |||
| const res = await clientAuthFetch(url, { method: "GET" }); | |||
| if (!res.ok) { | |||
| throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單"); | |||
| } | |||
| return (await res.json()) as OnPackSupportedCatalogDto; | |||
| } | |||
| export async function listOnPackTemplates(machine?: OnPackMachine): Promise<OnPackTemplateFileDto[]> { | |||
| const q = machine ? `?machine=${encodeURIComponent(machine)}` : ""; | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`; | |||
| const res = await clientAuthFetch(url, { method: "GET" }); | |||
| if (!res.ok) { | |||
| throw await parseBagPrintApiError(res, "讀取 OnPack 模板"); | |||
| } | |||
| return (await res.json()) as OnPackTemplateFileDto[]; | |||
| } | |||
| export async function uploadOnPackTemplates( | |||
| machine: OnPackMachine, | |||
| itemCode: string, | |||
| files: File[], | |||
| ): Promise<OnPackTemplateUploadResponse> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`; | |||
| const body = new FormData(); | |||
| body.append("machine", machine); | |||
| body.append("itemCode", itemCode); | |||
| files.forEach((f) => body.append("files", f)); | |||
| const res = await clientAuthFetch(url, { method: "POST", body }); | |||
| if (!res.ok) { | |||
| throw await parseBagPrintApiError(res, "上傳 OnPack 模板"); | |||
| } | |||
| return (await res.json()) as OnPackTemplateUploadResponse; | |||
| } | |||
| export async function deleteOnPackTemplate(id: number): Promise<void> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/${id}`; | |||
| const res = await clientAuthFetch(url, { method: "DELETE" }); | |||
| if (!res.ok && res.status !== 204) { | |||
| throw await parseBagPrintApiError(res, "刪除 OnPack 模板"); | |||
| } | |||
| } | |||
| @@ -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), | |||
| }; | |||
| }); | |||
| } | |||
| @@ -1741,6 +1741,38 @@ export const fetchDrinkProductionQty = cache( | |||
| }, | |||
| ); | |||
| export interface DrinkShipmentQtyDeliveryDetail { | |||
| deliveryOrderId: number; | |||
| deliveryOrderCode?: string | null; | |||
| deliveryDate?: string | null; | |||
| shopCode?: string | null; | |||
| shopName?: string | null; | |||
| deliveryOrderStatus?: string | null; | |||
| orderQty: number; | |||
| shippedQty: number; | |||
| } | |||
| export interface DrinkShipmentQtyResponse { | |||
| itemCode?: string | null; | |||
| itemName?: string | null; | |||
| uom?: string | null; | |||
| totalOrderQty: number; | |||
| totalShippedQty: number; | |||
| deliveries?: DrinkShipmentQtyDeliveryDetail[]; | |||
| } | |||
| export const fetchDrinkShipmentQty = cache(async (date?: string) => { | |||
| const params = new URLSearchParams(); | |||
| if (date) params.set("date", date); | |||
| const qs = params.toString(); | |||
| const url = `${BASE_API_URL}/product-process/Demo/DrinkShipmentQty${qs ? `?${qs}` : ""}`; | |||
| return serverFetchJson<DrinkShipmentQtyResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["drinkShipmentQty"] }, | |||
| }); | |||
| }); | |||
| // ===== Equipment Status Dashboard ===== | |||
| export interface EquipmentStatusProcessInfo { | |||
| @@ -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<LaserBag2Settings> { | |||
| return res.json() as Promise<LaserBag2Settings>; | |||
| } | |||
| /** List API may return LocalDate as `"2026-08-27"` or `[2026,8,27]` (@EnableWebMvc raw Jackson). */ | |||
| export function expiryDateForLaserSend(value: unknown): string | null { | |||
| if (value == null || value === "") return null; | |||
| if (typeof value === "string") { | |||
| const s = value.trim(); | |||
| if (!s) return null; | |||
| if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); | |||
| return s; | |||
| } | |||
| if (Array.isArray(value) && value.length >= 3) { | |||
| const y = Number(value[0]); | |||
| const m = Number(value[1]); | |||
| const d = Number(value[2]); | |||
| if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d) || y < 1 || m < 1 || d < 1) { | |||
| return null; | |||
| } | |||
| return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; | |||
| } | |||
| return null; | |||
| } | |||
| function messageFromLaserSendBody(data: Record<string, unknown>, status: number): string { | |||
| const message = typeof data.message === "string" ? data.message.trim() : ""; | |||
| if (message) return message; | |||
| const detail = typeof data.detail === "string" ? data.detail.trim() : ""; | |||
| if (detail) return detail; | |||
| const error = typeof data.error === "string" ? data.error.trim() : ""; | |||
| if (error) return error; | |||
| const traceId = typeof data.traceId === "string" ? data.traceId.trim() : ""; | |||
| if (traceId) return `送出失敗(traceId ${traceId})`; | |||
| return `送出失敗(HTTP ${status})`; | |||
| } | |||
| export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> { | |||
| 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<Lase | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(body), | |||
| }); | |||
| const data = (await res.json()) as LaserBag2SendResponse; | |||
| if (!res.ok) { | |||
| return data; | |||
| let data: Record<string, unknown> = {}; | |||
| try { | |||
| const text = await res.text(); | |||
| data = text ? (JSON.parse(text) as Record<string, unknown>) : {}; | |||
| } catch { | |||
| return { success: false, message: `送出失敗(HTTP ${res.status},無法解析回應)` }; | |||
| } | |||
| return data; | |||
| if (!res.ok || data.success === false) { | |||
| return { | |||
| success: false, | |||
| message: messageFromLaserSendBody(data, res.status), | |||
| payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null, | |||
| printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | |||
| receiveAcknowledged: Boolean(data.receiveAcknowledged), | |||
| }; | |||
| } | |||
| return { | |||
| success: true, | |||
| message: typeof data.message === "string" && data.message.trim() ? data.message : "已送出", | |||
| payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null, | |||
| printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | |||
| receiveAcknowledged: Boolean(data.receiveAcknowledged), | |||
| }; | |||
| } | |||
| export interface PrinterStatusRequest { | |||
| @@ -0,0 +1,88 @@ | |||
| "use client"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| const base = NEXT_PUBLIC_API_URL; | |||
| export type ItemDefaultShelfLifeRow = { | |||
| id: number; | |||
| itemCode: string; | |||
| itemName?: string | null; | |||
| defaultDays?: number | null; | |||
| minus18Days?: number | null; | |||
| useMinus18: boolean; | |||
| openedDays?: number | null; | |||
| storageC?: string | null; | |||
| remarks?: string | null; | |||
| effectiveDays?: number | null; | |||
| }; | |||
| export type ItemDefaultShelfLifeInput = { | |||
| itemCode: string; | |||
| defaultDays?: number | null; | |||
| minus18Days?: number | null; | |||
| useMinus18: boolean; | |||
| openedDays?: number | null; | |||
| storageC?: string | null; | |||
| remarks?: string | null; | |||
| }; | |||
| async function parseJson<T>(res: Response): Promise<T> { | |||
| if (!res.ok) { | |||
| throw new Error(await readError(res)); | |||
| } | |||
| return res.json() as Promise<T>; | |||
| } | |||
| async function readError(res: Response): Promise<string> { | |||
| const text = await res.text().catch(() => ""); | |||
| if (!text) return `HTTP ${res.status}`; | |||
| try { | |||
| const json = JSON.parse(text) as { message?: string; error?: string }; | |||
| return json.message || json.error || text; | |||
| } catch { | |||
| return text; | |||
| } | |||
| } | |||
| export async function fetchItemDefaultShelfLives( | |||
| q?: string, | |||
| ): Promise<ItemDefaultShelfLifeRow[]> { | |||
| const url = new URL(`${base}/itemDefaultShelfLives`); | |||
| if (q?.trim()) url.searchParams.set("q", q.trim()); | |||
| const res = await clientAuthFetch(url.toString(), { method: "GET" }); | |||
| return parseJson<ItemDefaultShelfLifeRow[]>(res); | |||
| } | |||
| export async function createItemDefaultShelfLife( | |||
| data: ItemDefaultShelfLifeInput, | |||
| ): Promise<ItemDefaultShelfLifeRow> { | |||
| const res = await clientAuthFetch(`${base}/itemDefaultShelfLives`, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(data), | |||
| }); | |||
| return parseJson<ItemDefaultShelfLifeRow>(res); | |||
| } | |||
| export async function updateItemDefaultShelfLife( | |||
| id: number, | |||
| data: ItemDefaultShelfLifeInput, | |||
| ): Promise<ItemDefaultShelfLifeRow> { | |||
| const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, { | |||
| method: "PUT", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(data), | |||
| }); | |||
| return parseJson<ItemDefaultShelfLifeRow>(res); | |||
| } | |||
| export async function deleteItemDefaultShelfLife( | |||
| id: number, | |||
| ): Promise<ItemDefaultShelfLifeRow[]> { | |||
| const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, { | |||
| method: "DELETE", | |||
| }); | |||
| return parseJson<ItemDefaultShelfLifeRow[]>(res); | |||
| } | |||
| @@ -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}` : ""}`, | |||
| ); | |||
| } | |||
| }; | |||
| @@ -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; | |||
| /** | |||
| @@ -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<AppBarProps> = ({ avatarImageSrc, profileName }) => { | |||
| gap: 1, | |||
| }} | |||
| > | |||
| <LanguageSwitcher /> | |||
| <Profile | |||
| avatarImageSrc={avatarImageSrc} | |||
| profileName={profileName} | |||
| @@ -0,0 +1,60 @@ | |||
| "use client"; | |||
| import React, { useRef } from "react"; | |||
| import ToggleButton from "@mui/material/ToggleButton"; | |||
| import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| type AppLanguage, | |||
| isAppLanguage, | |||
| setLanguageCookie, | |||
| } from "@/i18n/locale"; | |||
| const LanguageSwitcher: React.FC = () => { | |||
| const { i18n, t } = useTranslation("common"); | |||
| const { update } = useSession(); | |||
| const router = useRouter(); | |||
| const inFlightRef = useRef(false); | |||
| const current: AppLanguage = isAppLanguage(i18n.language) ? i18n.language : "zh"; | |||
| const onChange = async (_: React.MouseEvent<HTMLElement>, next: AppLanguage | null) => { | |||
| if (!next || next === current) return; | |||
| if (inFlightRef.current) return; | |||
| inFlightRef.current = true; | |||
| try { | |||
| setLanguageCookie(next); | |||
| await update({ locale: next }); | |||
| router.refresh(); | |||
| } finally { | |||
| inFlightRef.current = false; | |||
| } | |||
| }; | |||
| return ( | |||
| <ToggleButtonGroup | |||
| exclusive | |||
| size="small" | |||
| value={current} | |||
| onChange={onChange} | |||
| aria-label={t("Language")} | |||
| sx={{ | |||
| mr: 0.5, | |||
| "& .MuiToggleButton-root": { | |||
| px: 1, | |||
| py: 0.25, | |||
| fontSize: "0.75rem", | |||
| lineHeight: 1.4, | |||
| textTransform: "none", | |||
| }, | |||
| }} | |||
| > | |||
| <ToggleButton value="zh">中</ToggleButton> | |||
| <ToggleButton value="en">EN</ToggleButton> | |||
| </ToggleButtonGroup> | |||
| ); | |||
| }; | |||
| export default LanguageSwitcher; | |||
| @@ -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<string> { | |||
| return new Set(rows.map((r) => r.itemCode.trim().toUpperCase()).filter(Boolean)); | |||
| } | |||
| function daysLabel(value: number | null | undefined): string { | |||
| return value == null ? "未設" : String(value); | |||
| } | |||
| type ExpirySortKey = | |||
| | "itemCode" | |||
| | "name" | |||
| | "defaultDays" | |||
| | "minus18Days" | |||
| | "useMinus18" | |||
| | "effectiveDays"; | |||
| function displayName(row: OnPackExpiryItemCodeDto): string { | |||
| return (row.printName || row.defaultPrintName || "").trim(); | |||
| } | |||
| function cmpText(a: string, b: string): number { | |||
| return a.localeCompare(b, "zh-Hant", { numeric: true, sensitivity: "base" }); | |||
| } | |||
| /** Null (未設) sorts first on asc so unset rows are easy to find. */ | |||
| function cmpDays(a: number | null | undefined, b: number | null | undefined): number { | |||
| const av = a == null ? Number.NEGATIVE_INFINITY : a; | |||
| const bv = b == null ? Number.NEGATIVE_INFINITY : b; | |||
| return av - bv; | |||
| } | |||
| function skippedExpirySnackbar(okMessage: string, skipped: string[]): { | |||
| open: true; | |||
| message: string; | |||
| severity: "success" | "warning"; | |||
| duration: number; | |||
| } { | |||
| if (skipped.length === 0) { | |||
| return { open: true, message: okMessage, severity: "success", duration: 3000 }; | |||
| } | |||
| return { | |||
| open: true, | |||
| message: `${okMessage}。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`, | |||
| severity: "warning", | |||
| duration: 10000, | |||
| }; | |||
| } | |||
| function sortExpiryRows( | |||
| rows: OnPackExpiryItemCodeDto[], | |||
| key: ExpirySortKey, | |||
| dir: "asc" | "desc", | |||
| ): OnPackExpiryItemCodeDto[] { | |||
| const sign = dir === "asc" ? 1 : -1; | |||
| return [...rows].sort((a, b) => { | |||
| let cmp = 0; | |||
| switch (key) { | |||
| case "itemCode": | |||
| cmp = cmpText(a.itemCode, b.itemCode); | |||
| break; | |||
| case "name": | |||
| cmp = cmpText(displayName(a), displayName(b)); | |||
| break; | |||
| case "defaultDays": | |||
| cmp = cmpDays(a.defaultDays, b.defaultDays); | |||
| break; | |||
| case "minus18Days": | |||
| cmp = cmpDays(a.minus18Days, b.minus18Days); | |||
| break; | |||
| case "useMinus18": | |||
| cmp = Number(a.useMinus18 === true) - Number(b.useMinus18 === true); | |||
| break; | |||
| case "effectiveDays": | |||
| cmp = cmpDays(a.effectiveDays, b.effectiveDays); | |||
| break; | |||
| } | |||
| if (cmp === 0) cmp = cmpText(a.itemCode, b.itemCode); | |||
| return cmp * sign; | |||
| }); | |||
| } | |||
| const BagPrintSearch: React.FC = () => { | |||
| const { 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<JobOrderListItem[]>([]); | |||
| 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<OnPackExpiryItemCodeDto[]>([]); | |||
| const [expiryCodeInput, setExpiryCodeInput] = useState(""); | |||
| const [expiryCodesLoading, setExpiryCodesLoading] = useState(false); | |||
| const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({}); | |||
| const [expirySortKey, setExpirySortKey] = useState<ExpirySortKey>("itemCode"); | |||
| const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc"); | |||
| const [lemonCodeSet, setLemonCodeSet] = useState<Set<string>>(() => new Set()); | |||
| const expiryAddRef = useRef(false); | |||
| const expiryDeleteRef = useRef(false); | |||
| const expirySaveRef = useRef<Set<string>>(new Set()); | |||
| const expiryToggleRef = useRef<Set<string>>(new Set()); | |||
| const [snackbar, setSnackbar] = useState<{ | |||
| open: boolean; | |||
| message: string; | |||
| severity?: "success" | "info" | "warning" | "error"; | |||
| duration?: number; | |||
| }>({ open: false, message: "" }); | |||
| const [settings, setSettings] = useState(DEFAULT_SETTINGS); | |||
| const [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 = () => { | |||
| <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> | |||
| 設定 | |||
| </Button> | |||
| {canSeeOnPackAdmin && ( | |||
| <Button variant="outlined" onClick={() => setTemplatesOpen(true)}> | |||
| OnPack 到期日 ZIP | |||
| </Button> | |||
| )} | |||
| <Box | |||
| sx={{ | |||
| px: 1.5, | |||
| @@ -403,19 +805,66 @@ const BagPrintSearch: React.FC = () => { | |||
| variant="contained" | |||
| startIcon={<Download />} | |||
| onClick={handleDownloadOnPackQr} | |||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||
| disabled={ | |||
| loading || | |||
| downloadingOnPack || | |||
| downloadingOnPackExp || | |||
| downloadingOnPackText || | |||
| downloadingOnPackTextExp || | |||
| jobOrders.length === 0 | |||
| } | |||
| > | |||
| {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Download />} | |||
| onClick={handleDownloadOnPackQrWithExpiry} | |||
| disabled={ | |||
| loading || | |||
| downloadingOnPack || | |||
| downloadingOnPackExp || | |||
| downloadingOnPackText || | |||
| downloadingOnPackTextExp || | |||
| jobOrders.length === 0 | |||
| } | |||
| > | |||
| {downloadingOnPackExp ? "下載中..." : "下載 OnPack 汁水機(含到期日)"} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="secondary" | |||
| startIcon={<Download />} | |||
| onClick={handleDownloadOnPackTextQr} | |||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||
| disabled={ | |||
| loading || | |||
| downloadingOnPack || | |||
| downloadingOnPackExp || | |||
| downloadingOnPackText || | |||
| downloadingOnPackTextExp || | |||
| jobOrders.length === 0 | |||
| } | |||
| > | |||
| {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"} | |||
| </Button> | |||
| {canSeeOnPackAdmin && ( | |||
| <Button | |||
| variant="contained" | |||
| color="secondary" | |||
| startIcon={<Download />} | |||
| onClick={handleDownloadOnPackTextQrWithExpiry} | |||
| disabled={ | |||
| loading || | |||
| downloadingOnPack || | |||
| downloadingOnPackExp || | |||
| downloadingOnPackText || | |||
| downloadingOnPackTextExp || | |||
| jobOrders.length === 0 | |||
| } | |||
| > | |||
| {downloadingOnPackTextExp ? "下載中..." : "下載 OnPack2023檸檬機(含到期日)"} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| </Paper> | |||
| @@ -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 ( | |||
| <Paper | |||
| key={jo.id} | |||
| @@ -471,6 +923,10 @@ const BagPrintSearch: React.FC = () => { | |||
| <Typography variant="h6" sx={{ fontSize: "1.35rem" }}> | |||
| {jo.itemCode || "—"} | |||
| </Typography> | |||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}> | |||
| {juiceExpiryOk ? <Chip size="small" label="汁水機" color="primary" /> : null} | |||
| {lemonOk ? <Chip size="small" label="檸檬機" color="secondary" /> : null} | |||
| </Stack> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||
| <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}> | |||
| @@ -632,13 +1088,254 @@ const BagPrintSearch: React.FC = () => { | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog | |||
| open={templatesOpen && canSeeOnPackAdmin} | |||
| onClose={() => setTemplatesOpen(false)} | |||
| maxWidth="xl" | |||
| fullWidth | |||
| scroll="paper" | |||
| PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} | |||
| > | |||
| <DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle> | |||
| <DialogContent | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| overflow: "hidden", | |||
| pt: 1, | |||
| }} | |||
| > | |||
| <Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}> | |||
| <Typography variant="subtitle2" color="primary"> | |||
| 汁水機({expiryCodes.length}) | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。 | |||
| 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。 | |||
| </Typography> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <TextField | |||
| label="新增品號" | |||
| size="small" | |||
| placeholder="例如 PP2211" | |||
| value={expiryCodeInput} | |||
| onChange={(e) => setExpiryCodeInput(e.target.value)} | |||
| onKeyDown={(e) => { | |||
| if (e.key === "Enter") { | |||
| e.preventDefault(); | |||
| void handleAddExpiryCode(); | |||
| } | |||
| }} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| <Button variant="contained" onClick={() => void handleAddExpiryCode()}> | |||
| 加入 | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| {expiryCodesLoading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", py: 1 }}> | |||
| <CircularProgress size={20} /> | |||
| </Box> | |||
| ) : expiryCodes.length === 0 ? ( | |||
| <Typography color="text.secondary">清單空白</Typography> | |||
| ) : ( | |||
| <TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||
| <TableSortLabel | |||
| active={expirySortKey === "itemCode"} | |||
| direction={expirySortKey === "itemCode" ? expirySortDir : "asc"} | |||
| onClick={() => onExpirySort("itemCode")} | |||
| > | |||
| 品號 | |||
| </TableSortLabel> | |||
| </TableCell> | |||
| <TableCell sx={{ fontWeight: 700 }}> | |||
| <TableSortLabel | |||
| active={expirySortKey === "name"} | |||
| direction={expirySortKey === "name" ? expirySortDir : "asc"} | |||
| onClick={() => onExpirySort("name")} | |||
| > | |||
| 中文名稱+單位 | |||
| </TableSortLabel> | |||
| </TableCell> | |||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||
| <TableSortLabel | |||
| active={expirySortKey === "defaultDays"} | |||
| direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"} | |||
| onClick={() => onExpirySort("defaultDays")} | |||
| > | |||
| 冷藏 | |||
| </TableSortLabel> | |||
| <Typography variant="caption" display="block" color="text.secondary"> | |||
| 天 | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||
| <TableSortLabel | |||
| active={expirySortKey === "minus18Days"} | |||
| direction={expirySortKey === "minus18Days" ? expirySortDir : "asc"} | |||
| onClick={() => onExpirySort("minus18Days")} | |||
| > | |||
| -18 | |||
| </TableSortLabel> | |||
| <Typography variant="caption" display="block" color="text.secondary"> | |||
| 天 | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||
| <TableSortLabel | |||
| active={expirySortKey === "useMinus18"} | |||
| direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"} | |||
| onClick={() => onExpirySort("useMinus18")} | |||
| > | |||
| 用 -18 | |||
| </TableSortLabel> | |||
| </TableCell> | |||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||
| <TableSortLabel | |||
| active={expirySortKey === "effectiveDays"} | |||
| direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"} | |||
| onClick={() => onExpirySort("effectiveDays")} | |||
| > | |||
| 列印 | |||
| </TableSortLabel> | |||
| <Typography variant="caption" display="block" color="text.secondary"> | |||
| 天 | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ fontWeight: 700 }}> | |||
| 操作 | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {sortedExpiryCodes.map((row) => { | |||
| const draft = nameDrafts[row.itemCode] ?? ""; | |||
| const savedName = row.printName || row.defaultPrintName || ""; | |||
| const nameDirty = draft.trim() !== savedName.trim(); | |||
| const hasShelf = row.defaultDays != null || row.minus18Days != null; | |||
| const canUseMinus18 = row.minus18Days != null && row.minus18Days > 0; | |||
| const missingHint = hasShelf | |||
| ? canUseMinus18 | |||
| ? "" | |||
| : "此品號沒有 -18 天數" | |||
| : "未設定保質期,請到設定 → 物品預設保質期新增"; | |||
| return ( | |||
| <TableRow key={row.itemCode} hover> | |||
| <TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}> | |||
| {row.itemCode} | |||
| </TableCell> | |||
| <TableCell sx={{ minWidth: 280 }}> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <TextField | |||
| size="small" | |||
| value={draft} | |||
| onChange={(e) => | |||
| setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) | |||
| } | |||
| placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"} | |||
| inputProps={{ maxLength: 255 }} | |||
| fullWidth | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| disabled={!nameDirty} | |||
| onClick={() => void handleSaveExpiryPrintName(row.itemCode)} | |||
| > | |||
| 儲存 | |||
| </Button> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography | |||
| variant="h6" | |||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | |||
| color={row.defaultDays == null ? "warning.main" : "text.primary"} | |||
| > | |||
| {daysLabel(row.defaultDays)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography | |||
| variant="h6" | |||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | |||
| color={row.minus18Days == null ? "warning.main" : "text.primary"} | |||
| > | |||
| {daysLabel(row.minus18Days)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}> | |||
| <span> | |||
| <Switch | |||
| size="small" | |||
| checked={row.useMinus18 === true} | |||
| disabled={!canUseMinus18} | |||
| onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)} | |||
| inputProps={{ "aria-label": `${row.itemCode} 用 -18` }} | |||
| /> | |||
| </span> | |||
| </Tooltip> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography | |||
| variant="h6" | |||
| sx={{ fontWeight: 800, lineHeight: 1.2 }} | |||
| color={row.effectiveDays == null ? "warning.main" : "primary.main"} | |||
| > | |||
| {daysLabel(row.effectiveDays)} | |||
| </Typography> | |||
| {!hasShelf && ( | |||
| <Typography variant="caption" color="warning.main" display="block"> | |||
| 未設定 | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Button | |||
| size="small" | |||
| color="error" | |||
| onClick={() => void handleDeleteExpiryCode(row.itemCode)} | |||
| > | |||
| 移除 | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| )} | |||
| <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}> | |||
| 檸檬機到期日 ZIP 品號稍後加入。 | |||
| </Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setTemplatesOpen(false)}>關閉</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Snackbar | |||
| open={snackbar.open} | |||
| autoHideDuration={3000} | |||
| autoHideDuration={snackbar.duration ?? 3000} | |||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | |||
| message={snackbar.message} | |||
| anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | |||
| /> | |||
| > | |||
| <Alert | |||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | |||
| severity={snackbar.severity ?? "info"} | |||
| variant="filled" | |||
| sx={{ width: "100%", maxWidth: 720 }} | |||
| > | |||
| {snackbar.message} | |||
| </Alert> | |||
| </Snackbar> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -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", | |||
| @@ -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<Props> = ({ rules, auths }) => { | |||
| console.log(auths); | |||
| // console.log(auths); | |||
| const { t } = useTranslation("user"); | |||
| const formProps = useForm<UserInputs>(); | |||
| const searchParams = useSearchParams(); | |||
| @@ -172,7 +173,33 @@ const CreateUser: React.FC<Props> = ({ 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<Props> = ({ rules, auths }) => { | |||
| label={t("User Detail")} | |||
| icon={ | |||
| hasErrorsInTab(0, errors) ? ( | |||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
| <ErrorIcon sx={{ marginInlineEnd: 1 }} color="error" /> | |||
| ) : undefined | |||
| } | |||
| iconPosition="end" | |||
| @@ -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) | |||
| : "" | |||
| } | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| @@ -0,0 +1,473 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import DeleteOutline from "@mui/icons-material/DeleteOutline"; | |||
| import EditOutlined from "@mui/icons-material/EditOutlined"; | |||
| import { | |||
| Alert, | |||
| Box, | |||
| Button, | |||
| Checkbox, | |||
| Chip, | |||
| CircularProgress, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| FormControlLabel, | |||
| FormHelperText, | |||
| IconButton, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TablePagination, | |||
| TableRow, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { | |||
| createItemDefaultShelfLife, | |||
| deleteItemDefaultShelfLife, | |||
| fetchItemDefaultShelfLives, | |||
| updateItemDefaultShelfLife, | |||
| type ItemDefaultShelfLifeInput, | |||
| type ItemDefaultShelfLifeRow, | |||
| } from "@/app/api/settings/itemDefaultShelfLife/client"; | |||
| type FormState = { | |||
| itemCode: string; | |||
| defaultDays: string; | |||
| minus18Days: string; | |||
| useMinus18: boolean; | |||
| openedDays: string; | |||
| storageC: string; | |||
| remarks: string; | |||
| }; | |||
| const emptyForm = (): FormState => ({ | |||
| itemCode: "", | |||
| defaultDays: "", | |||
| minus18Days: "", | |||
| useMinus18: false, | |||
| openedDays: "", | |||
| storageC: "", | |||
| remarks: "", | |||
| }); | |||
| function parseOptionalDays(raw: string): number | null | "invalid" { | |||
| const t = raw.trim(); | |||
| if (!t) return null; | |||
| if (!/^\d+$/.test(t)) return "invalid"; | |||
| return Number(t); | |||
| } | |||
| function daysFromForm(form: FormState): { defaultDays: number | null; minus18Days: number | null } | "invalid" { | |||
| const defaultDays = parseOptionalDays(form.defaultDays); | |||
| const minus18Days = parseOptionalDays(form.minus18Days); | |||
| if (defaultDays === "invalid" || minus18Days === "invalid") return "invalid"; | |||
| return { defaultDays, minus18Days }; | |||
| } | |||
| function effectiveDays(form: FormState): number | null { | |||
| const parsed = daysFromForm(form); | |||
| if (parsed === "invalid") return null; | |||
| const chosen = form.useMinus18 ? parsed.minus18Days : parsed.defaultDays; | |||
| return chosen != null && chosen > 0 ? chosen : null; | |||
| } | |||
| function expiryPreview(days: number | null): string | null { | |||
| if (days == null) return null; | |||
| const d = new Date(); | |||
| d.setHours(0, 0, 0, 0); | |||
| d.setDate(d.getDate() + days); | |||
| const y = d.getFullYear(); | |||
| const m = String(d.getMonth() + 1).padStart(2, "0"); | |||
| const day = String(d.getDate()).padStart(2, "0"); | |||
| return `${y}-${m}-${day}`; | |||
| } | |||
| function toForm(row: ItemDefaultShelfLifeRow): FormState { | |||
| return { | |||
| itemCode: row.itemCode ?? "", | |||
| defaultDays: row.defaultDays != null ? String(row.defaultDays) : "", | |||
| minus18Days: row.minus18Days != null ? String(row.minus18Days) : "", | |||
| useMinus18: row.useMinus18 === true, | |||
| openedDays: row.openedDays != null ? String(row.openedDays) : "", | |||
| storageC: row.storageC ?? "", | |||
| remarks: row.remarks ?? "", | |||
| }; | |||
| } | |||
| const ItemDefaultShelfLifeSettings: React.FC = () => { | |||
| const { t } = useTranslation("itemDefaultShelfLife"); | |||
| const saveInFlightRef = useRef(false); | |||
| const deleteInFlightRef = useRef(false); | |||
| const [loading, setLoading] = useState(true); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [success, setSuccess] = useState<string | null>(null); | |||
| const [rows, setRows] = useState<ItemDefaultShelfLifeRow[]>([]); | |||
| const [query, setQuery] = useState(""); | |||
| const [page, setPage] = useState(0); | |||
| const [rowsPerPage, setRowsPerPage] = useState(25); | |||
| const [dialogOpen, setDialogOpen] = useState(false); | |||
| const [editing, setEditing] = useState<ItemDefaultShelfLifeRow | null>(null); | |||
| const [form, setForm] = useState<FormState>(emptyForm); | |||
| const [formError, setFormError] = useState<string | null>(null); | |||
| const [saving, setSaving] = useState(false); | |||
| const [deleteTarget, setDeleteTarget] = useState<ItemDefaultShelfLifeRow | null>(null); | |||
| const [deleting, setDeleting] = useState(false); | |||
| const load = useCallback(async () => { | |||
| setLoading(true); | |||
| setError(null); | |||
| try { | |||
| setRows(await fetchItemDefaultShelfLives()); | |||
| } catch (e: unknown) { | |||
| setError(e instanceof Error ? e.message : String(e)); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| useEffect(() => { | |||
| void load(); | |||
| }, [load]); | |||
| const filtered = useMemo(() => { | |||
| const needle = query.trim().toLowerCase(); | |||
| if (!needle) return rows; | |||
| return rows.filter((r) => | |||
| [r.itemCode, r.itemName, r.remarks].some((v) => v?.toLowerCase().includes(needle)), | |||
| ); | |||
| }, [query, rows]); | |||
| useEffect(() => { | |||
| setPage(0); | |||
| }, [query]); | |||
| const paged = useMemo(() => { | |||
| const start = page * rowsPerPage; | |||
| return filtered.slice(start, start + rowsPerPage); | |||
| }, [filtered, page, rowsPerPage]); | |||
| const openCreate = () => { | |||
| setEditing(null); | |||
| setForm(emptyForm()); | |||
| setFormError(null); | |||
| setDialogOpen(true); | |||
| }; | |||
| const openEdit = (row: ItemDefaultShelfLifeRow) => { | |||
| setEditing(row); | |||
| setForm(toForm(row)); | |||
| setFormError(null); | |||
| setDialogOpen(true); | |||
| }; | |||
| const closeDialog = () => { | |||
| if (saving) return; | |||
| setDialogOpen(false); | |||
| }; | |||
| const onSave = async () => { | |||
| if (saveInFlightRef.current) return; | |||
| const code = form.itemCode.trim(); | |||
| if (!code) { | |||
| setFormError(t("Item code required")); | |||
| return; | |||
| } | |||
| const parsed = daysFromForm(form); | |||
| const openedDays = parseOptionalDays(form.openedDays); | |||
| if (parsed === "invalid" || openedDays === "invalid") { | |||
| setFormError(t("Days invalid")); | |||
| return; | |||
| } | |||
| const payload: ItemDefaultShelfLifeInput = { | |||
| itemCode: code, | |||
| defaultDays: parsed.defaultDays, | |||
| minus18Days: parsed.minus18Days, | |||
| useMinus18: form.useMinus18, | |||
| openedDays, | |||
| storageC: form.storageC.trim() || null, | |||
| remarks: form.remarks.trim() || null, | |||
| }; | |||
| saveInFlightRef.current = true; | |||
| setSaving(true); | |||
| setFormError(null); | |||
| setError(null); | |||
| setSuccess(null); | |||
| try { | |||
| if (editing) { | |||
| const updated = await updateItemDefaultShelfLife(editing.id, payload); | |||
| setRows((prev) => | |||
| prev | |||
| .map((r) => (r.id === updated.id ? updated : r)) | |||
| .sort((a, b) => a.itemCode.localeCompare(b.itemCode)), | |||
| ); | |||
| } else { | |||
| const created = await createItemDefaultShelfLife(payload); | |||
| setRows((prev) => | |||
| [...prev.filter((r) => r.id !== created.id), created].sort((a, b) => | |||
| a.itemCode.localeCompare(b.itemCode), | |||
| ), | |||
| ); | |||
| } | |||
| setSuccess(t("Saved")); | |||
| setDialogOpen(false); | |||
| } catch (e: unknown) { | |||
| setFormError(e instanceof Error ? e.message : String(e)); | |||
| } finally { | |||
| setSaving(false); | |||
| saveInFlightRef.current = false; | |||
| } | |||
| }; | |||
| const onDelete = async () => { | |||
| if (!deleteTarget || deleteInFlightRef.current) return; | |||
| deleteInFlightRef.current = true; | |||
| setDeleting(true); | |||
| setError(null); | |||
| setSuccess(null); | |||
| try { | |||
| const next = await deleteItemDefaultShelfLife(deleteTarget.id); | |||
| setRows(next); | |||
| setSuccess(t("Deleted")); | |||
| setDeleteTarget(null); | |||
| } catch (e: unknown) { | |||
| setError(e instanceof Error ? e.message : String(e)); | |||
| } finally { | |||
| setDeleting(false); | |||
| deleteInFlightRef.current = false; | |||
| } | |||
| }; | |||
| const previewDays = effectiveDays(form); | |||
| const previewDate = expiryPreview(previewDays); | |||
| const from = filtered.length === 0 ? 0 : page * rowsPerPage + 1; | |||
| const to = Math.min(filtered.length, (page + 1) * rowsPerPage); | |||
| return ( | |||
| <Stack spacing={2}> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Intro")} | |||
| </Typography> | |||
| {error && <Alert severity="error">{error}</Alert>} | |||
| {success && ( | |||
| <Alert severity="success" onClose={() => setSuccess(null)}> | |||
| {success} | |||
| </Alert> | |||
| )} | |||
| <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center"> | |||
| <TextField | |||
| size="small" | |||
| value={query} | |||
| onChange={(e) => setQuery(e.target.value)} | |||
| placeholder={t("Search placeholder")} | |||
| sx={{ minWidth: 260, flex: 1 }} | |||
| /> | |||
| <Button variant="contained" startIcon={<Add />} onClick={openCreate}> | |||
| {t("Add")} | |||
| </Button> | |||
| </Stack> | |||
| {loading ? ( | |||
| <Box display="flex" justifyContent="center" py={4}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <TableContainer sx={{ maxHeight: 640 }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Col itemCode")}</TableCell> | |||
| <TableCell>{t("Col itemName")}</TableCell> | |||
| <TableCell align="right">{t("Col defaultDays")}</TableCell> | |||
| <TableCell align="right">{t("Col minus18Days")}</TableCell> | |||
| <TableCell>{t("Col useMinus18")}</TableCell> | |||
| <TableCell align="right">{t("Col effectiveDays")}</TableCell> | |||
| <TableCell align="right">{t("Col openedDays")}</TableCell> | |||
| <TableCell>{t("Col storageC")}</TableCell> | |||
| <TableCell>{t("Col remarks")}</TableCell> | |||
| <TableCell align="right">{t("Col actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paged.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={10}> | |||
| <Typography color="text.secondary"> | |||
| {rows.length === 0 ? t("Empty") : t("No match")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paged.map((row) => ( | |||
| <TableRow key={row.id} hover> | |||
| <TableCell>{row.itemCode}</TableCell> | |||
| <TableCell>{row.itemName || "—"}</TableCell> | |||
| <TableCell align="right">{row.defaultDays ?? "—"}</TableCell> | |||
| <TableCell align="right">{row.minus18Days ?? "—"}</TableCell> | |||
| <TableCell> | |||
| <Chip | |||
| size="small" | |||
| label={row.useMinus18 ? t("Yes") : t("No")} | |||
| color={row.useMinus18 ? "warning" : "default"} | |||
| variant={row.useMinus18 ? "filled" : "outlined"} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="right">{row.effectiveDays ?? "—"}</TableCell> | |||
| <TableCell align="right">{row.openedDays ?? "—"}</TableCell> | |||
| <TableCell>{row.storageC || "—"}</TableCell> | |||
| <TableCell>{row.remarks || "—"}</TableCell> | |||
| <TableCell align="right"> | |||
| <IconButton size="small" aria-label={t("Edit")} onClick={() => openEdit(row)}> | |||
| <EditOutlined fontSize="small" /> | |||
| </IconButton> | |||
| <IconButton | |||
| size="small" | |||
| aria-label={t("Delete")} | |||
| onClick={() => setDeleteTarget(row)} | |||
| > | |||
| <DeleteOutline fontSize="small" /> | |||
| </IconButton> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Showing", { from, to, total: filtered.length })} | |||
| </Typography> | |||
| <TablePagination | |||
| component="div" | |||
| count={filtered.length} | |||
| page={page} | |||
| onPageChange={(_, next) => setPage(next)} | |||
| rowsPerPage={rowsPerPage} | |||
| onRowsPerPageChange={(e) => { | |||
| setRowsPerPage(parseInt(e.target.value, 10)); | |||
| setPage(0); | |||
| }} | |||
| rowsPerPageOptions={[25, 50, 100]} | |||
| /> | |||
| </Stack> | |||
| </> | |||
| )} | |||
| <Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm"> | |||
| <DialogTitle>{editing ? t("Edit title") : t("Add title")}</DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||
| {formError && <Alert severity="error">{formError}</Alert>} | |||
| <TextField | |||
| required | |||
| label={t("Col itemCode")} | |||
| value={form.itemCode} | |||
| onChange={(e) => setForm((s) => ({ ...s, itemCode: e.target.value }))} | |||
| disabled={saving} | |||
| autoFocus={!editing} | |||
| /> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||
| <TextField | |||
| label={t("Col defaultDays")} | |||
| value={form.defaultDays} | |||
| onChange={(e) => setForm((s) => ({ ...s, defaultDays: e.target.value }))} | |||
| disabled={saving} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| label={t("Col minus18Days")} | |||
| value={form.minus18Days} | |||
| onChange={(e) => setForm((s) => ({ ...s, minus18Days: e.target.value }))} | |||
| disabled={saving} | |||
| fullWidth | |||
| /> | |||
| </Stack> | |||
| <Box> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={form.useMinus18} | |||
| onChange={(e) => setForm((s) => ({ ...s, useMinus18: e.target.checked }))} | |||
| disabled={saving} | |||
| /> | |||
| } | |||
| label={t("Use minus18")} | |||
| /> | |||
| <FormHelperText>{t("Use minus18 help")}</FormHelperText> | |||
| </Box> | |||
| <Typography variant="body2" color={previewDate ? "text.secondary" : "warning.main"}> | |||
| {previewDate | |||
| ? t("Expiry preview", { date: previewDate }) | |||
| : t("Expiry preview none")} | |||
| </Typography> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||
| <TextField | |||
| label={t("Col openedDays")} | |||
| value={form.openedDays} | |||
| onChange={(e) => setForm((s) => ({ ...s, openedDays: e.target.value }))} | |||
| disabled={saving} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| label={t("Col storageC")} | |||
| value={form.storageC} | |||
| onChange={(e) => setForm((s) => ({ ...s, storageC: e.target.value }))} | |||
| disabled={saving} | |||
| inputProps={{ maxLength: 20 }} | |||
| fullWidth | |||
| /> | |||
| </Stack> | |||
| <TextField | |||
| label={t("Col remarks")} | |||
| value={form.remarks} | |||
| onChange={(e) => setForm((s) => ({ ...s, remarks: e.target.value }))} | |||
| disabled={saving} | |||
| inputProps={{ maxLength: 255 }} | |||
| multiline | |||
| minRows={2} | |||
| /> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={closeDialog} disabled={saving}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" onClick={onSave} disabled={saving}> | |||
| {saving ? t("Saving") : t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={!!deleteTarget} onClose={() => !deleting && setDeleteTarget(null)}> | |||
| <DialogTitle>{t("Delete title")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography> | |||
| {t("Delete confirm", { itemCode: deleteTarget?.itemCode ?? "" })} | |||
| </Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setDeleteTarget(null)} disabled={deleting}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button color="error" variant="contained" onClick={onDelete} disabled={deleting}> | |||
| {t("Delete")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default ItemDefaultShelfLifeSettings; | |||
| @@ -1,4 +1,8 @@ | |||
| import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; | |||
| import { | |||
| ItemLotTraceJoPickLine, | |||
| ItemLotTraceJoPrelude, | |||
| ItemLotTraceMaterialInput, | |||
| } from "@/app/api/itemTracing"; | |||
| import { | |||
| TraceGraphDetailField, | |||
| TraceGraphDetailLabels, | |||
| @@ -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, | |||
| @@ -60,7 +60,7 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| /* | |||
| const handleAutoCompleteChange = useCallback( | |||
| (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | |||
| console.log("BOM changed to:", value); | |||
| // console.log("BOM changed to:", value); | |||
| onChange(value.id); | |||
| // 重置倍数为 1 | |||
| @@ -101,7 +101,7 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| }, [bomCombo]); | |||
| const handleAutoCompleteChange = useCallback( | |||
| (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | |||
| console.log("BOM changed to:", value); | |||
| // console.log("BOM changed to:", value); | |||
| onChange(value.id); | |||
| if (value.outputQty != null) { | |||
| @@ -272,13 +272,13 @@ const JoSearch: React.FC<Props> = ({ | |||
| 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([]); | |||
| @@ -273,13 +273,13 @@ const JoWorkbenchSearch: React.FC<Props> = ({ | |||
| 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([]); | |||
| @@ -618,7 +618,7 @@ const QrCodeModal: React.FC<{ | |||
| ); | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.1 | 2026-08-10 */ | |||
| const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | |||
| const workbenchMode = true; | |||
| const { t } = useTranslation("jo"); | |||
| @@ -896,10 +896,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||
| data.pickOrderLines.forEach((line) => { | |||
| // 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次) | |||
| const lotIdSet = new Set<number>(); | |||
| /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數),對齊 DO Workbench */ | |||
| let lotsAllocatedSumForLine = 0; | |||
| // lots:按 lotId 去重并合并 requiredQty(对齐 GoodPickExecutiondetail) | |||
| // lots:按 lotId 去重并合并 requiredQty(对齐 DO Workbench / GoodPickExecutiondetail) | |||
| if (line.lots && line.lots.length > 0) { | |||
| const lotMap = new Map<number, any>(); | |||
| @@ -916,7 +914,6 @@ const JobPickExecution: React.FC<Props> = ({ 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<Props> = ({ filterArgs, onBackToList, printerCo | |||
| }); | |||
| } | |||
| /** 工單 API 常在有揀貨後仍回傳 lots: [],缺口只在 stockouts;此時用非 noLot 的已揀量扣 POL(對齊實際剩餘) */ | |||
| const stockoutsPickedSumNonNoLot = (line.stockouts ?? []).reduce( | |||
| (acc: number, s: any) => { | |||
| if (!s || s.noLot) return acc; | |||
| return acc + (Number(s.qty) || 0); | |||
| }, | |||
| 0, | |||
| ); | |||
| const noLotRemainingBasis = | |||
| lotsAllocatedSumForLine > 0 | |||
| ? lotsAllocatedSumForLine | |||
| : stockoutsPickedSumNonNoLot; | |||
| // stockouts:用于“无 suggested lot / noLot”场景也显示并可 submit 0 闭环 | |||
| // 批號需求數:對齊 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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; | |||
| @@ -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 && ( | |||
| <Alert severity="info" sx={{ mb: 2 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} {formatHongKongDateTime(lastLaserReceive.sentAt)} | |||
| 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} | |||
| {formatHongKongDateTime(lastLaserReceive.sentAt)} | |||
| {lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ mt: 0.5 }}> | |||
| 此時間只會在檸檬機回覆 receive 時更新。之後送出失敗不會改這裡。 | |||
| </Typography> | |||
| </Alert> | |||
| )} | |||
| @@ -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: <Sync />, | |||
| 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: <EventAvailable />, | |||
| labelKey: "nav.settings.itemDefaultShelfLife", | |||
| path: "/settings/itemDefaultShelfLife", | |||
| }, | |||
| { | |||
| id: "nav.settings.equipment", | |||
| icon: <Build />, | |||
| @@ -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<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| @@ -1221,8 +1231,35 @@ const WorkbenchPickExecution: React.FC<Props> = ({ 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(() => { | |||
| @@ -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<Props> = ({ po, warehouse, printerCombo }) => { | |||
| const cameras = useContext(CameraContext); | |||
| const { data: session } = useSession(); | |||
| @@ -302,16 +303,43 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| const [selectedRow, setSelectedRow] = useState<PurchaseOrderLine | null>(null); | |||
| const [stockInLine, setStockInLine] = useState<StockInLine[]>([]); | |||
| const [processedQty, setProcessedQty] = useState(0); | |||
| /** Tracks user/nav selection so query patches via history.replaceState stay authoritative. */ | |||
| const selectedPolIdRef = useRef<number | null>(null); | |||
| /** Patch PO edit query without Next soft-navigation (avoids scroll-to-top). */ | |||
| const patchPoEditQuery = useCallback( | |||
| (mutate: (params: URLSearchParams) => void) => { | |||
| if (typeof window === "undefined") return; | |||
| const params = new URLSearchParams(window.location.search); | |||
| mutate(params); | |||
| const qs = params.toString(); | |||
| window.history.replaceState( | |||
| window.history.state, | |||
| "", | |||
| qs ? `${pathname}?${qs}` : pathname, | |||
| ); | |||
| }, | |||
| [pathname], | |||
| ); | |||
| /** Keep selection + bottom stock-in grid in sync with selected / URL `polId`. */ | |||
| useEffect(() => { | |||
| 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ po, warehouse, printerCombo }) => { | |||
| )} | |||
| <Radio | |||
| checked={selectedRow?.id === row.id} | |||
| // onChange={handleRowSelect} | |||
| // onClick={(e) => e.stopPropagation()} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="left" sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemNo}> | |||
| @@ -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); | |||
| @@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||
| 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<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -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<DrinkProductionQtyResponse[]>([]); | |||
| const [shipmentData, setShipmentData] = useState<DrinkShipmentQtyResponse[]>( | |||
| [], | |||
| ); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | |||
| const [joStatusFilter, setJoStatusFilter] = useState<string>(""); | |||
| @@ -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 ( | |||
| <Chip | |||
| size="small" | |||
| label={label} | |||
| sx={{ height: 22 }} | |||
| /> | |||
| ); | |||
| }; | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| @@ -216,29 +314,31 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| /> | |||
| </LocalizationProvider> | |||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||
| <InputLabel id="drink-jo-status-filter-label"> | |||
| {t("Job Order Status")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="drink-jo-status-filter-label" | |||
| id="drink-jo-status-filter" | |||
| label={t("Job Order Status")} | |||
| value={joStatusFilter} | |||
| onChange={(e) => { | |||
| setJoStatusFilter(String(e.target.value)); | |||
| }} | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("All")}</em> | |||
| </MenuItem> | |||
| {JO_STATUS_FILTER_VALUES.map((v) => ( | |||
| <MenuItem key={v} value={v}> | |||
| {t(v, { ns: "jo" })} | |||
| {!isShipment && ( | |||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||
| <InputLabel id="drink-jo-status-filter-label"> | |||
| {t("Job Order Status")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="drink-jo-status-filter-label" | |||
| id="drink-jo-status-filter" | |||
| label={t("Job Order Status")} | |||
| value={joStatusFilter} | |||
| onChange={(e) => { | |||
| setJoStatusFilter(String(e.target.value)); | |||
| }} | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("All")}</em> | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| {JO_STATUS_FILTER_VALUES.map((v) => ( | |||
| <MenuItem key={v} value={v}> | |||
| {t(v, { ns: "jo" })} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| )} | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| @@ -246,11 +346,14 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| variant="outlined" | |||
| size="small" | |||
| startIcon={<FileDownloadIcon />} | |||
| 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 = () => { | |||
| <ToggleButton value="planned"> | |||
| {t("Drink detail mode: planned")} | |||
| </ToggleButton> | |||
| <ToggleButton value="shipment"> | |||
| {t("Drink detail mode: shipment")} | |||
| </ToggleButton> | |||
| </ToggleButtonGroup> | |||
| </Stack> | |||
| @@ -347,22 +453,185 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 140 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {isPlanned | |||
| ? t("Planned Output Qty") | |||
| : t("Stock Req. Qty")} | |||
| {qtyHeaders.left} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 140 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {isPlanned | |||
| ? t("Actual Output Qty") | |||
| : t("Production Qty")} | |||
| {qtyHeaders.right} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {data.length === 0 ? ( | |||
| {isShipment ? ( | |||
| shipmentData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={6} align="center"> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ py: 2, color: "text.secondary" }} | |||
| > | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| shipmentData.map((row, idx) => { | |||
| const rowKey = getShipmentRowKey(row, idx); | |||
| const deliveries: DrinkShipmentQtyDeliveryDetail[] = | |||
| row.deliveries ?? []; | |||
| const isExpanded = expandedRowKeys.has(rowKey); | |||
| const hasExpandable = deliveries.length > 0; | |||
| return ( | |||
| <React.Fragment key={rowKey}> | |||
| <TableRow hover={hasExpandable}> | |||
| <TableCell padding="checkbox"> | |||
| {hasExpandable ? ( | |||
| <IconButton | |||
| size="small" | |||
| aria-label={ | |||
| isExpanded | |||
| ? t("Collapse delivery order details") | |||
| : t("Expand delivery order details") | |||
| } | |||
| onClick={() => toggleRowExpanded(rowKey)} | |||
| > | |||
| {isExpanded ? ( | |||
| <ExpandLessIcon fontSize="small" /> | |||
| ) : ( | |||
| <ExpandMoreIcon fontSize="small" /> | |||
| )} | |||
| </IconButton> | |||
| ) : null} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.itemCode || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.itemName || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.uom || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalOrderQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalShippedQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| {hasExpandable && ( | |||
| <TableRow> | |||
| <TableCell | |||
| colSpan={6} | |||
| sx={{ py: 0, borderBottom: 0 }} | |||
| > | |||
| <Collapse | |||
| in={isExpanded} | |||
| timeout="auto" | |||
| unmountOnExit | |||
| > | |||
| <Box sx={{ py: 1.5, pl: 6, pr: 2 }}> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Delivery Order Code", { | |||
| ns: "do", | |||
| })} | |||
| </TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Delivery Order Status", { | |||
| ns: "do", | |||
| })} | |||
| </TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Shop Name", { ns: "do" })} | |||
| </TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Delivery Date", { ns: "do" })} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Shipment Order Qty")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Shipped Qty")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {deliveries.map((delivery) => ( | |||
| <TableRow | |||
| key={`${rowKey}-do-${delivery.deliveryOrderId}`} | |||
| > | |||
| <TableCell> | |||
| {delivery.deliveryOrderId > 0 ? ( | |||
| <MuiLink | |||
| component={NextLink} | |||
| href={`/do/edit?id=${delivery.deliveryOrderId}`} | |||
| underline="hover" | |||
| > | |||
| {delivery.deliveryOrderCode || | |||
| `DO-${delivery.deliveryOrderId}`} | |||
| </MuiLink> | |||
| ) : ( | |||
| delivery.deliveryOrderCode || | |||
| "-" | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {renderDoStatusChip( | |||
| delivery.deliveryOrderStatus, | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {delivery.shopName || | |||
| delivery.shopCode || | |||
| "-"} | |||
| </TableCell> | |||
| <TableCell> | |||
| {formatProductionDate( | |||
| delivery.deliveryDate, | |||
| )} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(delivery.orderQty)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(delivery.shippedQty)} | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </Box> | |||
| </Collapse> | |||
| </TableCell> | |||
| </TableRow> | |||
| )} | |||
| </React.Fragment> | |||
| ); | |||
| }) | |||
| ) | |||
| ) : filteredItemRows.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={6} align="center"> | |||
| <Typography | |||
| @@ -374,14 +643,10 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| 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 ( | |||
| <React.Fragment key={rowKey}> | |||
| @@ -422,12 +687,12 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalReqQty)} | |||
| {formatQty(totalReqQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalQty)} | |||
| {formatQty(totalQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| @@ -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<ProductProcessDetailProps> = ({ | |||
| jobOrderId, | |||
| onBack, | |||
| @@ -77,7 +77,7 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| 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 ( | |||
| <TableRow key={line.id}> | |||
| <TableCell> | |||
| @@ -773,6 +774,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| <Chip label={t("Pending")} color="default" size="small" /> | |||
| ) : isPaused ? ( | |||
| <Chip label={t("Paused")} color="warning" size="small" /> | |||
| ) : isAutoPass ? ( | |||
| <Chip label={t("Auto Pass")} color="default" size="small" /> | |||
| ) : isPass ? ( | |||
| <Chip label={t("Just Pass")} color="success" size="small" /> | |||
| ) : ( | |||
| @@ -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<ProductProcessListProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo, | |||
| @@ -447,7 +447,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| 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<number, AllJoborderProductProcessInfoResponse[]>(); | |||
| for (const p of tabProcesses) { | |||
| @@ -459,8 +459,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const result = new Map<number, boolean>(); | |||
| 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) => { | |||
| @@ -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<ProductionProcessStepExecutionProps> = ({ | |||
| lineId, | |||
| onBack, | |||
| @@ -62,9 +63,18 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| allLines, // ✅ 添加 | |||
| jobOrderId, // ✅ 添加 | |||
| }) => { | |||
| const { t } = useTranslation( ["common","jo"]); | |||
| const { t } = useTranslation( ["common","jo","productionProcess"]); | |||
| const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null); | |||
| const isCompleted = lineDetail?.status === "Completed" || lineDetail?.status === "Pass"; | |||
| const lineStatusNorm = String(lineDetail?.status ?? "") | |||
| .trim() | |||
| .toLowerCase() | |||
| .replace(/\s+/g, ""); | |||
| const isCompleted = | |||
| lineStatusNorm === "completed" || | |||
| lineStatusNorm === "pass" || | |||
| lineStatusNorm === "autopass"; | |||
| const isPassStatus = lineStatusNorm === "pass"; | |||
| const isAutoPassStatus = lineStatusNorm === "autopass"; | |||
| const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & { | |||
| byproductName: string; | |||
| byproductQty: number; | |||
| @@ -161,8 +171,16 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| }, [lineId]); | |||
| useEffect(() => { | |||
| // Don't show time remaining if completed | |||
| if (lineDetail?.status === "Completed" || lineDetail?.status === "Pass") { | |||
| // Don't show time remaining if completed / pass / autoPass | |||
| const statusNorm = String(lineDetail?.status ?? "") | |||
| .trim() | |||
| .toLowerCase() | |||
| .replace(/\s+/g, ""); | |||
| if ( | |||
| statusNorm === "completed" || | |||
| statusNorm === "pass" || | |||
| statusNorm === "autopass" | |||
| ) { | |||
| console.log("Line is completed"); | |||
| setRemainingTime(null); | |||
| setIsOverTime(false); | |||
| @@ -553,9 +571,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| {isCompleted ? ( | |||
| <Card sx={{ bgcolor: 'success.50', border: '2px solid', borderColor: 'success.main', mb: 3 }}> | |||
| <CardContent> | |||
| {lineDetail?.status === "Pass" ? ( | |||
| {isAutoPassStatus ? ( | |||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | |||
| {t("Passed Step")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||
| {t("Auto Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||
| </Typography> | |||
| ) : isPassStatus ? ( | |||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | |||
| {t("Just Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||
| </Typography> | |||
| ) : ( | |||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | |||
| @@ -618,13 +640,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(1)")}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectQty}</Typography> | |||
| <Typography>{lineDetail?.defectQty}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectUom || "-"}</Typography> | |||
| <Typography>{lineDetail?.defectUom || "-"}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectDescription || "-"}</Typography> | |||
| <Typography>{lineDetail?.defectDescription || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| @@ -632,13 +654,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectQty2}</Typography> | |||
| <Typography>{lineDetail?.defectQty2}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectUom2 || "-"}</Typography> | |||
| <Typography>{lineDetail?.defectUom2 || "-"}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectDescription2 || "-"}</Typography> | |||
| <Typography>{lineDetail?.defectDescription2 || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| @@ -646,13 +668,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectQty3}</Typography> | |||
| <Typography>{lineDetail?.defectQty3}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectUom3 || "-"}</Typography> | |||
| <Typography>{lineDetail?.defectUom3 || "-"}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectDescription3 || "-"}</Typography> | |||
| <Typography>{lineDetail?.defectDescription3 || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| <TableRow sx={{ bgcolor: 'error.50' }}> | |||
| @@ -660,10 +682,10 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <Typography fontWeight={500} color="error.dark">{t("Scrap")}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.scrapQty}</Typography> | |||
| <Typography>{lineDetail?.scrapQty}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.scrapUom || "-"}</Typography> | |||
| <Typography>{lineDetail?.scrapUom || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableBody> | |||
| @@ -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<string, unknown>[] = []; | |||
| const itemRows = shipmentData.map((item) => { | |||
| for (const delivery of item.deliveries ?? []) { | |||
| doRows.push({ | |||
| [t("Export meta: exported at")]: exportedAt, | |||
| [t("Drink detail mode label")]: viewLabel, | |||
| [t("Date")]: selectedDate, | |||
| [t("Item Code")]: item.itemCode ?? "", | |||
| [t("Goods Name")]: item.itemName ?? "", | |||
| [t("Unit")]: item.uom ?? "", | |||
| [t("Delivery Order Code", { ns: "do" })]: delivery.deliveryOrderCode ?? "", | |||
| [t("Delivery Order Status", { ns: "do" })]: formatDoStatus( | |||
| delivery.deliveryOrderStatus, | |||
| t, | |||
| ), | |||
| [t("Shop Name", { ns: "do" })]: | |||
| delivery.shopName || delivery.shopCode || "", | |||
| [t("Delivery Date", { ns: "do" })]: formatDate(delivery.deliveryDate), | |||
| [t("Shipment Order Qty")]: delivery.orderQty ?? 0, | |||
| [t("Shipped Qty")]: delivery.shippedQty ?? 0, | |||
| }); | |||
| } | |||
| return { | |||
| [t("Export meta: exported at")]: exportedAt, | |||
| [t("Drink detail mode label")]: viewLabel, | |||
| [t("Date")]: selectedDate, | |||
| [t("Item Code")]: item.itemCode ?? "", | |||
| [t("Goods Name")]: item.itemName ?? "", | |||
| [t("Unit")]: item.uom ?? "", | |||
| [t("Shipment Order Qty")]: item.totalOrderQty ?? 0, | |||
| [t("Shipped Qty")]: item.totalShippedQty ?? 0, | |||
| [t("DO count")]: (item.deliveries ?? []).length, | |||
| }; | |||
| }); | |||
| const filename = `DrinkProductionQty_shipment_${selectedDate}_${dayjs().format("HHmm")}`; | |||
| exportMultiSheetToXlsx( | |||
| [ | |||
| { name: t("Excel sheet: DO detail"), rows: doRows }, | |||
| { name: t("Excel sheet: item summary"), rows: itemRows }, | |||
| ], | |||
| filename, | |||
| ); | |||
| return; | |||
| } | |||
| const filterJos = ( | |||
| jobOrders: DrinkProductionQtyJobOrderDetail[], | |||
| ): DrinkProductionQtyJobOrderDetail[] => { | |||
| @@ -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<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -328,7 +328,29 @@ const UserExcelSheetView: React.FC<Props> = ({ 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; | |||
| @@ -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; | |||
| @@ -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" }, | |||
| ], | |||
| }, | |||
| ], | |||
| }, | |||
| ] | |||
| @@ -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", | |||
| @@ -87,6 +87,7 @@ | |||
| "Select Date": "選擇日期", | |||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | |||
| "Sign out": "Sign out", | |||
| "Language": "Language", | |||
| "Status": "狀態", | |||
| "Stock Qty": "庫存數量", | |||
| "Supporting Document": "證明文件", | |||
| @@ -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" | |||
| } | |||
| @@ -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", | |||
| @@ -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", | |||
| @@ -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", | |||
| @@ -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", | |||
| @@ -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" | |||
| } | |||
| } | |||
| } | |||
| @@ -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." | |||
| } | |||
| @@ -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<string> => { | |||
| // Logic to get language preference from cookies/headers/session | |||
| @@ -21,11 +23,13 @@ export const detectLanguage = async (): Promise<string> => { | |||
| 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, | |||
| @@ -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`; | |||
| } | |||
| @@ -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": "不選則全部", | |||
| @@ -90,6 +90,7 @@ | |||
| "Select Date": "選擇日期", | |||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | |||
| "Sign out": "登出", | |||
| "Language": "語言", | |||
| "Status": "狀態", | |||
| "Stock Qty": "庫存數量", | |||
| "Supporting Document": "證明文件", | |||
| @@ -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": "否" | |||
| } | |||
| @@ -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": "採購提料", | |||
| @@ -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": "暫停原因", | |||
| @@ -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": "列印機", | |||
| @@ -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": "貨品名稱", | |||
| @@ -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": "其他" | |||
| } | |||
| } | |||
| } | |||
| @@ -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": "伺服器錯誤,請稍後再試。" | |||
| } | |||