| @@ -42,9 +42,11 @@ const en = { | |||||
| "delivery_store": "Store", | "delivery_store": "Store", | ||||
| "delivery_staff": "Staff", | "delivery_staff": "Staff", | ||||
| "delivery_staffPlaceholder": "Leave empty for all", | "delivery_staffPlaceholder": "Leave empty for all", | ||||
| "delivery_staffPerfCaption": "Per-person pick count & total duration for period", | |||||
| "delivery_staffPerfCaption": "Per-person pick count, item kinds, picked qty & total duration for period", | |||||
| "delivery_colStaff": "Staff", | "delivery_colStaff": "Staff", | ||||
| "delivery_colPickCount": "Pick Count", | "delivery_colPickCount": "Pick Count", | ||||
| "delivery_colItemKindCount": "Item Kind Count", | |||||
| "delivery_colItemQtyPicked": "Item Qty Picked", | |||||
| "delivery_colTotalMin": "Total Min", | "delivery_colTotalMin": "Total Min", | ||||
| "delivery_colAvgMin": "Avg Min/Order", | "delivery_colAvgMin": "Avg Min/Order", | ||||
| "delivery_dailyByStaff": "Daily by Staff", | "delivery_dailyByStaff": "Daily by Staff", | ||||
| @@ -167,9 +169,11 @@ const zh = { | |||||
| "delivery_store": "倉別", | "delivery_store": "倉別", | ||||
| "delivery_staff": "員工", | "delivery_staff": "員工", | ||||
| "delivery_staffPlaceholder": "不選則全部", | "delivery_staffPlaceholder": "不選則全部", | ||||
| "delivery_staffPerfCaption": "週期內每人揀單數及總耗時(首揀至完成)", | |||||
| "delivery_staffPerfCaption": "週期內每人揀單數、品項數、揀貨數量及總耗時(首揀至完成)", | |||||
| "delivery_colStaff": "員工", | "delivery_colStaff": "員工", | ||||
| "delivery_colPickCount": "揀單數", | "delivery_colPickCount": "揀單數", | ||||
| "delivery_colItemKindCount": "品項數", | |||||
| "delivery_colItemQtyPicked": "揀貨數量", | |||||
| "delivery_colTotalMin": "總分鐘", | "delivery_colTotalMin": "總分鐘", | ||||
| "delivery_colAvgMin": "平均分鐘/單", | "delivery_colAvgMin": "平均分鐘/單", | ||||
| "delivery_dailyByStaff": "每日按員工單數", | "delivery_dailyByStaff": "每日按員工單數", | ||||
| @@ -66,6 +66,7 @@ const defaultCriteria: Criteria = { | |||||
| }, | }, | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ | |||||
| export default function DeliveryChartPage() { | export default function DeliveryChartPage() { | ||||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | ||||
| const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | ||||
| @@ -76,7 +77,14 @@ export default function DeliveryChartPage() { | |||||
| const [chartData, setChartData] = useState<{ | const [chartData, setChartData] = useState<{ | ||||
| delivery: { date: string; orderCount: number; totalQty: number }[]; | delivery: { date: string; orderCount: number; totalQty: number }[]; | ||||
| topItems: { itemCode: string; itemName: string; totalQty: number }[]; | topItems: { itemCode: string; itemName: string; totalQty: number }[]; | ||||
| staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[]; | |||||
| staffPerf: { | |||||
| date: string; | |||||
| staffName: string; | |||||
| orderCount: number; | |||||
| totalMinutes: number; | |||||
| itemKindCount: number; | |||||
| itemQtyPicked: number; | |||||
| }[]; | |||||
| }>({ delivery: [], topItems: [], staffPerf: [] }); | }>({ delivery: [], topItems: [], staffPerf: [] }); | ||||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | ||||
| @@ -146,6 +154,8 @@ export default function DeliveryChartPage() { | |||||
| staffName: string; | staffName: string; | ||||
| orderCount: number; | orderCount: number; | ||||
| totalMinutes: number; | totalMinutes: number; | ||||
| itemKindCount: number; | |||||
| itemQtyPicked: number; | |||||
| }[], | }[], | ||||
| })) | })) | ||||
| ) | ) | ||||
| @@ -164,18 +174,30 @@ export default function DeliveryChartPage() { | |||||
| }, [criteria.topItems.rangeDays]); | }, [criteria.topItems.rangeDays]); | ||||
| const staffPerfByStaff = useMemo(() => { | const staffPerfByStaff = useMemo(() => { | ||||
| const map = new Map<string, { orderCount: number; totalMinutes: number }>(); | |||||
| const map = new Map< | |||||
| string, | |||||
| { orderCount: number; totalMinutes: number; itemKindCount: number; itemQtyPicked: number } | |||||
| >(); | |||||
| for (const r of chartData.staffPerf) { | for (const r of chartData.staffPerf) { | ||||
| const name = r.staffName || "Unknown"; | const name = r.staffName || "Unknown"; | ||||
| const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 }; | |||||
| const cur = map.get(name) ?? { | |||||
| orderCount: 0, | |||||
| totalMinutes: 0, | |||||
| itemKindCount: 0, | |||||
| itemQtyPicked: 0, | |||||
| }; | |||||
| map.set(name, { | map.set(name, { | ||||
| orderCount: cur.orderCount + r.orderCount, | orderCount: cur.orderCount + r.orderCount, | ||||
| totalMinutes: cur.totalMinutes + r.totalMinutes, | totalMinutes: cur.totalMinutes + r.totalMinutes, | ||||
| itemKindCount: cur.itemKindCount + r.itemKindCount, | |||||
| itemQtyPicked: cur.itemQtyPicked + r.itemQtyPicked, | |||||
| }); | }); | ||||
| } | } | ||||
| return Array.from(map.entries()).map(([staffName, v]) => ({ | return Array.from(map.entries()).map(([staffName, v]) => ({ | ||||
| staffName, | staffName, | ||||
| orderCount: v.orderCount, | orderCount: v.orderCount, | ||||
| itemKindCount: v.itemKindCount, | |||||
| itemQtyPicked: v.itemQtyPicked, | |||||
| totalMinutes: v.totalMinutes, | totalMinutes: v.totalMinutes, | ||||
| avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0, | avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0, | ||||
| })); | })); | ||||
| @@ -298,7 +320,14 @@ export default function DeliveryChartPage() { | |||||
| <ChartCard | <ChartCard | ||||
| title="員工發貨績效(每日揀貨數量與耗時)" | title="員工發貨績效(每日揀貨數量與耗時)" | ||||
| exportFilename="員工發貨績效" | exportFilename="員工發貨績效" | ||||
| exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))} | |||||
| exportData={chartData.staffPerf.map((r) => ({ | |||||
| 日期: r.date, | |||||
| 員工: r.staffName, | |||||
| 揀單數: r.orderCount, | |||||
| 總揀貨款數: r.itemKindCount, | |||||
| 總揀貨件數: r.itemQtyPicked, | |||||
| 總分鐘: r.totalMinutes, | |||||
| }))} | |||||
| filters={ | filters={ | ||||
| <> | <> | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| @@ -388,7 +417,7 @@ export default function DeliveryChartPage() { | |||||
| <> | <> | ||||
| <Box sx={{ mb: 2 }}> | <Box sx={{ mb: 2 }}> | ||||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | <Typography variant="subtitle2" color="text.secondary" gutterBottom> | ||||
| 週期內每人揀單數及總耗時(首揀至完成) | |||||
| 週期內每人揀單數、總揀貨款數、總揀貨件數及總耗時(首揀至完成) | |||||
| </Typography> | </Typography> | ||||
| <Box | <Box | ||||
| component="table" | component="table" | ||||
| @@ -409,6 +438,8 @@ export default function DeliveryChartPage() { | |||||
| <tr> | <tr> | ||||
| <th>員工</th> | <th>員工</th> | ||||
| <th>揀單數</th> | <th>揀單數</th> | ||||
| <th>總揀貨款數</th> | |||||
| <th>總揀貨件數</th> | |||||
| <th>總分鐘</th> | <th>總分鐘</th> | ||||
| <th>平均分鐘/單</th> | <th>平均分鐘/單</th> | ||||
| </tr> | </tr> | ||||
| @@ -416,13 +447,15 @@ export default function DeliveryChartPage() { | |||||
| <tbody> | <tbody> | ||||
| {staffPerfByStaff.length === 0 ? ( | {staffPerfByStaff.length === 0 ? ( | ||||
| <tr> | <tr> | ||||
| <td colSpan={4}>無數據</td> | |||||
| <td colSpan={6}>無數據</td> | |||||
| </tr> | </tr> | ||||
| ) : ( | ) : ( | ||||
| staffPerfByStaff.map((row) => ( | staffPerfByStaff.map((row) => ( | ||||
| <tr key={row.staffName}> | <tr key={row.staffName}> | ||||
| <td>{row.staffName}</td> | <td>{row.staffName}</td> | ||||
| <td>{row.orderCount}</td> | <td>{row.orderCount}</td> | ||||
| <td>{row.itemKindCount}</td> | |||||
| <td>{row.itemQtyPicked}</td> | |||||
| <td>{row.totalMinutes}</td> | <td>{row.totalMinutes}</td> | ||||
| <td>{row.avgMinutesPerOrder}</td> | <td>{row.avgMinutesPerOrder}</td> | ||||
| </tr> | </tr> | ||||
| @@ -1,10 +1,24 @@ | |||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| import { redirect } from "next/navigation"; | |||||
| export default function M18SyncLayout({ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ | |||||
| export default async function M18SyncLayout({ | |||||
| children, | children, | ||||
| }: { | }: { | ||||
| children: React.ReactNode; | children: React.ReactNode; | ||||
| }) { | }) { | ||||
| const session = await getServerSession(authOptions); | |||||
| const abilities = session?.user?.abilities ?? []; | |||||
| const canAccess = | |||||
| hasAbility(abilities, AUTH.M18_SYNC) || hasAbility(abilities, AUTH.ADMIN); | |||||
| if (!canAccess) { | |||||
| redirect("/dashboard"); | |||||
| } | |||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["m18Sync", "navigation", "common"]}> | <I18nProvider namespaces={["m18Sync", "navigation", "common"]}> | ||||
| {children} | {children} | ||||
| @@ -26,6 +26,7 @@ function TabPanel(props: TabPanelProps) { | |||||
| ); | ); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ | |||||
| export default function M18SynPage() { | export default function M18SynPage() { | ||||
| const [tabValue, setTabValue] = useState(0); | const [tabValue, setTabValue] = useState(0); | ||||
| @@ -270,7 +271,7 @@ export default function M18SynPage() { | |||||
| M18 Sync (by code) | M18 Sync (by code) | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | ||||
| ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||||
| Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||||
| </Typography> | </Typography> | ||||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | ||||
| @@ -38,7 +38,7 @@ const production: React.FC = async () => { | |||||
| {t("Create Process")} | {t("Create Process")} | ||||
| </Button> */} | </Button> */} | ||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["production","productionProcess","navigation","common","purchaseOrder","jo","dashboard"]}> | |||||
| <I18nProvider namespaces={["production","productionProcess","navigation","common","purchaseOrder","jo","do","dashboard"]}> | |||||
| <ProductionProcessPage printerCombo={printerCombo} /> {/* Use new component */} | <ProductionProcessPage printerCombo={printerCombo} /> {/* Use new component */} | ||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| @@ -36,7 +36,7 @@ const productionProcess: React.FC = async () => { | |||||
| {t("Create Process")} | {t("Create Process")} | ||||
| </Button> */} | </Button> */} | ||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["productionProcess","navigation","common","purchaseOrder","jo","dashboard"]}> | |||||
| <I18nProvider namespaces={["productionProcess","navigation","common","purchaseOrder","jo","do","dashboard"]}> | |||||
| <Suspense fallback={<ProductionProcessLoading />}> | <Suspense fallback={<ProductionProcessLoading />}> | ||||
| <ProductionProcessPage printerCombo={printerCombo} /> | <ProductionProcessPage printerCombo={printerCombo} /> | ||||
| </Suspense> | </Suspense> | ||||
| @@ -1,6 +1,7 @@ | |||||
| "use client"; | "use client"; | ||||
| import React from "react"; | import React from "react"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | import { | ||||
| FormHelperText, | FormHelperText, | ||||
| Grid, | Grid, | ||||
| @@ -8,10 +9,10 @@ import { | |||||
| TextField, | TextField, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| export type QcItemFilter = "measurable" | "non_measurable"; | |||||
| export type QcItemFilter = "all" | "measurable"; | |||||
| export const REP010_DEFAULT_CRITERIA: Record<string, string> = { | export const REP010_DEFAULT_CRITERIA: Record<string, string> = { | ||||
| qcItemFilter: "measurable", | |||||
| qcItemScope: "all", | |||||
| }; | }; | ||||
| interface ItemQcReportFiltersProps { | interface ItemQcReportFiltersProps { | ||||
| @@ -21,18 +22,24 @@ interface ItemQcReportFiltersProps { | |||||
| const gridSize = { xs: 12, sm: 6 }; | const gridSize = { xs: 12, sm: 6 }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| export default function ItemQcReportFilters({ | export default function ItemQcReportFilters({ | ||||
| criteria, | criteria, | ||||
| onFieldChange, | onFieldChange, | ||||
| }: ItemQcReportFiltersProps) { | }: ItemQcReportFiltersProps) { | ||||
| const qcItemFilter = (criteria.qcItemFilter || "measurable") as QcItemFilter; | |||||
| const { t } = useTranslation("report"); | |||||
| const qcItemFilter = (criteria.qcItemScope || "all") as QcItemFilter; | |||||
| const field = (name: string, fallback: string) => | |||||
| t(`reports.rep-010.fields.${name}`, { defaultValue: fallback }); | |||||
| const opt = (fieldName: string, value: string, fallback: string) => | |||||
| t(`reports.rep-010.options.${fieldName}.${value}`, { defaultValue: fallback }); | |||||
| return ( | return ( | ||||
| <Grid container spacing={3}> | <Grid container spacing={3}> | ||||
| <Grid item {...gridSize}> | <Grid item {...gridSize}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label="QC 檢測日期:由 Last In Date Start" | |||||
| label={field("lastInDateStart", "QC 檢測日期:由 QC Date Start")} | |||||
| type="date" | type="date" | ||||
| value={criteria.lastInDateStart || ""} | value={criteria.lastInDateStart || ""} | ||||
| onChange={(e) => onFieldChange("lastInDateStart", e.target.value)} | onChange={(e) => onFieldChange("lastInDateStart", e.target.value)} | ||||
| @@ -42,7 +49,7 @@ export default function ItemQcReportFilters({ | |||||
| <Grid item {...gridSize}> | <Grid item {...gridSize}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label="QC 檢測日期:至 Last In Date End" | |||||
| label={field("lastInDateEnd", "QC 檢測日期:至 QC Date End")} | |||||
| type="date" | type="date" | ||||
| value={criteria.lastInDateEnd || ""} | value={criteria.lastInDateEnd || ""} | ||||
| onChange={(e) => onFieldChange("lastInDateEnd", e.target.value)} | onChange={(e) => onFieldChange("lastInDateEnd", e.target.value)} | ||||
| @@ -53,19 +60,19 @@ export default function ItemQcReportFilters({ | |||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| select | select | ||||
| label="QC 類型" | |||||
| value={criteria.qcType || ""} | |||||
| label={field("qcType", "QC 類型")} | |||||
| value={criteria.qcType || "all"} | |||||
| onChange={(e) => onFieldChange("qcType", e.target.value)} | onChange={(e) => onFieldChange("qcType", e.target.value)} | ||||
| > | > | ||||
| <MenuItem value="">全部</MenuItem> | |||||
| <MenuItem value="IQC">IQC</MenuItem> | |||||
| <MenuItem value="EPQC">EPQC</MenuItem> | |||||
| <MenuItem value="all">{opt("qcType", "all", "全部")}</MenuItem> | |||||
| <MenuItem value="IQC">{opt("qcType", "IQC", "IQC(採購)")}</MenuItem> | |||||
| <MenuItem value="EPQC">{opt("qcType", "EPQC", "EPQC(工單)")}</MenuItem> | |||||
| </TextField> | </TextField> | ||||
| </Grid> | </Grid> | ||||
| <Grid item {...gridSize}> | <Grid item {...gridSize}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label="貨品編號 Item Code" | |||||
| label={field("itemCode", "貨品編號 Item Code")} | |||||
| value={criteria.itemCode || ""} | value={criteria.itemCode || ""} | ||||
| onChange={(e) => onFieldChange("itemCode", e.target.value)} | onChange={(e) => onFieldChange("itemCode", e.target.value)} | ||||
| placeholder="e.g. MJ0364" | placeholder="e.g. MJ0364" | ||||
| @@ -75,17 +82,17 @@ export default function ItemQcReportFilters({ | |||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| select | select | ||||
| label="溫濕度數據篩選" | |||||
| value={qcItemFilter} | |||||
| onChange={(e) => onFieldChange("qcItemFilter", e.target.value)} | |||||
| label={field("qcItemScope", "QC 項目範圍")} | |||||
| value={qcItemFilter === "measurable" ? "measurable" : "all"} | |||||
| onChange={(e) => onFieldChange("qcItemScope", e.target.value)} | |||||
| > | > | ||||
| <MenuItem value="measurable">只包含溫度濕度</MenuItem> | |||||
| <MenuItem value="non_measurable">不包含溫度濕度</MenuItem> | |||||
| <MenuItem value="all">{opt("qcItemScope", "all", "全部 QC 項目")}</MenuItem> | |||||
| <MenuItem value="measurable">{opt("qcItemScope", "measurable", "只包含溫度濕度")}</MenuItem> | |||||
| </TextField> | </TextField> | ||||
| <FormHelperText> | <FormHelperText> | ||||
| {qcItemFilter === "measurable" | {qcItemFilter === "measurable" | ||||
| ? "僅匯出已填寫實測值的溫度/濕度 QC 項目。" | |||||
| : "僅匯出非溫度/濕度之其他 QC 檢驗項目。"} | |||||
| ? t("qcScopeHelpMeasurable") | |||||
| : t("qcScopeHelpAll")} | |||||
| </FormHelperText> | </FormHelperText> | ||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| @@ -19,17 +19,22 @@ import PieChartOutlineOutlinedIcon from "@mui/icons-material/PieChartOutlineOutl | |||||
| import type { SvgIconComponent } from "@mui/icons-material"; | import type { SvgIconComponent } from "@mui/icons-material"; | ||||
| import { REPORTS } from "@/config/reportConfig"; | import { REPORTS } from "@/config/reportConfig"; | ||||
| import { REPORT_CATEGORIES, type ReportCategoryConfig } from "./reportCategories"; | import { REPORT_CATEGORIES, type ReportCategoryConfig } from "./reportCategories"; | ||||
| import { useReportLabels } from "./reportI18n"; | |||||
| const REPORT_ICON_MAP: Record<string, SvgIconComponent> = { | const REPORT_ICON_MAP: Record<string, SvgIconComponent> = { | ||||
| "rep-011": Inventory2OutlinedIcon, | "rep-011": Inventory2OutlinedIcon, | ||||
| "rep-007": MonetizationOnOutlinedIcon, | "rep-007": MonetizationOnOutlinedIcon, | ||||
| "rep-012": LayersOutlinedIcon, | "rep-012": LayersOutlinedIcon, | ||||
| "rep-021": Inventory2OutlinedIcon, | |||||
| "rep-010": SearchOutlinedIcon, | "rep-010": SearchOutlinedIcon, | ||||
| "rep-004": LocalShippingOutlinedIcon, | "rep-004": LocalShippingOutlinedIcon, | ||||
| "rep-014": LocalShippingOutlinedIcon, | "rep-014": LocalShippingOutlinedIcon, | ||||
| "rep-008": OutboundOutlinedIcon, | "rep-008": OutboundOutlinedIcon, | ||||
| "rep-009": OutboundOutlinedIcon, | "rep-009": OutboundOutlinedIcon, | ||||
| "rep-013": LocalShippingOutlinedIcon, | "rep-013": LocalShippingOutlinedIcon, | ||||
| "rep-016": OutboundOutlinedIcon, | |||||
| "rep-017": LocalShippingOutlinedIcon, | |||||
| "rep-018": SearchOutlinedIcon, | |||||
| "rep-006": BarChartOutlinedIcon, | "rep-006": BarChartOutlinedIcon, | ||||
| "rep-005": PieChartOutlineOutlinedIcon, | "rep-005": PieChartOutlineOutlinedIcon, | ||||
| "rep-015": LayersOutlinedIcon, | "rep-015": LayersOutlinedIcon, | ||||
| @@ -113,6 +118,7 @@ function CategoryColumn({ | |||||
| selectedReportId: string; | selectedReportId: string; | ||||
| onSelectReport: (reportId: string) => void; | onSelectReport: (reportId: string) => void; | ||||
| }) { | }) { | ||||
| const { reportTitle, categoryTitle } = useReportLabels(); | |||||
| const reports = category.reportIds | const reports = category.reportIds | ||||
| .map((id) => reportById[id]) | .map((id) => reportById[id]) | ||||
| .filter(Boolean); | .filter(Boolean); | ||||
| @@ -137,7 +143,7 @@ function CategoryColumn({ | |||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="subtitle1" fontWeight="bold"> | <Typography variant="subtitle1" fontWeight="bold"> | ||||
| {category.title} | |||||
| {categoryTitle(category.id, category.title)} | |||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| <Box | <Box | ||||
| @@ -152,7 +158,7 @@ function CategoryColumn({ | |||||
| <Grid item xs={6} key={report.id}> | <Grid item xs={6} key={report.id}> | ||||
| <ReportCard | <ReportCard | ||||
| reportId={report.id} | reportId={report.id} | ||||
| title={report.title} | |||||
| title={reportTitle(report)} | |||||
| accent={category.accent} | accent={category.accent} | ||||
| selected={selectedReportId === report.id} | selected={selectedReportId === report.id} | ||||
| onClick={() => onSelectReport(report.id)} | onClick={() => onSelectReport(report.id)} | ||||
| @@ -165,6 +171,7 @@ function CategoryColumn({ | |||||
| ); | ); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| export default function ReportSelectionDashboard({ | export default function ReportSelectionDashboard({ | ||||
| selectedReportId, | selectedReportId, | ||||
| onSelectReport, | onSelectReport, | ||||
| @@ -1,6 +1,7 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | import { | ||||
| Dialog, | Dialog, | ||||
| DialogTitle, | DialogTitle, | ||||
| @@ -43,6 +44,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| reportTitle = '成品/半成品生產分析報告', | reportTitle = '成品/半成品生產分析報告', | ||||
| onExportSuccess, | onExportSuccess, | ||||
| }: SemiFGProductionAnalysisReportProps) { | }: SemiFGProductionAnalysisReportProps) { | ||||
| const { t } = useTranslation("report"); | |||||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | const [showConfirmDialog, setShowConfirmDialog] = useState(false); | ||||
| const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | ||||
| const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | ||||
| @@ -70,7 +72,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| setExportFormat(format); | setExportFormat(format); | ||||
| // Validate required fields | // Validate required fields | ||||
| if (requiredFieldLabels.length > 0) { | if (requiredFieldLabels.length > 0) { | ||||
| alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`); | |||||
| alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') })); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -107,7 +109,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| setShowConfirmDialog(false); | setShowConfirmDialog(false); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Failed to generate report:', error); | console.error('Failed to generate report:', error); | ||||
| alert('An error occurred while generating the report. Please try again.'); | |||||
| alert(t('generateError')); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| @@ -124,7 +126,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? '生成 PDF...' : '下載報告 (PDF)'} | |||||
| {loading ? t('generatingPdf') : t('downloadPdf')} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -134,7 +136,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? '生成 Excel...' : '下載報告 (Excel)'} | |||||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||||
| </Button> | </Button> | ||||
| </div> | </div> | ||||
| @@ -147,22 +149,22 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| > | > | ||||
| <DialogTitle> | <DialogTitle> | ||||
| <Typography variant="h6" fontWeight="bold"> | <Typography variant="h6" fontWeight="bold"> | ||||
| 已選擇的物料編號以及列印成品/半成品生產分析報告 | |||||
| {t('semiFgConfirmTitle')} | |||||
| </Typography> | </Typography> | ||||
| </DialogTitle> | </DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
| 請確認以下已選擇的物料編號及其類別: | |||||
| {t('semiFgConfirmHint')} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer component={Paper} variant="outlined"> | <TableContainer component={Paper} variant="outlined"> | ||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | <TableCell> | ||||
| <strong>物料編號及名稱</strong> | |||||
| <strong>{t('semiFgColItem')}</strong> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <strong>類別</strong> | |||||
| <strong>{t('semiFgColCategory')}</strong> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| @@ -187,7 +189,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| </TableContainer> | </TableContainer> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions sx={{ p: 2 }}> | <DialogActions sx={{ p: 2 }}> | ||||
| <Button onClick={() => setShowConfirmDialog(false)}>取消</Button> | |||||
| <Button onClick={() => setShowConfirmDialog(false)}>{t('cancel')}</Button> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| onClick={() => executeExport()} | onClick={() => executeExport()} | ||||
| @@ -196,11 +198,11 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| > | > | ||||
| {loading | {loading | ||||
| ? exportFormat === 'excel' | ? exportFormat === 'excel' | ||||
| ? '生成 Excel...' | |||||
| : '生成 PDF...' | |||||
| ? t('generatingExcel') | |||||
| : t('generatingPdf') | |||||
| : exportFormat === 'excel' | : exportFormat === 'excel' | ||||
| ? '確認下載 Excel' | |||||
| : '確認下載 PDF'} | |||||
| ? t('confirmDownloadExcel') | |||||
| : t('confirmDownloadPdf')} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -5,6 +5,8 @@ import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| import { | import { | ||||
| exportMultiSheetToXlsx, | exportMultiSheetToXlsx, | ||||
| } from "@/app/(main)/chart/_components/exportChartToXlsx"; | } from "@/app/(main)/chart/_components/exportChartToXlsx"; | ||||
| import { reportExcelT as tx } from "./reportI18n"; | |||||
| import type { TFunction } from "i18next"; | |||||
| export interface BomShopSyncReportSummary { | export interface BomShopSyncReportSummary { | ||||
| totalAttempts?: number; | totalAttempts?: number; | ||||
| @@ -55,43 +57,64 @@ export interface BomShopSyncReportResponse { | |||||
| materialRows?: BomShopSyncMaterialRow[]; | materialRows?: BomShopSyncMaterialRow[]; | ||||
| } | } | ||||
| const SHEET_SYNC = "BOM同步記錄"; | |||||
| const SHEET_MATERIALS = "BOM物料明細"; | |||||
| const NO_DATA_NOTE = | |||||
| "(篩選範圍內無資料 / No records in the selected range)"; | |||||
| function bomSyncLabels(t?: TFunction) { | |||||
| return { | |||||
| sheetSync: tx(t, "excel.bomSync.sheetSync", "BOM同步記錄"), | |||||
| sheetMaterials: tx(t, "excel.bomSync.sheetMaterials", "BOM物料明細"), | |||||
| noData: tx(t, "excel.noData", "(篩選範圍內無資料)"), | |||||
| syncTime: tx(t, "excel.bomSync.syncTime", "同步時間"), | |||||
| finishedItemCode: tx(t, "excel.bomSync.finishedItemCode", "成品貨號"), | |||||
| finishedItemName: tx(t, "excel.bomSync.finishedItemName", "成品名稱"), | |||||
| bomRoutingCode: tx(t, "excel.bomSync.bomRoutingCode", "BOM路由編號"), | |||||
| version: tx(t, "excel.bomSync.version", "版本"), | |||||
| status: tx(t, "excel.bomSync.status", "狀態"), | |||||
| failureReason: tx(t, "excel.bomSync.failureReason", "失敗原因"), | |||||
| message: tx(t, "excel.bomSync.message", "訊息"), | |||||
| lineNo: tx(t, "excel.bomSync.lineNo", "行號"), | |||||
| materialName: tx(t, "excel.bomSync.materialName", "物料名稱"), | |||||
| uom: tx(t, "excel.bomSync.uom", "單位"), | |||||
| qty: tx(t, "excel.bomSync.qty", "用量"), | |||||
| statusSuccess: tx(t, "excel.bomSync.statusSuccess", "成功"), | |||||
| statusSkipped: tx(t, "excel.bomSync.statusSkipped", "略過(內容未變)"), | |||||
| statusFailed: tx(t, "excel.bomSync.statusFailed", "失敗"), | |||||
| }; | |||||
| } | |||||
| /** Column keys for sheet 1 — used for headers when there are no data rows. */ | |||||
| function emptySyncSheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> { | |||||
| function emptySyncSheetRow( | |||||
| L: ReturnType<typeof bomSyncLabels>, | |||||
| note?: string, | |||||
| ): Record<string, unknown> { | |||||
| return { | return { | ||||
| 同步時間: note, | |||||
| 成品貨號: "", | |||||
| 成品名稱: "", | |||||
| BOM路由編號: "", | |||||
| [L.syncTime]: note ?? L.noData, | |||||
| [L.finishedItemCode]: "", | |||||
| [L.finishedItemName]: "", | |||||
| [L.bomRoutingCode]: "", | |||||
| "M18 BOM Code": "", | "M18 BOM Code": "", | ||||
| 版本: "", | |||||
| [L.version]: "", | |||||
| "M18 Record Id": "", | "M18 Record Id": "", | ||||
| 狀態: "", | |||||
| 失敗原因: "", | |||||
| 訊息: "", | |||||
| [L.status]: "", | |||||
| [L.failureReason]: "", | |||||
| [L.message]: "", | |||||
| "BOM Id": "", | "BOM Id": "", | ||||
| "Sync Log Id": "", | "Sync Log Id": "", | ||||
| }; | }; | ||||
| } | } | ||||
| /** Column keys for sheet 2 — used for headers when there are no data rows. */ | |||||
| function emptyMaterialSheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> { | |||||
| function emptyMaterialSheetRow( | |||||
| L: ReturnType<typeof bomSyncLabels>, | |||||
| note?: string, | |||||
| ): Record<string, unknown> { | |||||
| return { | return { | ||||
| 同步時間: note, | |||||
| 成品貨號: "", | |||||
| [L.syncTime]: note ?? L.noData, | |||||
| [L.finishedItemCode]: "", | |||||
| "M18 BOM Code": "", | "M18 BOM Code": "", | ||||
| 版本: "", | |||||
| 狀態: "", | |||||
| 行號: "", | |||||
| 物料名稱: "", | |||||
| [L.version]: "", | |||||
| [L.status]: "", | |||||
| [L.lineNo]: "", | |||||
| [L.materialName]: "", | |||||
| "M18 Product Id": "", | "M18 Product Id": "", | ||||
| 單位: "", | |||||
| 用量: "", | |||||
| [L.uom]: "", | |||||
| [L.qty]: "", | |||||
| "M18 Supplier Id": "", | "M18 Supplier Id": "", | ||||
| "M18 Purchase Unit Id": "", | "M18 Purchase Unit Id": "", | ||||
| "Sync Log Id": "", | "Sync Log Id": "", | ||||
| @@ -117,52 +140,59 @@ export async function fetchBomShopSyncReportData( | |||||
| return (await response.json()) as BomShopSyncReportResponse; | return (await response.json()) as BomShopSyncReportResponse; | ||||
| } | } | ||||
| function syncStatusLabel(status: string | undefined): string { | |||||
| function syncStatusLabel( | |||||
| status: string | undefined, | |||||
| L: ReturnType<typeof bomSyncLabels>, | |||||
| ): string { | |||||
| switch (status) { | switch (status) { | ||||
| case "SUCCESS": | case "SUCCESS": | ||||
| return "成功"; | |||||
| return L.statusSuccess; | |||||
| case "SKIPPED_UNCHANGED": | case "SKIPPED_UNCHANGED": | ||||
| return "略過(內容未變)"; | |||||
| return L.statusSkipped; | |||||
| case "FAILED": | case "FAILED": | ||||
| return "失敗"; | |||||
| return L.statusFailed; | |||||
| default: | default: | ||||
| return status ?? ""; | return status ?? ""; | ||||
| } | } | ||||
| } | } | ||||
| function toSyncExcelRow(r: BomShopSyncRow): Record<string, unknown> { | |||||
| const base = emptySyncSheetRow(""); | |||||
| function toSyncExcelRow( | |||||
| r: BomShopSyncRow, | |||||
| L: ReturnType<typeof bomSyncLabels>, | |||||
| ): Record<string, unknown> { | |||||
| return { | return { | ||||
| ...base, | |||||
| 同步時間: r.syncDateTime ?? "", | |||||
| 成品貨號: r.finishedItemCode ?? "", | |||||
| 成品名稱: r.finishedItemName ?? "", | |||||
| BOM路由編號: r.bomRoutingCode ?? "", | |||||
| ...emptySyncSheetRow(L, ""), | |||||
| [L.syncTime]: r.syncDateTime ?? "", | |||||
| [L.finishedItemCode]: r.finishedItemCode ?? "", | |||||
| [L.finishedItemName]: r.finishedItemName ?? "", | |||||
| [L.bomRoutingCode]: r.bomRoutingCode ?? "", | |||||
| "M18 BOM Code": r.m18HeaderCode ?? "", | "M18 BOM Code": r.m18HeaderCode ?? "", | ||||
| 版本: r.version ?? "", | |||||
| [L.version]: r.version ?? "", | |||||
| "M18 Record Id": r.m18RecordId ?? "", | "M18 Record Id": r.m18RecordId ?? "", | ||||
| 狀態: syncStatusLabel(r.syncStatus), | |||||
| 失敗原因: r.failureReason ?? "", | |||||
| 訊息: r.message ?? "", | |||||
| [L.status]: syncStatusLabel(r.syncStatus, L), | |||||
| [L.failureReason]: r.failureReason ?? "", | |||||
| [L.message]: r.message ?? "", | |||||
| "BOM Id": r.bomId ?? "", | "BOM Id": r.bomId ?? "", | ||||
| "Sync Log Id": r.syncLogId ?? "", | "Sync Log Id": r.syncLogId ?? "", | ||||
| }; | }; | ||||
| } | } | ||||
| function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record<string, unknown> { | |||||
| const base = emptyMaterialSheetRow(""); | |||||
| function toMaterialExcelRow( | |||||
| r: BomShopSyncMaterialRow, | |||||
| L: ReturnType<typeof bomSyncLabels>, | |||||
| ): Record<string, unknown> { | |||||
| return { | return { | ||||
| ...base, | |||||
| 同步時間: r.syncDateTime ?? "", | |||||
| 成品貨號: r.finishedItemCode ?? "", | |||||
| ...emptyMaterialSheetRow(L, ""), | |||||
| [L.syncTime]: r.syncDateTime ?? "", | |||||
| [L.finishedItemCode]: r.finishedItemCode ?? "", | |||||
| "M18 BOM Code": r.m18HeaderCode ?? "", | "M18 BOM Code": r.m18HeaderCode ?? "", | ||||
| 版本: r.version ?? "", | |||||
| 狀態: syncStatusLabel(r.syncStatus), | |||||
| 行號: r.lineNo ?? "", | |||||
| 物料名稱: r.materialName ?? "", | |||||
| [L.version]: r.version ?? "", | |||||
| [L.status]: syncStatusLabel(r.syncStatus, L), | |||||
| [L.lineNo]: r.lineNo ?? "", | |||||
| [L.materialName]: r.materialName ?? "", | |||||
| "M18 Product Id": r.udfProductM18Id ?? "", | "M18 Product Id": r.udfProductM18Id ?? "", | ||||
| 單位: r.udfBaseUnit ?? "", | |||||
| 用量: r.udfQty ?? "", | |||||
| [L.uom]: r.udfBaseUnit ?? "", | |||||
| [L.qty]: r.udfQty ?? "", | |||||
| "M18 Supplier Id": r.udfSupplierM18Id ?? "", | "M18 Supplier Id": r.udfSupplierM18Id ?? "", | ||||
| "M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "", | "M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "", | ||||
| "Sync Log Id": r.syncLogId ?? "", | "Sync Log Id": r.syncLogId ?? "", | ||||
| @@ -172,16 +202,18 @@ function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record<string, unknown> | |||||
| export async function generateBomShopSyncReportExcel( | export async function generateBomShopSyncReportExcel( | ||||
| criteria: Record<string, string>, | criteria: Record<string, string>, | ||||
| reportTitle: string = "M18 BOM Shop 同步記錄", | reportTitle: string = "M18 BOM Shop 同步記錄", | ||||
| t?: TFunction, | |||||
| ): Promise<void> { | ): Promise<void> { | ||||
| const L = bomSyncLabels(t); | |||||
| const data = await fetchBomShopSyncReportData(criteria); | const data = await fetchBomShopSyncReportData(criteria); | ||||
| const syncRows = | const syncRows = | ||||
| (data.syncRows ?? []).length > 0 | (data.syncRows ?? []).length > 0 | ||||
| ? (data.syncRows ?? []).map(toSyncExcelRow) | |||||
| : [emptySyncSheetRow()]; | |||||
| ? (data.syncRows ?? []).map((r) => toSyncExcelRow(r, L)) | |||||
| : [emptySyncSheetRow(L)]; | |||||
| const materialRows = | const materialRows = | ||||
| (data.materialRows ?? []).length > 0 | (data.materialRows ?? []).length > 0 | ||||
| ? (data.materialRows ?? []).map(toMaterialExcelRow) | |||||
| : [emptyMaterialSheetRow()]; | |||||
| ? (data.materialRows ?? []).map((r) => toMaterialExcelRow(r, L)) | |||||
| : [emptyMaterialSheetRow(L)]; | |||||
| const start = criteria.syncDateStart; | const start = criteria.syncDateStart; | ||||
| const end = criteria.syncDateEnd; | const end = criteria.syncDateEnd; | ||||
| @@ -197,8 +229,8 @@ export async function generateBomShopSyncReportExcel( | |||||
| exportMultiSheetToXlsx( | exportMultiSheetToXlsx( | ||||
| [ | [ | ||||
| { name: SHEET_SYNC, rows: syncRows }, | |||||
| { name: SHEET_MATERIALS, rows: materialRows }, | |||||
| { name: L.sheetSync, rows: syncRows }, | |||||
| { name: L.sheetMaterials, rows: materialRows }, | |||||
| ], | ], | ||||
| filename, | filename, | ||||
| ); | ); | ||||
| @@ -6,6 +6,8 @@ import { | |||||
| exportChartToXlsx, | exportChartToXlsx, | ||||
| exportMultiSheetToXlsx, | exportMultiSheetToXlsx, | ||||
| } from "@/app/(main)/chart/_components/exportChartToXlsx"; | } from "@/app/(main)/chart/_components/exportChartToXlsx"; | ||||
| import { reportExcelT as tx } from "./reportI18n"; | |||||
| import type { TFunction } from "i18next"; | |||||
| export interface GrnReportRow { | export interface GrnReportRow { | ||||
| poCode?: string; | poCode?: string; | ||||
| @@ -122,77 +124,102 @@ const formatQty = (n: number | undefined | null): string => { | |||||
| }).format(Number(n)); | }).format(Number(n)); | ||||
| }; | }; | ||||
| /** Excel column headers (bilingual) for GRN report */ | |||||
| function grnLabels(t?: TFunction) { | |||||
| return { | |||||
| sheetDetail: tx(t, "excel.grn.sheetDetail", "PO入倉記錄"), | |||||
| sheetListedPo: tx(t, "excel.grn.sheetListedPo", "已上架PO金額"), | |||||
| poNo: tx(t, "excel.grn.poNo", "訂單編號"), | |||||
| deliveryNoteNo: tx(t, "excel.grn.deliveryNoteNo", "送貨單編號"), | |||||
| receiptDate: tx(t, "excel.grn.receiptDate", "收貨日期"), | |||||
| itemCode: tx(t, "excel.grn.itemCode", "物料編號"), | |||||
| itemName: tx(t, "excel.grn.itemName", "物料名稱"), | |||||
| qty: tx(t, "excel.grn.qty", "數量"), | |||||
| demandQty: tx(t, "excel.grn.demandQty", "訂單數量"), | |||||
| uom: tx(t, "excel.grn.uom", "單位"), | |||||
| supplierLotNo: tx(t, "excel.grn.supplierLotNo", "供應商批次"), | |||||
| expiryDate: tx(t, "excel.grn.expiryDate", "到期日"), | |||||
| supplierCode: tx(t, "excel.grn.supplierCode", "供應商編號"), | |||||
| supplier: tx(t, "excel.grn.supplier", "供應商"), | |||||
| status: tx(t, "excel.grn.status", "入倉狀態"), | |||||
| unitPrice: tx(t, "excel.grn.unitPrice", "單價"), | |||||
| currency: tx(t, "excel.grn.currency", "貨幣"), | |||||
| amount: tx(t, "excel.grn.amount", "金額"), | |||||
| grnCode: tx(t, "excel.grn.grnCode", "M18 入倉單號"), | |||||
| grnId: tx(t, "excel.grn.grnId", "M18 記錄編號"), | |||||
| poCreator: tx(t, "excel.grn.poCreator", "PO建立者(M18)"), | |||||
| note: tx(t, "excel.grn.note", "備註"), | |||||
| category: tx(t, "excel.grn.category", "類別"), | |||||
| totalAmount: tx(t, "excel.grn.totalAmount", "金額"), | |||||
| grnCodes: tx(t, "excel.grn.grnCodes", "M18 入倉單號"), | |||||
| noCompletedPo: tx(t, "excel.grn.noCompletedPo", "(篩選範圍內無已完成之 PO 行)"), | |||||
| categoryCurrencyTotal: tx(t, "excel.grn.categoryCurrencyTotal", "貨幣小計"), | |||||
| categoryPo: tx(t, "excel.grn.categoryPo", "訂單"), | |||||
| }; | |||||
| } | |||||
| function toExcelRow( | function toExcelRow( | ||||
| r: GrnReportRow, | r: GrnReportRow, | ||||
| includeFinancialColumns: boolean | |||||
| includeFinancialColumns: boolean, | |||||
| t?: TFunction, | |||||
| ): Record<string, string | number | undefined> { | ): Record<string, string | number | undefined> { | ||||
| const L = grnLabels(t); | |||||
| const base: Record<string, string | number | undefined> = { | const base: Record<string, string | number | undefined> = { | ||||
| "PO No. / 訂單編號": r.poCode ?? "", | |||||
| "Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "", | |||||
| "Receipt Date / 收貨日期": r.receiptDate ?? "", | |||||
| "Item Code / 物料編號": r.itemCode ?? "", | |||||
| "Item Name / 物料名稱": r.itemName ?? "", | |||||
| "Qty / 數量": formatQty( | |||||
| r.acceptedQty ?? r.receivedQty ?? undefined | |||||
| ), | |||||
| "Demand Qty / 訂單數量": formatQty(r.demandQty), | |||||
| "UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||||
| "Supplier Lot No. 供應商批次": r.productLotNo ?? "", | |||||
| "Expiry Date / 到期日": r.expiryDate ?? "", | |||||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||||
| "Supplier / 供應商": r.supplier ?? "", | |||||
| "入倉狀態": r.status ?? "", | |||||
| [L.poNo]: r.poCode ?? "", | |||||
| [L.deliveryNoteNo]: r.deliveryNoteNo ?? "", | |||||
| [L.receiptDate]: r.receiptDate ?? "", | |||||
| [L.itemCode]: r.itemCode ?? "", | |||||
| [L.itemName]: r.itemName ?? "", | |||||
| [L.qty]: formatQty(r.acceptedQty ?? r.receivedQty ?? undefined), | |||||
| [L.demandQty]: formatQty(r.demandQty), | |||||
| [L.uom]: r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||||
| [L.supplierLotNo]: r.productLotNo ?? "", | |||||
| [L.expiryDate]: r.expiryDate ?? "", | |||||
| [L.supplierCode]: r.supplierCode ?? "", | |||||
| [L.supplier]: r.supplier ?? "", | |||||
| [L.status]: r.status ?? "", | |||||
| }; | }; | ||||
| if (includeFinancialColumns) { | if (includeFinancialColumns) { | ||||
| base["Unit Price / 單價"] = moneyCellValue(r.unitPrice); | |||||
| base["Currency / 貨幣"] = r.currencyCode ?? ""; | |||||
| base["Amount / 金額"] = moneyCellValue(r.lineAmount); | |||||
| base[L.unitPrice] = moneyCellValue(r.unitPrice); | |||||
| base[L.currency] = r.currencyCode ?? ""; | |||||
| base[L.amount] = moneyCellValue(r.lineAmount); | |||||
| } | } | ||||
| base["GRN Code / M18 入倉單號"] = r.grnCode ?? ""; | |||||
| base["GRN Id / M18 記錄編號"] = r.grnId ?? ""; | |||||
| base["PO建立者(M18) / PO creator (M18)"] = r.poM18CreatorDisplay ?? ""; | |||||
| base[L.grnCode] = r.grnCode ?? ""; | |||||
| base[L.grnId] = r.grnId ?? ""; | |||||
| base[L.poCreator] = r.poM18CreatorDisplay ?? ""; | |||||
| return base; | return base; | ||||
| } | } | ||||
| const GRN_SHEET_DETAIL = "PO入倉記錄"; | |||||
| const GRN_SHEET_LISTED_PO = "已上架PO金額"; | |||||
| /** Rows for sheet "已上架PO金額" (ADMIN-only; do not add this sheet for other users). */ | |||||
| function buildListedPoAmountSheetRows( | function buildListedPoAmountSheetRows( | ||||
| listed: ListedPoAmounts | undefined | |||||
| listed: ListedPoAmounts | undefined, | |||||
| t?: TFunction, | |||||
| ): Record<string, string | number | undefined>[] { | ): Record<string, string | number | undefined>[] { | ||||
| const L = grnLabels(t); | |||||
| if ( | if ( | ||||
| !listed || | !listed || | ||||
| (listed.currencyTotals.length === 0 && | (listed.currencyTotals.length === 0 && | ||||
| listed.byPurchaseOrder.length === 0) | listed.byPurchaseOrder.length === 0) | ||||
| ) { | ) { | ||||
| return [ | |||||
| { | |||||
| "Note / 備註": | |||||
| "(篩選範圍內無已完成之 PO 行) / No completed PO lines in the selected range", | |||||
| }, | |||||
| ]; | |||||
| return [{ [L.note]: L.noCompletedPo }]; | |||||
| } | } | ||||
| const out: Record<string, string | number | undefined>[] = []; | const out: Record<string, string | number | undefined>[] = []; | ||||
| for (const c of listed.currencyTotals) { | for (const c of listed.currencyTotals) { | ||||
| out.push({ | out.push({ | ||||
| "Category / 類別": "貨幣小計 / Currency total", | |||||
| "Receipt Date / 收貨日期": c.receiptDate ?? "", | |||||
| "PO No. / 訂單編號": "", | |||||
| "Currency / 貨幣": c.currencyCode ?? "", | |||||
| "Total Amount / 金額": moneyCellValue(c.totalAmount), | |||||
| "GRN Code(s) / M18 入倉單號": "", | |||||
| [L.category]: L.categoryCurrencyTotal, | |||||
| [L.receiptDate]: c.receiptDate ?? "", | |||||
| [L.poNo]: "", | |||||
| [L.currency]: c.currencyCode ?? "", | |||||
| [L.totalAmount]: moneyCellValue(c.totalAmount), | |||||
| [L.grnCodes]: "", | |||||
| }); | }); | ||||
| } | } | ||||
| for (const p of listed.byPurchaseOrder) { | for (const p of listed.byPurchaseOrder) { | ||||
| out.push({ | out.push({ | ||||
| "Category / 類別": "訂單 / PO", | |||||
| "Receipt Date / 收貨日期": p.receiptDate ?? "", | |||||
| "PO No. / 訂單編號": p.poCode ?? "", | |||||
| "Currency / 貨幣": p.currencyCode ?? "", | |||||
| "Total Amount / 金額": moneyCellValue(p.totalAmount), | |||||
| "GRN Code(s) / M18 入倉單號": p.grnCodes ?? "", | |||||
| [L.category]: L.categoryPo, | |||||
| [L.receiptDate]: p.receiptDate ?? "", | |||||
| [L.poNo]: p.poCode ?? "", | |||||
| [L.currency]: p.currencyCode ?? "", | |||||
| [L.totalAmount]: moneyCellValue(p.totalAmount), | |||||
| [L.grnCodes]: p.grnCodes ?? "", | |||||
| }); | }); | ||||
| } | } | ||||
| return out; | return out; | ||||
| @@ -206,10 +233,11 @@ export async function generateGrnReportExcel( | |||||
| criteria: Record<string, string>, | criteria: Record<string, string>, | ||||
| reportTitle: string = "PO 入倉記錄", | reportTitle: string = "PO 入倉記錄", | ||||
| /** Only users with ADMIN authority should pass true (must match backend). */ | /** Only users with ADMIN authority should pass true (must match backend). */ | ||||
| includeFinancialColumns: boolean = false | |||||
| includeFinancialColumns: boolean = false, | |||||
| t?: TFunction, | |||||
| ): Promise<void> { | ): Promise<void> { | ||||
| const { rows, listedPoAmounts } = await fetchGrnReportData(criteria); | const { rows, listedPoAmounts } = await fetchGrnReportData(criteria); | ||||
| const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns)); | |||||
| const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns, t)); | |||||
| const start = criteria.receiptDateStart; | const start = criteria.receiptDateStart; | ||||
| const end = criteria.receiptDateEnd; | const end = criteria.receiptDateEnd; | ||||
| let datePart: string; | let datePart: string; | ||||
| @@ -222,17 +250,18 @@ export async function generateGrnReportExcel( | |||||
| } | } | ||||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | ||||
| const filename = `${reportTitle}_${safeDatePart}`; | const filename = `${reportTitle}_${safeDatePart}`; | ||||
| const L = grnLabels(t); | |||||
| if (includeFinancialColumns) { | if (includeFinancialColumns) { | ||||
| const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts); | |||||
| const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts, t); | |||||
| exportMultiSheetToXlsx( | exportMultiSheetToXlsx( | ||||
| [ | [ | ||||
| { name: GRN_SHEET_DETAIL, rows: excelRows as Record<string, unknown>[] }, | |||||
| { name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record<string, unknown>[] }, | |||||
| { name: L.sheetDetail, rows: excelRows as Record<string, unknown>[] }, | |||||
| { name: L.sheetListedPo, rows: sheet2 as Record<string, unknown>[] }, | |||||
| ], | ], | ||||
| filename | filename | ||||
| ); | ); | ||||
| } else { | } else { | ||||
| exportChartToXlsx(excelRows as Record<string, unknown>[], filename, GRN_SHEET_DETAIL); | |||||
| exportChartToXlsx(excelRows as Record<string, unknown>[], filename, L.sheetDetail); | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useState, useMemo, useEffect } from 'react'; | |||||
| import React, { useState, useMemo, useEffect, useRef } from 'react'; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { AUTH } from "@/authorities"; | import { AUTH } from "@/authorities"; | ||||
| @@ -18,13 +18,24 @@ import { | |||||
| Autocomplete, | Autocomplete, | ||||
| Checkbox, | Checkbox, | ||||
| FormControlLabel, | FormControlLabel, | ||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogActions, | |||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import DownloadIcon from '@mui/icons-material/Download'; | import DownloadIcon from '@mui/icons-material/Download'; | ||||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | |||||
| import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | |||||
| import { REPORTS } from '@/config/reportConfig'; | |||||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | import { NEXT_PUBLIC_API_URL } from '@/config/api'; | ||||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | ||||
| import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; | import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; | ||||
| import ReportSelectionDashboard from './ReportSelectionDashboard'; | import ReportSelectionDashboard from './ReportSelectionDashboard'; | ||||
| import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; | |||||
| import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; | |||||
| import dayjs from 'dayjs'; | |||||
| import 'dayjs/locale/zh-hk'; | |||||
| import { OUTPUT_DATE_FORMAT } from '@/app/utils/formatUtil'; | |||||
| import { useReportLabels } from './reportI18n'; | |||||
| import { | import { | ||||
| fetchSemiFGItemCodes, | fetchSemiFGItemCodes, | ||||
| fetchSemiFGItemCodesWithCategory | fetchSemiFGItemCodesWithCategory | ||||
| @@ -44,16 +55,23 @@ interface ItemCodeWithName { | |||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| export default function ReportPage() { | export default function ReportPage() { | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); | |||||
| const isZh = (i18n.language || 'zh').startsWith('zh'); | |||||
| const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY'; | |||||
| const includeGrnFinancialColumns = | const includeGrnFinancialColumns = | ||||
| session?.abilities?.includes(AUTH.ADMIN) ?? false; | session?.abilities?.includes(AUTH.ADMIN) ?? false; | ||||
| const [selectedReportId, setSelectedReportId] = useState<string>(''); | const [selectedReportId, setSelectedReportId] = useState<string>(''); | ||||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | const [criteria, setCriteria] = useState<Record<string, string>>({}); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const excelInFlightRef = useRef(false); | |||||
| const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | ||||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | const [showConfirmDialog, setShowConfirmDialog] = useState(false); | ||||
| const [showNoDataDialog, setShowNoDataDialog] = useState(false); | |||||
| // Find the configuration for the currently selected report | // Find the configuration for the currently selected report | ||||
| const rep012RoundIds = useMemo(() => { | const rep012RoundIds = useMemo(() => { | ||||
| @@ -73,12 +91,27 @@ export default function ReportPage() { | |||||
| const handleSelectReport = (reportId: string) => { | const handleSelectReport = (reportId: string) => { | ||||
| if (reportId === selectedReportId) return; | if (reportId === selectedReportId) return; | ||||
| setSelectedReportId(reportId); | setSelectedReportId(reportId); | ||||
| setCriteria({}); | |||||
| if (reportId === 'rep-010') { | |||||
| setCriteria({ qcType: 'all', qcItemScope: 'all' }); | |||||
| } else if (reportId === 'rep-004') { | |||||
| setCriteria({ storeId: 'All', poPrefix: 'All' }); | |||||
| } else if (reportId === 'rep-021') { | |||||
| setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); | |||||
| } else { | |||||
| setCriteria({}); | |||||
| } | |||||
| }; | }; | ||||
| const handleFieldChange = (name: string, value: string | string[]) => { | const handleFieldChange = (name: string, value: string | string[]) => { | ||||
| const stringValue = Array.isArray(value) ? value.join(',') : value; | const stringValue = Array.isArray(value) ? value.join(',') : value; | ||||
| setCriteria((prev) => ({ ...prev, [name]: stringValue })); | |||||
| setCriteria((prev) => { | |||||
| const next = { ...prev, [name]: stringValue }; | |||||
| if (currentReport?.id === 'rep-021' && name === 'warehouse') { | |||||
| const m = stringValue.trim().match(/^w(\d)/i); | |||||
| if (m) next.storeId = `${m[1]}F`; | |||||
| } | |||||
| return next; | |||||
| }); | |||||
| // If this is stockCategory and there's a field that depends on it, fetch dynamic options | // If this is stockCategory and there's a field that depends on it, fetch dynamic options | ||||
| if (name === 'stockCategory' && currentReport) { | if (name === 'stockCategory' && currentReport) { | ||||
| @@ -135,8 +168,27 @@ export default function ReportPage() { | |||||
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | ||||
| const data = await response.json(); | const data = await response.json(); | ||||
| const options = Array.isArray(data) | |||||
| ? data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item) })) | |||||
| const options = Array.isArray(data) | |||||
| ? field.name === 'stockTakeSectionDescription' | |||||
| ? (() => { | |||||
| const seen = new Set<string>(); | |||||
| const mapped: { label: string; value: string }[] = [{ label: '全部', value: 'All' }]; | |||||
| data.forEach((item: { stockTakeSectionDescription?: string; stockTakeSection?: string }) => { | |||||
| const desc = (item.stockTakeSectionDescription || '').trim(); | |||||
| if (!desc || seen.has(desc)) return; | |||||
| seen.add(desc); | |||||
| const section = (item.stockTakeSection || '').trim(); | |||||
| mapped.push({ | |||||
| label: section ? `${desc} (${section})` : desc, | |||||
| value: desc, | |||||
| }); | |||||
| }); | |||||
| return mapped; | |||||
| })() | |||||
| : data.map((item: any) => ({ | |||||
| label: item.label || item.name || item.code || String(item), | |||||
| value: item.value || item.code || String(item), | |||||
| })) | |||||
| : []; | : []; | ||||
| setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); | setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); | ||||
| @@ -194,7 +246,9 @@ export default function ReportPage() { | |||||
| if (currentReport.id === 'rep-012') { | if (currentReport.id === 'rep-012') { | ||||
| if (rep012RoundIds.length === 0) { | if (rep012RoundIds.length === 0) { | ||||
| alert('缺少必填條件:\n- 盤點輪次'); | |||||
| alert(t('missingRequired', { | |||||
| fields: fieldLabel('rep-012', { name: 'stockTakeRoundId', label: '盤點輪次' }), | |||||
| })); | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -206,10 +260,26 @@ export default function ReportPage() { | |||||
| if (!field.required) return false; | if (!field.required) return false; | ||||
| return !criteria[field.name]; | return !criteria[field.name]; | ||||
| }) | }) | ||||
| .map(field => field.label); | |||||
| .map((field) => fieldLabel(currentReport.id, field)); | |||||
| if (missingFields.length > 0) { | if (missingFields.length > 0) { | ||||
| alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`); | |||||
| alert(t('missingRequired', { fields: missingFields.join('\n- ') })); | |||||
| return false; | |||||
| } | |||||
| // Date fields with minDate: 'today' must not be before local today | |||||
| const today = new Date(); | |||||
| const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; | |||||
| const beforeToday = currentReport.fields | |||||
| .filter((field) => field.type === 'date' && field.minDate === 'today') | |||||
| .filter((field) => { | |||||
| const v = (criteria[field.name] || '').trim(); | |||||
| return v && v < todayStr; | |||||
| }) | |||||
| .map((field) => fieldLabel(currentReport.id, field)); | |||||
| if (beforeToday.length > 0) { | |||||
| alert(t('dateNotBeforeToday', { fields: beforeToday.join('\n- ') })); | |||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -235,6 +305,28 @@ export default function ReportPage() { | |||||
| return p.toString(); | return p.toString(); | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| /** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */ | |||||
| const buildRep010QueryString = (): string => { | |||||
| const p = new URLSearchParams(); | |||||
| Object.entries(criteria).forEach(([key, value]) => { | |||||
| if (key === 'qcItemScope') return; | |||||
| if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return; | |||||
| if (value != null && String(value).trim() !== '') { | |||||
| p.set(key, String(value)); | |||||
| } | |||||
| }); | |||||
| const scope = (criteria.qcItemScope || 'all').trim().toLowerCase(); | |||||
| if (scope === 'measurable') { | |||||
| p.set('includeMeasurable', 'true'); | |||||
| p.set('includeOther', 'false'); | |||||
| } else { | |||||
| p.set('includeMeasurable', 'true'); | |||||
| p.set('includeOther', 'true'); | |||||
| } | |||||
| return p.toString(); | |||||
| }; | |||||
| const handlePrint = async () => { | const handlePrint = async () => { | ||||
| if (!currentReport) return; | if (!currentReport) return; | ||||
| if (!validateRequiredFields()) return; | if (!validateRequiredFields()) return; | ||||
| @@ -259,23 +351,28 @@ export default function ReportPage() { | |||||
| const executeExcelReport = async () => { | const executeExcelReport = async () => { | ||||
| if (!currentReport) return; | if (!currentReport) return; | ||||
| if (excelInFlightRef.current) return; | |||||
| excelInFlightRef.current = true; | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| if (currentReport.id === 'rep-014') { | if (currentReport.id === 'rep-014') { | ||||
| await generateGrnReportExcel( | await generateGrnReportExcel( | ||||
| criteria, | criteria, | ||||
| currentReport.title, | |||||
| includeGrnFinancialColumns | |||||
| reportTitle(currentReport), | |||||
| includeGrnFinancialColumns, | |||||
| t, | |||||
| ); | ); | ||||
| } else if (currentReport.id === 'rep-015') { | } else if (currentReport.id === 'rep-015') { | ||||
| await generateBomShopSyncReportExcel(criteria, currentReport.title); | |||||
| await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t); | |||||
| } else if (currentReport.id === 'rep-017') { | } else if (currentReport.id === 'rep-017') { | ||||
| await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title); | |||||
| await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); | |||||
| } else { | } else { | ||||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | // Backend returns actual .xlsx bytes for this Excel endpoint. | ||||
| let queryParams = | let queryParams = | ||||
| currentReport.id === 'rep-012' | currentReport.id === 'rep-012' | ||||
| ? buildRep012QueryString() | ? buildRep012QueryString() | ||||
| : currentReport.id === 'rep-010' | |||||
| ? buildRep010QueryString() | |||||
| : new URLSearchParams(criteria).toString(); | : new URLSearchParams(criteria).toString(); | ||||
| // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. | // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. | ||||
| if (currentReport.id === 'rep-016') { | if (currentReport.id === 'rep-016') { | ||||
| @@ -295,6 +392,10 @@ export default function ReportPage() { | |||||
| }); | }); | ||||
| if (response.status === 401 || response.status === 403) return; | if (response.status === 401 || response.status === 403) return; | ||||
| if (response.status === 204) { | |||||
| setShowNoDataDialog(true); | |||||
| return; | |||||
| } | |||||
| if (!response.ok) { | if (!response.ok) { | ||||
| const errorText = await response.text(); | const errorText = await response.text(); | ||||
| console.error("Response error:", errorText); | console.error("Response error:", errorText); | ||||
| @@ -307,7 +408,7 @@ export default function ReportPage() { | |||||
| link.href = downloadUrl; | link.href = downloadUrl; | ||||
| const contentDisposition = response.headers.get('Content-Disposition'); | const contentDisposition = response.headers.get('Content-Disposition'); | ||||
| let fileName = `${currentReport.title}.xlsx`; | |||||
| let fileName = `${reportTitle(currentReport)}.xlsx`; | |||||
| if (contentDisposition?.includes('filename=')) { | if (contentDisposition?.includes('filename=')) { | ||||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | ||||
| } | } | ||||
| @@ -328,9 +429,10 @@ export default function ReportPage() { | |||||
| setShowConfirmDialog(false); | setShowConfirmDialog(false); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to generate Excel report:", error); | console.error("Failed to generate Excel report:", error); | ||||
| alert("An error occurred while generating the report. Please try again."); | |||||
| alert(t('generateError')); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| excelInFlightRef.current = false; | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -339,9 +441,11 @@ export default function ReportPage() { | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const queryParams = | |||||
| let queryParams = | |||||
| currentReport.id === 'rep-012' | currentReport.id === 'rep-012' | ||||
| ? buildRep012QueryString() | ? buildRep012QueryString() | ||||
| : currentReport.id === 'rep-010' | |||||
| ? buildRep010QueryString() | |||||
| : new URLSearchParams(criteria).toString(); | : new URLSearchParams(criteria).toString(); | ||||
| const url = `${currentReport.apiEndpoint}?${queryParams}`; | const url = `${currentReport.apiEndpoint}?${queryParams}`; | ||||
| @@ -363,7 +467,7 @@ export default function ReportPage() { | |||||
| link.href = downloadUrl; | link.href = downloadUrl; | ||||
| const contentDisposition = response.headers.get('Content-Disposition'); | const contentDisposition = response.headers.get('Content-Disposition'); | ||||
| let fileName = `${currentReport.title}.pdf`; | |||||
| let fileName = `${reportTitle(currentReport)}.pdf`; | |||||
| if (contentDisposition?.includes('filename=')) { | if (contentDisposition?.includes('filename=')) { | ||||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | ||||
| } | } | ||||
| @@ -383,16 +487,17 @@ export default function ReportPage() { | |||||
| setShowConfirmDialog(false); | setShowConfirmDialog(false); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to generate report:", error); | console.error("Failed to generate report:", error); | ||||
| alert("An error occurred while generating the report. Please try again."); | |||||
| alert(t('generateError')); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }; | }; | ||||
| return ( | return ( | ||||
| <> | |||||
| <Box sx={{ p: 4, maxWidth: 1280, margin: '0 auto' }}> | <Box sx={{ p: 4, maxWidth: 1280, margin: '0 auto' }}> | ||||
| <Typography variant="h4" gutterBottom fontWeight="bold"> | <Typography variant="h4" gutterBottom fontWeight="bold"> | ||||
| 報告管理 | |||||
| {t('title')} | |||||
| </Typography> | </Typography> | ||||
| <ReportSelectionDashboard | <ReportSelectionDashboard | ||||
| @@ -404,15 +509,33 @@ export default function ReportPage() { | |||||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | ||||
| <CardContent> | <CardContent> | ||||
| <Typography variant="h6" color="primary" gutterBottom> | <Typography variant="h6" color="primary" gutterBottom> | ||||
| 搜索條件: {currentReport.title} | |||||
| {t('searchCriteriaWithTitle', { title: reportTitle(currentReport) })} | |||||
| </Typography> | </Typography> | ||||
| <Divider sx={{ mb: 3 }} /> | <Divider sx={{ mb: 3 }} /> | ||||
| <LocalizationProvider | |||||
| dateAdapter={AdapterDayjs} | |||||
| adapterLocale={isZh ? 'zh-hk' : 'en'} | |||||
| localeText={ | |||||
| isZh | |||||
| ? { | |||||
| fieldDayPlaceholder: () => '日', | |||||
| fieldMonthPlaceholder: () => '月', | |||||
| fieldYearPlaceholder: () => '年', | |||||
| } | |||||
| : undefined | |||||
| } | |||||
| > | |||||
| <Grid container spacing={3}> | <Grid container spacing={3}> | ||||
| {currentReport.fields.map((field) => { | {currentReport.fields.map((field) => { | ||||
| const options = field.dynamicOptions | |||||
| ? (dynamicOptions[field.name] || []) | |||||
| const translatedLabel = fieldLabel(currentReport.id, field); | |||||
| const rawOptions = field.dynamicOptions | |||||
| ? (dynamicOptions[field.name] || field.options || []) | |||||
| : (field.options || []); | : (field.options || []); | ||||
| const options = rawOptions.map((opt) => ({ | |||||
| ...opt, | |||||
| label: optionLabel(currentReport.id, field.name, opt), | |||||
| })); | |||||
| const currentValue = criteria[field.name] || ''; | const currentValue = criteria[field.name] || ''; | ||||
| const valueForSelect = field.multiple | const valueForSelect = field.multiple | ||||
| ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) | ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) | ||||
| @@ -430,6 +553,41 @@ export default function ReportPage() { | |||||
| field.name === 'status' && | field.name === 'status' && | ||||
| rep012MultiRound; | rep012MultiRound; | ||||
| if (field.type === 'date') { | |||||
| const parsed = currentValue ? dayjs(currentValue) : null; | |||||
| return ( | |||||
| <Grid item {...gridSize} key={field.name}> | |||||
| <DatePicker | |||||
| label={translatedLabel} | |||||
| format={dateDisplayFormat} | |||||
| value={parsed?.isValid() ? parsed : null} | |||||
| minDate={field.minDate === 'today' ? dayjs().startOf('day') : undefined} | |||||
| disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | |||||
| onChange={(date) => { | |||||
| handleFieldChange( | |||||
| field.name, | |||||
| date?.isValid() ? date.format(OUTPUT_DATE_FORMAT) : '', | |||||
| ); | |||||
| }} | |||||
| slotProps={{ | |||||
| textField: { | |||||
| fullWidth: true, | |||||
| sx: currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}, | |||||
| }, | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| ); | |||||
| } | |||||
| if (field.type === 'checkbox') { | if (field.type === 'checkbox') { | ||||
| return ( | return ( | ||||
| <Grid item {...gridSize} key={field.name}> | <Grid item {...gridSize} key={field.name}> | ||||
| @@ -442,7 +600,7 @@ export default function ReportPage() { | |||||
| } | } | ||||
| /> | /> | ||||
| } | } | ||||
| label={field.label} | |||||
| label={translatedLabel} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| ); | ); | ||||
| @@ -498,8 +656,8 @@ export default function ReportPage() { | |||||
| <TextField | <TextField | ||||
| {...params} | {...params} | ||||
| fullWidth | fullWidth | ||||
| label={field.label} | |||||
| placeholder={field.placeholder || "選擇或輸入物料編號"} | |||||
| label={translatedLabel} | |||||
| placeholder={field.placeholder || t('selectOrEnterItemCode')} | |||||
| sx={currentReport.id === 'rep-005' ? { | sx={currentReport.id === 'rep-005' ? { | ||||
| '& .MuiOutlinedInput-root': { | '& .MuiOutlinedInput-root': { | ||||
| minHeight: '64px', | minHeight: '64px', | ||||
| @@ -541,11 +699,10 @@ export default function ReportPage() { | |||||
| <Grid item {...gridSize} key={field.name}> | <Grid item {...gridSize} key={field.name}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label={field.label} | |||||
| label={translatedLabel} | |||||
| type={field.type} | type={field.type} | ||||
| placeholder={field.placeholder} | placeholder={field.placeholder} | ||||
| disabled={disabledByCheckedCheckbox || disabledRep012Status} | |||||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | |||||
| disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | |||||
| sx={currentReport.id === 'rep-005' ? { | sx={currentReport.id === 'rep-005' ? { | ||||
| '& .MuiOutlinedInput-root': { | '& .MuiOutlinedInput-root': { | ||||
| minHeight: '64px', | minHeight: '64px', | ||||
| @@ -624,15 +781,16 @@ export default function ReportPage() { | |||||
| ); | ); | ||||
| })} | })} | ||||
| </Grid> | </Grid> | ||||
| </LocalizationProvider> | |||||
| <Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}> | <Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}> | ||||
| {currentReport.id === 'rep-005' ? ( | {currentReport.id === 'rep-005' ? ( | ||||
| <SemiFGProductionAnalysisReport | <SemiFGProductionAnalysisReport | ||||
| criteria={criteria} | criteria={criteria} | ||||
| requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)} | |||||
| requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))} | |||||
| loading={loading} | loading={loading} | ||||
| setLoading={setLoading} | setLoading={setLoading} | ||||
| reportTitle={currentReport.title} | |||||
| reportTitle={reportTitle(currentReport)} | |||||
| onExportSuccess={(format) => { | onExportSuccess={(format) => { | ||||
| logFeatureUsage( | logFeatureUsage( | ||||
| FEATURE_USAGE.REPORT_MANAGEMENT, | FEATURE_USAGE.REPORT_MANAGEMENT, | ||||
| @@ -651,7 +809,7 @@ export default function ReportPage() { | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成 PDF..." : "下載報告 (PDF)"} | |||||
| {loading ? t('generatingPdf') : t('downloadPdf')} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -661,7 +819,7 @@ export default function ReportPage() { | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||||
| </Button> | </Button> | ||||
| </> | </> | ||||
| ) : currentReport.id === 'rep-006' || currentReport.id === 'rep-010' ? ( | ) : currentReport.id === 'rep-006' || currentReport.id === 'rep-010' ? ( | ||||
| @@ -674,7 +832,7 @@ export default function ReportPage() { | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成 PDF..." : "下載報告 (PDF)"} | |||||
| {loading ? t('generatingPdf') : t('downloadPdf')} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -684,7 +842,7 @@ export default function ReportPage() { | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||||
| </Button> | </Button> | ||||
| </> | </> | ||||
| ) : currentReport.responseType === 'excel' ? ( | ) : currentReport.responseType === 'excel' ? ( | ||||
| @@ -696,7 +854,7 @@ export default function ReportPage() { | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||||
| </Button> | </Button> | ||||
| ) : ( | ) : ( | ||||
| <Button | <Button | ||||
| @@ -707,7 +865,7 @@ export default function ReportPage() { | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成報告..." : "下載報告 (PDF)"} | |||||
| {loading ? t('generatingReport') : t('downloadPdf')} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -715,5 +873,49 @@ export default function ReportPage() { | |||||
| </Card> | </Card> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| <Dialog | |||||
| open={showNoDataDialog} | |||||
| onClose={() => setShowNoDataDialog(false)} | |||||
| maxWidth="sm" | |||||
| fullWidth | |||||
| PaperProps={{ | |||||
| sx: { | |||||
| borderRadius: 3, | |||||
| px: 1, | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pt: 3, px: 4, pb: 1.5 }}> | |||||
| <Box | |||||
| sx={{ | |||||
| width: 36, | |||||
| height: 36, | |||||
| borderRadius: '50%', | |||||
| bgcolor: 'warning.light', | |||||
| color: 'warning.dark', | |||||
| display: 'flex', | |||||
| alignItems: 'center', | |||||
| justifyContent: 'center', | |||||
| flexShrink: 0, | |||||
| }} | |||||
| > | |||||
| <InfoOutlinedIcon fontSize="small" /> | |||||
| </Box> | |||||
| <Typography component="span" variant="h6" fontWeight="bold"> | |||||
| {t('noDataFoundTitle')} | |||||
| </Typography> | |||||
| </DialogTitle> | |||||
| <DialogContent sx={{ px: 4, pt: 0.5 }}> | |||||
| <Typography color="text.secondary" sx={{ pl: 6.5 }}> | |||||
| {t('noDataFoundHint')} | |||||
| </Typography> | |||||
| </DialogContent> | |||||
| <DialogActions sx={{ justifyContent: 'center', px: 4, pb: 3, pt: 2 }}> | |||||
| <Button variant="contained" onClick={() => setShowNoDataDialog(false)} autoFocus sx={{ minWidth: 96 }}> | |||||
| {t('ok')} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </> | |||||
| ); | ); | ||||
| } | } | ||||
| @@ -9,7 +9,7 @@ export interface ReportCategoryConfig { | |||||
| reportIds: string[]; | reportIds: string[]; | ||||
| } | } | ||||
| /** Display order and grouping for the report management dashboard. */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | ||||
| { | { | ||||
| id: "inventory", | id: "inventory", | ||||
| @@ -17,7 +17,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | |||||
| headerBg: "#b8e0b8", | headerBg: "#b8e0b8", | ||||
| bodyBg: "#eef8ee", | bodyBg: "#eef8ee", | ||||
| accent: "#2e7d32", | accent: "#2e7d32", | ||||
| reportIds: ["rep-011", "rep-007", "rep-012", "rep-010"], | |||||
| reportIds: ["rep-011", "rep-007", "rep-012", "rep-021", "rep-010"], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "inbound-outbound", | id: "inbound-outbound", | ||||
| @@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | |||||
| headerBg: "#b3d4f0", | headerBg: "#b3d4f0", | ||||
| bodyBg: "#eef5fc", | bodyBg: "#eef5fc", | ||||
| accent: "#1565c0", | accent: "#1565c0", | ||||
| reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017"], | |||||
| reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017", "rep-018"], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "production", | id: "production", | ||||
| @@ -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 { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | ||||
| import { reportExcelT as tx } from "./reportI18n"; | |||||
| import type { TFunction } from "i18next"; | |||||
| export interface ShopOrderReplenishmentReportRow { | export interface ShopOrderReplenishmentReportRow { | ||||
| shopNo?: string; | shopNo?: string; | ||||
| @@ -13,10 +15,12 @@ export interface ShopOrderReplenishmentReportRow { | |||||
| itemName?: string; | itemName?: string; | ||||
| firstOrderQty?: number | string; | firstOrderQty?: number | string; | ||||
| firstOrderActualPickQty?: number | string; | firstOrderActualPickQty?: number | string; | ||||
| firstOrderPickerHandler?: string; | |||||
| reorderQty?: number | string; | reorderQty?: number | string; | ||||
| reorderDate?: string; | reorderDate?: string; | ||||
| reason?: string; | reason?: string; | ||||
| actualDeliveredQty?: number | string; | actualDeliveredQty?: number | string; | ||||
| actualDeliveredHandler?: string; | |||||
| deliveredDate?: string; | deliveredDate?: string; | ||||
| [key: string]: unknown; | [key: string]: unknown; | ||||
| } | } | ||||
| @@ -25,37 +29,75 @@ export interface ShopOrderReplenishmentReportResponse { | |||||
| rows: ShopOrderReplenishmentReportRow[]; | rows: ShopOrderReplenishmentReportRow[]; | ||||
| } | } | ||||
| const SHEET_NAME = "店鋪訂單補貨記錄"; | |||||
| function shopReplenishmentLabels(t?: TFunction) { | |||||
| return { | |||||
| sheetName: tx(t, "excel.shopReplenishment.sheetName", "店鋪訂單補貨記錄"), | |||||
| noData: tx(t, "excel.noData", "(篩選範圍內無資料)"), | |||||
| shopCode: tx(t, "excel.shopReplenishment.shopCode", "店鋪編號"), | |||||
| shopName: tx(t, "excel.shopReplenishment.shopName", "店鋪名稱"), | |||||
| shopOrderDate: tx(t, "excel.shopReplenishment.shopOrderDate", "店鋪訂單日期"), | |||||
| shopOrderNo: tx(t, "excel.shopReplenishment.shopOrderNo", "店鋪訂單編號"), | |||||
| itemCode: tx(t, "excel.shopReplenishment.itemCode", "貨品編號"), | |||||
| itemName: tx(t, "excel.shopReplenishment.itemName", "貨品名稱"), | |||||
| firstOrderQty: tx(t, "excel.shopReplenishment.firstOrderQty", "原訂單數量"), | |||||
| firstOrderActualPickQty: tx( | |||||
| t, | |||||
| "excel.shopReplenishment.firstOrderActualPickQty", | |||||
| "原單實際提料數量", | |||||
| ), | |||||
| firstOrderPicker: tx(t, "excel.shopReplenishment.firstOrderPicker", "原單提料人"), | |||||
| reorderQty: tx(t, "excel.shopReplenishment.reorderQty", "補貨數量"), | |||||
| reorderDate: tx(t, "excel.shopReplenishment.reorderDate", "補貨日期"), | |||||
| reason: tx(t, "excel.shopReplenishment.reason", "補貨原因"), | |||||
| actualDeliveredQty: tx(t, "excel.shopReplenishment.actualDeliveredQty", "實際補貨數量"), | |||||
| actualDeliveredHandler: tx( | |||||
| t, | |||||
| "excel.shopReplenishment.actualDeliveredHandler", | |||||
| "實際補貨提料人", | |||||
| ), | |||||
| deliveredDate: tx(t, "excel.shopReplenishment.deliveredDate", "送貨日期"), | |||||
| reasonQuality: tx(t, "excel.shopReplenishment.reasonQuality", "質素問題"), | |||||
| reasonOutOfStock: tx(t, "excel.shopReplenishment.reasonOutOfStock", "缺貨"), | |||||
| reasonOther: tx(t, "excel.shopReplenishment.reasonOther", "其他"), | |||||
| }; | |||||
| } | |||||
| const NO_DATA_NOTE = | |||||
| "(篩選範圍內無資料 / No records in the selected range)"; | |||||
| type ShopReplenishmentLabels = ReturnType<typeof shopReplenishmentLabels>; | |||||
| function emptySheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> { | |||||
| function emptySheetRow( | |||||
| L: ShopReplenishmentLabels, | |||||
| note?: string, | |||||
| ): Record<string, unknown> { | |||||
| return { | return { | ||||
| "Shop No. / 店鋪編號": note, | |||||
| "Shop Name / 店鋪名稱": "", | |||||
| "Shop Order Date / 店鋪訂單日期": "", | |||||
| "Shop Order No. / 店鋪訂單編號": "", | |||||
| "Item No. / 貨品編號": "", | |||||
| "Item Name / 貨品名稱": "", | |||||
| "First Order Qty / 原訂單數量": "", | |||||
| "First Order Actual Pick Qty / 原單實際提料數量": "", | |||||
| "Reorder Qty / 補貨數量": "", | |||||
| "Reorder Date / 補貨日期": "", | |||||
| "Reason / 補貨原因": "", | |||||
| "Actual Delivered Qty / 實際補貨數量": "", | |||||
| "Delivered Date / 送貨日期": "", | |||||
| [L.shopCode]: note ?? L.noData, | |||||
| [L.shopName]: "", | |||||
| [L.shopOrderDate]: "", | |||||
| [L.shopOrderNo]: "", | |||||
| [L.itemCode]: "", | |||||
| [L.itemName]: "", | |||||
| [L.firstOrderQty]: "", | |||||
| [L.firstOrderActualPickQty]: "", | |||||
| [L.firstOrderPicker]: "", | |||||
| [L.reorderQty]: "", | |||||
| [L.reorderDate]: "", | |||||
| [L.reason]: "", | |||||
| [L.actualDeliveredQty]: "", | |||||
| [L.actualDeliveredHandler]: "", | |||||
| [L.deliveredDate]: "", | |||||
| }; | }; | ||||
| } | } | ||||
| function formatReason(reason: string | undefined): string { | |||||
| function formatReason( | |||||
| reason: string | undefined, | |||||
| L: ShopReplenishmentLabels, | |||||
| ): string { | |||||
| switch ((reason ?? "").trim()) { | switch ((reason ?? "").trim()) { | ||||
| case "quality_issue": | case "quality_issue": | ||||
| return "質素問題"; | |||||
| return L.reasonQuality; | |||||
| case "out_of_stock": | case "out_of_stock": | ||||
| return "缺貨"; | |||||
| return L.reasonOutOfStock; | |||||
| case "other": | case "other": | ||||
| return "其他"; | |||||
| return L.reasonOther; | |||||
| default: | default: | ||||
| return reason ?? ""; | return reason ?? ""; | ||||
| } | } | ||||
| @@ -88,23 +130,27 @@ function formatQty(value: unknown): string | number { | |||||
| return n; | return n; | ||||
| } | } | ||||
| function toExcelRow(r: ShopOrderReplenishmentReportRow): Record<string, unknown> { | |||||
| const base = emptySheetRow(""); | |||||
| function toExcelRow( | |||||
| r: ShopOrderReplenishmentReportRow, | |||||
| L: ShopReplenishmentLabels, | |||||
| ): Record<string, unknown> { | |||||
| return { | return { | ||||
| ...base, | |||||
| "Shop No. / 店鋪編號": r.shopNo ?? "", | |||||
| "Shop Name / 店鋪名稱": r.shopName ?? "", | |||||
| "Shop Order Date / 店鋪訂單日期": formatDateCell(r.shopOrderDate), | |||||
| "Shop Order No. / 店鋪訂單編號": r.shopOrderNo ?? "", | |||||
| "Item No. / 貨品編號": r.itemNo ?? "", | |||||
| "Item Name / 貨品名稱": r.itemName ?? "", | |||||
| "First Order Qty / 原訂單數量": formatQty(r.firstOrderQty), | |||||
| "First Order Actual Pick Qty / 原單實際提料數量": formatQty(r.firstOrderActualPickQty), | |||||
| "Reorder Qty / 補貨數量": formatQty(r.reorderQty), | |||||
| "Reorder Date / 補貨日期": formatDateCell(r.reorderDate), | |||||
| "Reason / 補貨原因": formatReason(r.reason), | |||||
| "Actual Delivered Qty / 實際補貨數量": formatQty(r.actualDeliveredQty), | |||||
| "Delivered Date / 送貨日期": formatDateCell(r.deliveredDate), | |||||
| ...emptySheetRow(L, ""), | |||||
| [L.shopCode]: r.shopNo ?? "", | |||||
| [L.shopName]: r.shopName ?? "", | |||||
| [L.shopOrderDate]: formatDateCell(r.shopOrderDate), | |||||
| [L.shopOrderNo]: r.shopOrderNo ?? "", | |||||
| [L.itemCode]: r.itemNo ?? "", | |||||
| [L.itemName]: r.itemName ?? "", | |||||
| [L.firstOrderQty]: formatQty(r.firstOrderQty), | |||||
| [L.firstOrderActualPickQty]: formatQty(r.firstOrderActualPickQty), | |||||
| [L.firstOrderPicker]: r.firstOrderPickerHandler ?? "", | |||||
| [L.reorderQty]: formatQty(r.reorderQty), | |||||
| [L.reorderDate]: formatDateCell(r.reorderDate), | |||||
| [L.reason]: formatReason(r.reason, L), | |||||
| [L.actualDeliveredQty]: formatQty(r.actualDeliveredQty), | |||||
| [L.actualDeliveredHandler]: r.actualDeliveredHandler ?? "", | |||||
| [L.deliveredDate]: formatDateCell(r.deliveredDate), | |||||
| }; | }; | ||||
| } | } | ||||
| @@ -132,15 +178,18 @@ export async function fetchShopOrderReplenishmentReportData( | |||||
| } | } | ||||
| /** | /** | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 | |||||
| * Generate and download Shop Orders Replenishment Records as Excel. | * Generate and download Shop Orders Replenishment Records as Excel. | ||||
| */ | */ | ||||
| export async function generateShopOrderReplenishmentReportExcel( | export async function generateShopOrderReplenishmentReportExcel( | ||||
| criteria: Record<string, string>, | criteria: Record<string, string>, | ||||
| reportTitle: string = "店鋪訂單補貨記錄", | reportTitle: string = "店鋪訂單補貨記錄", | ||||
| t?: TFunction, | |||||
| ): Promise<void> { | ): Promise<void> { | ||||
| const L = shopReplenishmentLabels(t); | |||||
| const rows = await fetchShopOrderReplenishmentReportData(criteria); | const rows = await fetchShopOrderReplenishmentReportData(criteria); | ||||
| const excelRows = | const excelRows = | ||||
| rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()]; | |||||
| rows.length > 0 ? rows.map((r) => toExcelRow(r, L)) : [emptySheetRow(L)]; | |||||
| const dateCandidates = [ | const dateCandidates = [ | ||||
| criteria.reorderDateStart, | criteria.reorderDateStart, | ||||
| @@ -157,5 +206,5 @@ export async function generateShopOrderReplenishmentReportExcel( | |||||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | ||||
| const filename = `${reportTitle}_${safeDatePart}`; | const filename = `${reportTitle}_${safeDatePart}`; | ||||
| exportChartToXlsx(excelRows, filename, SHEET_NAME); | |||||
| exportChartToXlsx(excelRows, filename, L.sheetName); | |||||
| } | } | ||||
| @@ -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; | stockInLineId: number | null; | ||||
| itemId: number | null; | itemId: number | null; | ||||
| lotNo: string | null; | lotNo: string | null; | ||||
| /** Effective shelf life days used for print (chilled or -18, according to useMinus18). */ | |||||
| defaultShelfLifeDays?: number | null; | |||||
| /** True when expiry is computed from -18 warehouse days. */ | |||||
| useMinus18?: boolean | null; | |||||
| /** Print date + effective shelf life days (yyyy-MM-dd). */ | |||||
| expiryDate?: string | null; | |||||
| /** 打袋機 DataFlex cumulative printed qty */ | /** 打袋機 DataFlex cumulative printed qty */ | ||||
| bagPrintedQty?: number; | bagPrintedQty?: number; | ||||
| /** 標簽機 cumulative printed qty */ | /** 標簽機 cumulative printed qty */ | ||||
| @@ -37,6 +43,8 @@ export interface OnPackQrDownloadRequest { | |||||
| jobOrderId: number; | jobOrderId: number; | ||||
| itemCode: string; | itemCode: string; | ||||
| }[]; | }[]; | ||||
| /** /bagPrint filter date (YYYY-MM-DD). Used by expiry ZIP for production date. */ | |||||
| planDate?: string; | |||||
| } | } | ||||
| /** Same mapping as Bag Print download buttons: one entry per row with a non-empty item code. */ | /** Same mapping as Bag Print download buttons: one entry per row with a non-empty item code. */ | ||||
| @@ -80,17 +88,27 @@ export async function pushOnPackTextQrZipToNgpcl(request: OnPackQrDownloadReques | |||||
| /** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */ | /** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */ | ||||
| async function zipDownloadError(res: Response): Promise<Error> { | async function zipDownloadError(res: Response): Promise<Error> { | ||||
| return parseBagPrintApiError(res, "下載"); | |||||
| } | |||||
| /** Backend ErrorRes is `{ timestamp, traceId }` with no message; avoid calling that a ZIP download failure. */ | |||||
| async function parseBagPrintApiError(res: Response, action: string): Promise<Error> { | |||||
| const text = await res.text(); | const text = await res.text(); | ||||
| const ct = res.headers.get("content-type") ?? ""; | const ct = res.headers.get("content-type") ?? ""; | ||||
| if (ct.includes("application/json")) { | if (ct.includes("application/json")) { | ||||
| try { | try { | ||||
| const j = JSON.parse(text) as { message?: string; error?: string }; | |||||
| const j = JSON.parse(text) as { message?: string; error?: string; traceId?: string }; | |||||
| if (typeof j.message === "string" && j.message.length > 0) { | if (typeof j.message === "string" && j.message.length > 0) { | ||||
| return new Error(j.message); | return new Error(j.message); | ||||
| } | } | ||||
| if (typeof j.error === "string" && j.error.length > 0) { | if (typeof j.error === "string" && j.error.length > 0) { | ||||
| return new Error(j.error); | return new Error(j.error); | ||||
| } | } | ||||
| if (typeof j.traceId === "string" && j.traceId.length > 0) { | |||||
| return new Error( | |||||
| `${action}失敗(HTTP ${res.status})。請重啟後端以執行 Liquibase,或查看日誌 traceId ${j.traceId}。`, | |||||
| ); | |||||
| } | |||||
| } catch { | } catch { | ||||
| /* ignore parse */ | /* ignore parse */ | ||||
| } | } | ||||
| @@ -98,7 +116,7 @@ async function zipDownloadError(res: Response): Promise<Error> { | |||||
| if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { | if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { | ||||
| return new Error(text); | return new Error(text); | ||||
| } | } | ||||
| return new Error(`下載失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`); | |||||
| return new Error(`${action}失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`); | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -149,6 +167,40 @@ export async function downloadOnPackQrZip( | |||||
| return res.blob(); | return res.blob(); | ||||
| } | } | ||||
| export type OnPackZipDownload = { | |||||
| blob: Blob; | |||||
| skippedWithoutExpiry: string[]; | |||||
| }; | |||||
| function skippedWithoutExpiryFromResponse(res: Response): string[] { | |||||
| const raw = res.headers.get("X-OnPack-Skipped-Expiry") ?? ""; | |||||
| return raw | |||||
| .split(",") | |||||
| .map((s) => s.trim().toUpperCase()) | |||||
| .filter(Boolean); | |||||
| } | |||||
| /** 汁水機 OnPack — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */ | |||||
| export async function downloadOnPackQrZipWithExpiry( | |||||
| request: OnPackQrDownloadRequest, | |||||
| ): Promise<OnPackZipDownload> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`; | |||||
| const res = await clientAuthFetch(url, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify(request), | |||||
| }); | |||||
| if (!res.ok) { | |||||
| throw await zipDownloadError(res); | |||||
| } | |||||
| return { | |||||
| blob: await res.blob(), | |||||
| skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res), | |||||
| }; | |||||
| } | |||||
| /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ | /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ | ||||
| export async function downloadOnPackTextQrZip( | export async function downloadOnPackTextQrZip( | ||||
| request: OnPackQrDownloadRequest, | request: OnPackQrDownloadRequest, | ||||
| @@ -166,3 +218,167 @@ export async function downloadOnPackTextQrZip( | |||||
| return res.blob(); | return res.blob(); | ||||
| } | } | ||||
| /** OnPack2023 檸檬機 — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */ | |||||
| export async function downloadOnPackTextQrZipWithExpiry( | |||||
| request: OnPackQrDownloadRequest, | |||||
| ): Promise<OnPackZipDownload> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`; | |||||
| const res = await clientAuthFetch(url, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify(request), | |||||
| }); | |||||
| if (!res.ok) { | |||||
| throw await zipDownloadError(res); | |||||
| } | |||||
| return { | |||||
| blob: await res.blob(), | |||||
| skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res), | |||||
| }; | |||||
| } | |||||
| export type OnPackMachine = "juice" | "lemon"; | |||||
| export interface OnPackTemplateFileDto { | |||||
| id: number; | |||||
| machine: OnPackMachine | string; | |||||
| itemCode: string; | |||||
| fileName: string; | |||||
| byteSize: number; | |||||
| modified?: string | null; | |||||
| } | |||||
| export interface OnPackTemplateUploadResponse { | |||||
| machine: string; | |||||
| itemCode: string; | |||||
| saved: string[]; | |||||
| } | |||||
| export interface OnPackSupportedItemDto { | |||||
| itemCode: string; | |||||
| printable: boolean; | |||||
| inDatabase: boolean; | |||||
| builtin: boolean; | |||||
| registered: boolean; | |||||
| } | |||||
| export interface OnPackSupportedCatalogDto { | |||||
| juice: OnPackSupportedItemDto[]; | |||||
| lemon: OnPackSupportedItemDto[]; | |||||
| } | |||||
| export interface OnPackExpiryItemCodeDto { | |||||
| machine: string; | |||||
| itemCode: string; | |||||
| printName?: string | null; | |||||
| defaultPrintName?: string | null; | |||||
| defaultDays?: number | null; | |||||
| minus18Days?: number | null; | |||||
| useMinus18?: boolean; | |||||
| effectiveDays?: number | null; | |||||
| } | |||||
| export type OnPackExpiryItemCodeUpdate = { | |||||
| itemCode: string; | |||||
| machine?: OnPackMachine; | |||||
| printName?: string | null; | |||||
| useMinus18?: boolean; | |||||
| }; | |||||
| export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice"): Promise<OnPackExpiryItemCodeDto[]> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}`; | |||||
| const res = await clientAuthFetch(url, { method: "GET" }); | |||||
| if (!res.ok) { | |||||
| throw await parseBagPrintApiError(res, "讀取到期日 ZIP 品號"); | |||||
| } | |||||
| return (await res.json()) as OnPackExpiryItemCodeDto[]; | |||||
| } | |||||
| export async function addOnPackExpiryCode( | |||||
| itemCode: string, | |||||
| machine: OnPackMachine = "juice", | |||||
| ): Promise<OnPackExpiryItemCodeDto> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | |||||
| const res = await clientAuthFetch(url, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify({ itemCode, machine }), | |||||
| }); | |||||
| if (!res.ok) { | |||||
| throw await parseBagPrintApiError(res, "新增到期日 ZIP 品號"); | |||||
| } | |||||
| return (await res.json()) as OnPackExpiryItemCodeDto; | |||||
| } | |||||
| export async function updateOnPackExpiryCode( | |||||
| body: OnPackExpiryItemCodeUpdate, | |||||
| ): Promise<OnPackExpiryItemCodeDto> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | |||||
| const res = await clientAuthFetch(url, { | |||||
| method: "PUT", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify(body), | |||||
| }); | |||||
| if (!res.ok) { | |||||
| throw await parseBagPrintApiError(res, "更新到期日 ZIP 品號"); | |||||
| } | |||||
| return (await res.json()) as OnPackExpiryItemCodeDto; | |||||
| } | |||||
| export async function deleteOnPackExpiryCode( | |||||
| itemCode: string, | |||||
| machine: OnPackMachine = "juice", | |||||
| ): Promise<void> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}&itemCode=${encodeURIComponent(itemCode)}`; | |||||
| const res = await clientAuthFetch(url, { method: "DELETE" }); | |||||
| if (!res.ok && res.status !== 204) { | |||||
| throw await parseBagPrintApiError(res, "刪除到期日 ZIP 品號"); | |||||
| } | |||||
| } | |||||
| export async function fetchOnPackSupportedCatalog(): Promise<OnPackSupportedCatalogDto> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`; | |||||
| const res = await clientAuthFetch(url, { method: "GET" }); | |||||
| if (!res.ok) { | |||||
| throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單"); | |||||
| } | |||||
| return (await res.json()) as OnPackSupportedCatalogDto; | |||||
| } | |||||
| export async function listOnPackTemplates(machine?: OnPackMachine): Promise<OnPackTemplateFileDto[]> { | |||||
| const q = machine ? `?machine=${encodeURIComponent(machine)}` : ""; | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`; | |||||
| const res = await clientAuthFetch(url, { method: "GET" }); | |||||
| if (!res.ok) { | |||||
| throw await parseBagPrintApiError(res, "讀取 OnPack 模板"); | |||||
| } | |||||
| return (await res.json()) as OnPackTemplateFileDto[]; | |||||
| } | |||||
| export async function uploadOnPackTemplates( | |||||
| machine: OnPackMachine, | |||||
| itemCode: string, | |||||
| files: File[], | |||||
| ): Promise<OnPackTemplateUploadResponse> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`; | |||||
| const body = new FormData(); | |||||
| body.append("machine", machine); | |||||
| body.append("itemCode", itemCode); | |||||
| files.forEach((f) => body.append("files", f)); | |||||
| const res = await clientAuthFetch(url, { method: "POST", body }); | |||||
| if (!res.ok) { | |||||
| throw await parseBagPrintApiError(res, "上傳 OnPack 模板"); | |||||
| } | |||||
| return (await res.json()) as OnPackTemplateUploadResponse; | |||||
| } | |||||
| export async function deleteOnPackTemplate(id: number): Promise<void> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/${id}`; | |||||
| const res = await clientAuthFetch(url, { method: "DELETE" }); | |||||
| if (!res.ok && res.status !== 204) { | |||||
| throw await parseBagPrintApiError(res, "刪除 OnPack 模板"); | |||||
| } | |||||
| } | |||||
| @@ -131,6 +131,8 @@ export interface StaffDeliveryPerformanceRow { | |||||
| staffName: string; | staffName: string; | ||||
| orderCount: number; | orderCount: number; | ||||
| totalMinutes: number; | totalMinutes: number; | ||||
| itemKindCount: number; | |||||
| itemQtyPicked: number; | |||||
| } | } | ||||
| export interface StaffOption { | export interface StaffOption { | ||||
| @@ -577,6 +579,7 @@ export async function fetchPlannedOutputByDateAndItem( | |||||
| /** Warehouse / lane filter for staff delivery performance chart (delivery_order_pick_order.store_id). */ | /** Warehouse / lane filter for staff delivery performance chart (delivery_order_pick_order.store_id). */ | ||||
| export type StaffDeliveryPerformanceStoreFilter = "all" | "2/F" | "4/F" | "null_only"; | export type StaffDeliveryPerformanceStoreFilter = "all" | "2/F" | "4/F" | "null_only"; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ | |||||
| export async function fetchStaffDeliveryPerformance( | export async function fetchStaffDeliveryPerformance( | ||||
| startDate?: string, | startDate?: string, | ||||
| endDate?: string, | endDate?: string, | ||||
| @@ -604,6 +607,8 @@ export async function fetchStaffDeliveryPerformance( | |||||
| staffName: String(row.staffName ?? row.staffname ?? ""), | staffName: String(row.staffName ?? row.staffname ?? ""), | ||||
| orderCount: Number(row.orderCount ?? row.ordercount ?? 0), | orderCount: Number(row.orderCount ?? row.ordercount ?? 0), | ||||
| totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0), | totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0), | ||||
| itemKindCount: Number(row.itemKindCount ?? row.itemkindcount ?? 0), | |||||
| itemQtyPicked: Number(row.itemQtyPicked ?? row.itemqtypicked ?? 0), | |||||
| }; | }; | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -1741,6 +1741,38 @@ export const fetchDrinkProductionQty = cache( | |||||
| }, | }, | ||||
| ); | ); | ||||
| export interface DrinkShipmentQtyDeliveryDetail { | |||||
| deliveryOrderId: number; | |||||
| deliveryOrderCode?: string | null; | |||||
| deliveryDate?: string | null; | |||||
| shopCode?: string | null; | |||||
| shopName?: string | null; | |||||
| deliveryOrderStatus?: string | null; | |||||
| orderQty: number; | |||||
| shippedQty: number; | |||||
| } | |||||
| export interface DrinkShipmentQtyResponse { | |||||
| itemCode?: string | null; | |||||
| itemName?: string | null; | |||||
| uom?: string | null; | |||||
| totalOrderQty: number; | |||||
| totalShippedQty: number; | |||||
| deliveries?: DrinkShipmentQtyDeliveryDetail[]; | |||||
| } | |||||
| export const fetchDrinkShipmentQty = cache(async (date?: string) => { | |||||
| const params = new URLSearchParams(); | |||||
| if (date) params.set("date", date); | |||||
| const qs = params.toString(); | |||||
| const url = `${BASE_API_URL}/product-process/Demo/DrinkShipmentQty${qs ? `?${qs}` : ""}`; | |||||
| return serverFetchJson<DrinkShipmentQtyResponse[]>(url, { | |||||
| method: "GET", | |||||
| next: { tags: ["drinkShipmentQty"] }, | |||||
| }); | |||||
| }); | |||||
| // ===== Equipment Status Dashboard ===== | // ===== Equipment Status Dashboard ===== | ||||
| export interface EquipmentStatusProcessInfo { | export interface EquipmentStatusProcessInfo { | ||||
| @@ -13,6 +13,10 @@ export interface JobOrderListItem { | |||||
| stockInLineId: number | null; | stockInLineId: number | null; | ||||
| itemId: number | null; | itemId: number | null; | ||||
| lotNo: string | null; | lotNo: string | null; | ||||
| defaultShelfLifeDays?: number | null; | |||||
| useMinus18?: boolean | null; | |||||
| /** ISO `yyyy-MM-dd`, or Jackson date array `[yyyy,M,d]`. */ | |||||
| expiryDate?: string | number[] | null; | |||||
| bagPrintedQty?: number; | bagPrintedQty?: number; | ||||
| labelPrintedQty?: number; | labelPrintedQty?: number; | ||||
| laserPrintedQty?: number; | laserPrintedQty?: number; | ||||
| @@ -48,6 +52,8 @@ export interface LaserBag2SendRequest { | |||||
| jobOrderId?: number | null; | jobOrderId?: number | null; | ||||
| jobOrderNo?: string | null; | jobOrderNo?: string | null; | ||||
| lotNo?: string | null; | lotNo?: string | null; | ||||
| /** Print-time expiry from job list (`yyyy-MM-dd`); backend sends as 4th TCP field. */ | |||||
| expiryDate?: string | null; | |||||
| source?: string | null; | source?: string | null; | ||||
| } | } | ||||
| @@ -109,6 +115,39 @@ export async function fetchLaserBag2Settings(): Promise<LaserBag2Settings> { | |||||
| return res.json() as Promise<LaserBag2Settings>; | return res.json() as Promise<LaserBag2Settings>; | ||||
| } | } | ||||
| /** List API may return LocalDate as `"2026-08-27"` or `[2026,8,27]` (@EnableWebMvc raw Jackson). */ | |||||
| export function expiryDateForLaserSend(value: unknown): string | null { | |||||
| if (value == null || value === "") return null; | |||||
| if (typeof value === "string") { | |||||
| const s = value.trim(); | |||||
| if (!s) return null; | |||||
| if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); | |||||
| return s; | |||||
| } | |||||
| if (Array.isArray(value) && value.length >= 3) { | |||||
| const y = Number(value[0]); | |||||
| const m = Number(value[1]); | |||||
| const d = Number(value[2]); | |||||
| if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d) || y < 1 || m < 1 || d < 1) { | |||||
| return null; | |||||
| } | |||||
| return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; | |||||
| } | |||||
| return null; | |||||
| } | |||||
| function messageFromLaserSendBody(data: Record<string, unknown>, status: number): string { | |||||
| const message = typeof data.message === "string" ? data.message.trim() : ""; | |||||
| if (message) return message; | |||||
| const detail = typeof data.detail === "string" ? data.detail.trim() : ""; | |||||
| if (detail) return detail; | |||||
| const error = typeof data.error === "string" ? data.error.trim() : ""; | |||||
| if (error) return error; | |||||
| const traceId = typeof data.traceId === "string" ? data.traceId.trim() : ""; | |||||
| if (traceId) return `送出失敗(traceId ${traceId})`; | |||||
| return `送出失敗(HTTP ${status})`; | |||||
| } | |||||
| export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> { | export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -116,11 +155,29 @@ export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<Lase | |||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| body: JSON.stringify(body), | body: JSON.stringify(body), | ||||
| }); | }); | ||||
| const data = (await res.json()) as LaserBag2SendResponse; | |||||
| if (!res.ok) { | |||||
| return data; | |||||
| let data: Record<string, unknown> = {}; | |||||
| try { | |||||
| const text = await res.text(); | |||||
| data = text ? (JSON.parse(text) as Record<string, unknown>) : {}; | |||||
| } catch { | |||||
| return { success: false, message: `送出失敗(HTTP ${res.status},無法解析回應)` }; | |||||
| } | } | ||||
| return data; | |||||
| if (!res.ok || data.success === false) { | |||||
| return { | |||||
| success: false, | |||||
| message: messageFromLaserSendBody(data, res.status), | |||||
| payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null, | |||||
| printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | |||||
| receiveAcknowledged: Boolean(data.receiveAcknowledged), | |||||
| }; | |||||
| } | |||||
| return { | |||||
| success: true, | |||||
| message: typeof data.message === "string" && data.message.trim() ? data.message : "已送出", | |||||
| payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null, | |||||
| printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | |||||
| receiveAcknowledged: Boolean(data.receiveAcknowledged), | |||||
| }; | |||||
| } | } | ||||
| export interface PrinterStatusRequest { | export interface PrinterStatusRequest { | ||||
| @@ -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) { | if (response.status === 401) { | ||||
| throw new Error("Unauthorized: Please log in again"); | throw new Error("Unauthorized: Please log in again"); | ||||
| } | } | ||||
| throw new Error(`Failed to update user: ${response.status} ${response.statusText}`); | |||||
| let detail = ""; | |||||
| try { | |||||
| const body = await response.json(); | |||||
| detail = body?.error || body?.message || ""; | |||||
| } catch { | |||||
| // ignore parse errors | |||||
| } | |||||
| throw new Error( | |||||
| `Failed to update user: ${response.status} ${response.statusText}${detail ? `. ${detail}` : ""}`, | |||||
| ); | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -25,6 +25,8 @@ export const AUTH = { | |||||
| */ | */ | ||||
| PRODUCT_PROCESS: "PRODUCT_PROCESS", | PRODUCT_PROCESS: "PRODUCT_PROCESS", | ||||
| REPORT_MGMT: "REPORT_MGMT", | REPORT_MGMT: "REPORT_MGMT", | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 — Manual M18 sync page (/m18Syn): ADMIN or this ability */ | |||||
| M18_SYNC: "M18_SYNC", | |||||
| } as const; | } as const; | ||||
| /** | /** | ||||
| @@ -1,6 +1,7 @@ | |||||
| import MUIAppBar from "@mui/material/AppBar"; | import MUIAppBar from "@mui/material/AppBar"; | ||||
| import Toolbar from "@mui/material/Toolbar"; | import Toolbar from "@mui/material/Toolbar"; | ||||
| import React from "react"; | import React from "react"; | ||||
| import LanguageSwitcher from "./LanguageSwitcher"; | |||||
| import Profile from "./Profile"; | import Profile from "./Profile"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import NavigationToggle from "./NavigationToggle"; | import NavigationToggle from "./NavigationToggle"; | ||||
| @@ -35,6 +36,7 @@ const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => { | |||||
| gap: 1, | gap: 1, | ||||
| }} | }} | ||||
| > | > | ||||
| <LanguageSwitcher /> | |||||
| <Profile | <Profile | ||||
| avatarImageSrc={avatarImageSrc} | avatarImageSrc={avatarImageSrc} | ||||
| profileName={profileName} | profileName={profileName} | ||||
| @@ -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"; | "use client"; | ||||
| import React, { useCallback, useEffect, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Alert, | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Chip, | |||||
| FormControl, | FormControl, | ||||
| InputLabel, | InputLabel, | ||||
| MenuItem, | MenuItem, | ||||
| @@ -19,6 +21,15 @@ import { | |||||
| DialogActions, | DialogActions, | ||||
| TextField, | TextField, | ||||
| Snackbar, | Snackbar, | ||||
| Switch, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| TableSortLabel, | |||||
| Tooltip, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import ChevronLeft from "@mui/icons-material/ChevronLeft"; | import ChevronLeft from "@mui/icons-material/ChevronLeft"; | ||||
| import ChevronRight from "@mui/icons-material/ChevronRight"; | import ChevronRight from "@mui/icons-material/ChevronRight"; | ||||
| @@ -29,11 +40,21 @@ import { | |||||
| buildOnPackJobOrdersPayload, | buildOnPackJobOrdersPayload, | ||||
| checkPrinterStatus, | checkPrinterStatus, | ||||
| downloadOnPackQrZip, | downloadOnPackQrZip, | ||||
| downloadOnPackQrZipWithExpiry, | |||||
| downloadOnPackTextQrZip, | downloadOnPackTextQrZip, | ||||
| downloadOnPackTextQrZipWithExpiry, | |||||
| fetchJobOrders, | fetchJobOrders, | ||||
| fetchOnPackExpiryCodes, | |||||
| addOnPackExpiryCode, | |||||
| updateOnPackExpiryCode, | |||||
| deleteOnPackExpiryCode, | |||||
| fetchOnPackSupportedCatalog, | |||||
| JobOrderListItem, | JobOrderListItem, | ||||
| OnPackExpiryItemCodeDto, | |||||
| } from "@/app/api/bagPrint/actions"; | } from "@/app/api/bagPrint/actions"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| @@ -56,6 +77,28 @@ const REFRESH_MS = 60 * 1000; | |||||
| const PRINTER_CHECK_MS = 60 * 1000; | const PRINTER_CHECK_MS = 60 * 1000; | ||||
| const PRINTER_RETRY_MS = 30 * 1000; | const PRINTER_RETRY_MS = 30 * 1000; | ||||
| const SETTINGS_KEY = "bagPrint_settings"; | const SETTINGS_KEY = "bagPrint_settings"; | ||||
| const ONPACK_ADMIN_USERNAME = "2fi"; | |||||
| /** Login username from backend JWT `sub` (UserDetails.username). */ | |||||
| function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string { | |||||
| const token = session?.accessToken?.trim(); | |||||
| if (token) { | |||||
| try { | |||||
| const parts = token.split("."); | |||||
| if (parts.length >= 2) { | |||||
| const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); | |||||
| const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); | |||||
| const payload = JSON.parse(atob(padded)) as { sub?: unknown }; | |||||
| if (typeof payload.sub === "string" && payload.sub.trim()) { | |||||
| return payload.sub.trim(); | |||||
| } | |||||
| } | |||||
| } catch { | |||||
| // fall through to display name | |||||
| } | |||||
| } | |||||
| return (session?.user?.name ?? "").trim(); | |||||
| } | |||||
| const DEFAULT_SETTINGS = { | const DEFAULT_SETTINGS = { | ||||
| dabag_ip: "", | dabag_ip: "", | ||||
| @@ -95,7 +138,91 @@ function getBatch(jo: JobOrderListItem): string { | |||||
| return (jo.lotNo || "—").trim() || "—"; | return (jo.lotNo || "—").trim() || "—"; | ||||
| } | } | ||||
| function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set<string> { | |||||
| return new Set(rows.map((r) => r.itemCode.trim().toUpperCase()).filter(Boolean)); | |||||
| } | |||||
| function daysLabel(value: number | null | undefined): string { | |||||
| return value == null ? "未設" : String(value); | |||||
| } | |||||
| type ExpirySortKey = | |||||
| | "itemCode" | |||||
| | "name" | |||||
| | "defaultDays" | |||||
| | "minus18Days" | |||||
| | "useMinus18" | |||||
| | "effectiveDays"; | |||||
| function displayName(row: OnPackExpiryItemCodeDto): string { | |||||
| return (row.printName || row.defaultPrintName || "").trim(); | |||||
| } | |||||
| function cmpText(a: string, b: string): number { | |||||
| return a.localeCompare(b, "zh-Hant", { numeric: true, sensitivity: "base" }); | |||||
| } | |||||
| /** Null (未設) sorts first on asc so unset rows are easy to find. */ | |||||
| function cmpDays(a: number | null | undefined, b: number | null | undefined): number { | |||||
| const av = a == null ? Number.NEGATIVE_INFINITY : a; | |||||
| const bv = b == null ? Number.NEGATIVE_INFINITY : b; | |||||
| return av - bv; | |||||
| } | |||||
| function skippedExpirySnackbar(okMessage: string, skipped: string[]): { | |||||
| open: true; | |||||
| message: string; | |||||
| severity: "success" | "warning"; | |||||
| duration: number; | |||||
| } { | |||||
| if (skipped.length === 0) { | |||||
| return { open: true, message: okMessage, severity: "success", duration: 3000 }; | |||||
| } | |||||
| return { | |||||
| open: true, | |||||
| message: `${okMessage}。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`, | |||||
| severity: "warning", | |||||
| duration: 10000, | |||||
| }; | |||||
| } | |||||
| function sortExpiryRows( | |||||
| rows: OnPackExpiryItemCodeDto[], | |||||
| key: ExpirySortKey, | |||||
| dir: "asc" | "desc", | |||||
| ): OnPackExpiryItemCodeDto[] { | |||||
| const sign = dir === "asc" ? 1 : -1; | |||||
| return [...rows].sort((a, b) => { | |||||
| let cmp = 0; | |||||
| switch (key) { | |||||
| case "itemCode": | |||||
| cmp = cmpText(a.itemCode, b.itemCode); | |||||
| break; | |||||
| case "name": | |||||
| cmp = cmpText(displayName(a), displayName(b)); | |||||
| break; | |||||
| case "defaultDays": | |||||
| cmp = cmpDays(a.defaultDays, b.defaultDays); | |||||
| break; | |||||
| case "minus18Days": | |||||
| cmp = cmpDays(a.minus18Days, b.minus18Days); | |||||
| break; | |||||
| case "useMinus18": | |||||
| cmp = Number(a.useMinus18 === true) - Number(b.useMinus18 === true); | |||||
| break; | |||||
| case "effectiveDays": | |||||
| cmp = cmpDays(a.effectiveDays, b.effectiveDays); | |||||
| break; | |||||
| } | |||||
| if (cmp === 0) cmp = cmpText(a.itemCode, b.itemCode); | |||||
| return cmp * sign; | |||||
| }); | |||||
| } | |||||
| const BagPrintSearch: React.FC = () => { | const BagPrintSearch: React.FC = () => { | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||||
| const canSeeOnPackAdmin = | |||||
| loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME; | |||||
| const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); | const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); | ||||
| const [loading, setLoading] = useState(true); | const [loading, setLoading] = useState(true); | ||||
| @@ -109,12 +236,35 @@ const BagPrintSearch: React.FC = () => { | |||||
| const [printContinuous, setPrintContinuous] = useState(false); | const [printContinuous, setPrintContinuous] = useState(false); | ||||
| const [printing, setPrinting] = useState(false); | const [printing, setPrinting] = useState(false); | ||||
| const [settingsOpen, setSettingsOpen] = useState(false); | const [settingsOpen, setSettingsOpen] = useState(false); | ||||
| const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); | |||||
| const [templatesOpen, setTemplatesOpen] = useState(false); | |||||
| const [expiryCodes, setExpiryCodes] = useState<OnPackExpiryItemCodeDto[]>([]); | |||||
| const [expiryCodeInput, setExpiryCodeInput] = useState(""); | |||||
| const [expiryCodesLoading, setExpiryCodesLoading] = useState(false); | |||||
| const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({}); | |||||
| const [expirySortKey, setExpirySortKey] = useState<ExpirySortKey>("itemCode"); | |||||
| const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc"); | |||||
| const [lemonCodeSet, setLemonCodeSet] = useState<Set<string>>(() => new Set()); | |||||
| const expiryAddRef = useRef(false); | |||||
| const expiryDeleteRef = useRef(false); | |||||
| const expirySaveRef = useRef<Set<string>>(new Set()); | |||||
| const expiryToggleRef = useRef<Set<string>>(new Set()); | |||||
| const [snackbar, setSnackbar] = useState<{ | |||||
| open: boolean; | |||||
| message: string; | |||||
| severity?: "success" | "info" | "warning" | "error"; | |||||
| duration?: number; | |||||
| }>({ open: false, message: "" }); | |||||
| const [settings, setSettings] = useState(DEFAULT_SETTINGS); | const [settings, setSettings] = useState(DEFAULT_SETTINGS); | ||||
| const [printerConnected, setPrinterConnected] = useState(false); | const [printerConnected, setPrinterConnected] = useState(false); | ||||
| const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | ||||
| const [downloadingOnPack, setDownloadingOnPack] = useState(false); | const [downloadingOnPack, setDownloadingOnPack] = useState(false); | ||||
| const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false); | |||||
| const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); | const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); | ||||
| const [downloadingOnPackTextExp, setDownloadingOnPackTextExp] = useState(false); | |||||
| const downloadingOnPackRef = useRef(false); | |||||
| const downloadingOnPackExpRef = useRef(false); | |||||
| const downloadingOnPackTextRef = useRef(false); | |||||
| const downloadingOnPackTextExpRef = useRef(false); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| setSettings(loadSettings()); | setSettings(loadSettings()); | ||||
| @@ -275,6 +425,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| }; | }; | ||||
| const handleDownloadOnPackQr = async () => { | const handleDownloadOnPackQr = async () => { | ||||
| if (downloadingOnPackRef.current) return; | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | ||||
| if (onPackJobOrders.length === 0) { | if (onPackJobOrders.length === 0) { | ||||
| @@ -282,6 +433,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| return; | return; | ||||
| } | } | ||||
| downloadingOnPackRef.current = true; | |||||
| setDownloadingOnPack(true); | setDownloadingOnPack(true); | ||||
| try { | try { | ||||
| const blob = await downloadOnPackQrZip({ | const blob = await downloadOnPackQrZip({ | ||||
| @@ -306,10 +458,51 @@ const BagPrintSearch: React.FC = () => { | |||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| setDownloadingOnPack(false); | setDownloadingOnPack(false); | ||||
| downloadingOnPackRef.current = false; | |||||
| } | |||||
| }; | |||||
| const handleDownloadOnPackQrWithExpiry = async () => { | |||||
| if (downloadingOnPackExpRef.current) return; | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | |||||
| if (onPackJobOrders.length === 0) { | |||||
| setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); | |||||
| return; | |||||
| } | |||||
| downloadingOnPackExpRef.current = true; | |||||
| setDownloadingOnPackExp(true); | |||||
| try { | |||||
| const { blob, skippedWithoutExpiry } = await downloadOnPackQrZipWithExpiry({ | |||||
| jobOrders: onPackJobOrders, | |||||
| planDate, | |||||
| }); | |||||
| const url = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement("a"); | |||||
| link.href = url; | |||||
| link.setAttribute("download", `onpack_qr_exp_${planDate}.zip`); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(url); | |||||
| setSnackbar(skippedExpirySnackbar("OnPack 汁水機(含到期日)ZIP 已下載", skippedWithoutExpiry)); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "下載 OnPack 汁水機(含到期日)失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| setDownloadingOnPackExp(false); | |||||
| downloadingOnPackExpRef.current = false; | |||||
| } | } | ||||
| }; | }; | ||||
| const handleDownloadOnPackTextQr = async () => { | const handleDownloadOnPackTextQr = async () => { | ||||
| if (downloadingOnPackTextRef.current) return; | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | ||||
| if (onPackJobOrders.length === 0) { | if (onPackJobOrders.length === 0) { | ||||
| @@ -317,6 +510,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| return; | return; | ||||
| } | } | ||||
| downloadingOnPackTextRef.current = true; | |||||
| setDownloadingOnPackText(true); | setDownloadingOnPackText(true); | ||||
| try { | try { | ||||
| const blob = await downloadOnPackTextQrZip({ | const blob = await downloadOnPackTextQrZip({ | ||||
| @@ -341,6 +535,209 @@ const BagPrintSearch: React.FC = () => { | |||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| setDownloadingOnPackText(false); | setDownloadingOnPackText(false); | ||||
| downloadingOnPackTextRef.current = false; | |||||
| } | |||||
| }; | |||||
| const handleDownloadOnPackTextQrWithExpiry = async () => { | |||||
| if (downloadingOnPackTextExpRef.current) return; | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | |||||
| if (onPackJobOrders.length === 0) { | |||||
| setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); | |||||
| return; | |||||
| } | |||||
| downloadingOnPackTextExpRef.current = true; | |||||
| setDownloadingOnPackTextExp(true); | |||||
| try { | |||||
| const { blob, skippedWithoutExpiry } = await downloadOnPackTextQrZipWithExpiry({ | |||||
| jobOrders: onPackJobOrders, | |||||
| planDate, | |||||
| }); | |||||
| const url = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement("a"); | |||||
| link.href = url; | |||||
| link.setAttribute("download", `onpack2023_lemon_qr_exp_${planDate}.zip`); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(url); | |||||
| setSnackbar(skippedExpirySnackbar("OnPack2023檸檬機(含到期日)ZIP 已下載", skippedWithoutExpiry)); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機(含到期日)失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| setDownloadingOnPackTextExp(false); | |||||
| downloadingOnPackTextExpRef.current = false; | |||||
| } | |||||
| }; | |||||
| const loadExpiryCodes = useCallback(async (notify = false) => { | |||||
| setExpiryCodesLoading(true); | |||||
| try { | |||||
| const rows = await fetchOnPackExpiryCodes("juice"); | |||||
| setExpiryCodes(rows); | |||||
| setNameDrafts( | |||||
| Object.fromEntries( | |||||
| rows.map((r) => [r.itemCode, r.printName || r.defaultPrintName || ""]), | |||||
| ), | |||||
| ); | |||||
| } catch (e) { | |||||
| if (notify) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "讀取到期日 ZIP 品號失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } | |||||
| } finally { | |||||
| setExpiryCodesLoading(false); | |||||
| } | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| void loadExpiryCodes(); | |||||
| }, [loadExpiryCodes]); | |||||
| useEffect(() => { | |||||
| void (async () => { | |||||
| try { | |||||
| const catalog = await fetchOnPackSupportedCatalog(); | |||||
| setLemonCodeSet( | |||||
| new Set( | |||||
| (catalog.lemon ?? []) | |||||
| .filter((row) => row.printable) | |||||
| .map((row) => row.itemCode.trim().toUpperCase()) | |||||
| .filter(Boolean), | |||||
| ), | |||||
| ); | |||||
| } catch { | |||||
| /* 檸檬機標籤可沒有;不擋畫面 */ | |||||
| } | |||||
| })(); | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| if (!templatesOpen) return; | |||||
| void loadExpiryCodes(true); | |||||
| }, [templatesOpen, loadExpiryCodes]); | |||||
| const handleAddExpiryCode = async () => { | |||||
| if (expiryAddRef.current) return; | |||||
| const itemCode = expiryCodeInput.trim(); | |||||
| if (!itemCode) { | |||||
| setSnackbar({ open: true, message: "請先填寫品號", severity: "error" }); | |||||
| return; | |||||
| } | |||||
| expiryAddRef.current = true; | |||||
| try { | |||||
| await addOnPackExpiryCode(itemCode, "juice"); | |||||
| setExpiryCodeInput(""); | |||||
| setSnackbar({ open: true, message: `已加入到期日 ZIP:${itemCode.toUpperCase()}`, severity: "success" }); | |||||
| await loadExpiryCodes(); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "新增到期日 ZIP 品號失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| expiryAddRef.current = false; | |||||
| } | |||||
| }; | |||||
| const handleDeleteExpiryCode = async (itemCode: string) => { | |||||
| if (expiryDeleteRef.current) return; | |||||
| expiryDeleteRef.current = true; | |||||
| try { | |||||
| await deleteOnPackExpiryCode(itemCode, "juice"); | |||||
| setSnackbar({ open: true, message: `已從到期日 ZIP 移除 ${itemCode}`, severity: "success" }); | |||||
| await loadExpiryCodes(); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "刪除到期日 ZIP 品號失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| expiryDeleteRef.current = false; | |||||
| } | |||||
| }; | |||||
| const applyExpiryRow = (updated: OnPackExpiryItemCodeDto) => { | |||||
| setExpiryCodes((prev) => prev.map((r) => (r.itemCode === updated.itemCode ? updated : r))); | |||||
| setNameDrafts((prev) => ({ | |||||
| ...prev, | |||||
| [updated.itemCode]: updated.printName || updated.defaultPrintName || "", | |||||
| })); | |||||
| }; | |||||
| const handleSaveExpiryPrintName = async (itemCode: string) => { | |||||
| if (expirySaveRef.current.has(itemCode)) return; | |||||
| expirySaveRef.current.add(itemCode); | |||||
| try { | |||||
| const updated = await updateOnPackExpiryCode({ | |||||
| itemCode, | |||||
| machine: "juice", | |||||
| printName: (nameDrafts[itemCode] ?? "").trim(), | |||||
| }); | |||||
| applyExpiryRow(updated); | |||||
| setSnackbar({ open: true, message: `已儲存 ${itemCode} 列印名稱`, severity: "success" }); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "儲存列印名稱失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| expirySaveRef.current.delete(itemCode); | |||||
| } | |||||
| }; | |||||
| const handleToggleUseMinus18 = async (itemCode: string, useMinus18: boolean) => { | |||||
| if (expiryToggleRef.current.has(itemCode)) return; | |||||
| expiryToggleRef.current.add(itemCode); | |||||
| try { | |||||
| const updated = await updateOnPackExpiryCode({ | |||||
| itemCode, | |||||
| machine: "juice", | |||||
| useMinus18, | |||||
| }); | |||||
| applyExpiryRow(updated); | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: useMinus18 ? `已改用 ${itemCode} 的 -18 天數` : `已改用 ${itemCode} 的冷藏天數`, | |||||
| severity: "success", | |||||
| }); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "更新保質期旗標失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| expiryToggleRef.current.delete(itemCode); | |||||
| } | |||||
| }; | |||||
| const juiceExpiryCodeSet = expiryCodeSet(expiryCodes); | |||||
| const sortedExpiryCodes = useMemo( | |||||
| () => sortExpiryRows(expiryCodes, expirySortKey, expirySortDir), | |||||
| [expiryCodes, expirySortKey, expirySortDir], | |||||
| ); | |||||
| const onExpirySort = (key: ExpirySortKey) => { | |||||
| if (expirySortKey === key) { | |||||
| setExpirySortDir((d) => (d === "asc" ? "desc" : "asc")); | |||||
| } else { | |||||
| setExpirySortKey(key); | |||||
| setExpirySortDir("asc"); | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -369,6 +766,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> | <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> | ||||
| 設定 | 設定 | ||||
| </Button> | </Button> | ||||
| {canSeeOnPackAdmin && ( | |||||
| <Button variant="outlined" onClick={() => setTemplatesOpen(true)}> | |||||
| OnPack 到期日 ZIP | |||||
| </Button> | |||||
| )} | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| px: 1.5, | px: 1.5, | ||||
| @@ -403,19 +805,66 @@ const BagPrintSearch: React.FC = () => { | |||||
| variant="contained" | variant="contained" | ||||
| startIcon={<Download />} | startIcon={<Download />} | ||||
| onClick={handleDownloadOnPackQr} | onClick={handleDownloadOnPackQr} | ||||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||||
| disabled={ | |||||
| loading || | |||||
| downloadingOnPack || | |||||
| downloadingOnPackExp || | |||||
| downloadingOnPackText || | |||||
| downloadingOnPackTextExp || | |||||
| jobOrders.length === 0 | |||||
| } | |||||
| > | > | ||||
| {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} | {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} | ||||
| </Button> | </Button> | ||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Download />} | |||||
| onClick={handleDownloadOnPackQrWithExpiry} | |||||
| disabled={ | |||||
| loading || | |||||
| downloadingOnPack || | |||||
| downloadingOnPackExp || | |||||
| downloadingOnPackText || | |||||
| downloadingOnPackTextExp || | |||||
| jobOrders.length === 0 | |||||
| } | |||||
| > | |||||
| {downloadingOnPackExp ? "下載中..." : "下載 OnPack 汁水機(含到期日)"} | |||||
| </Button> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="secondary" | color="secondary" | ||||
| startIcon={<Download />} | startIcon={<Download />} | ||||
| onClick={handleDownloadOnPackTextQr} | onClick={handleDownloadOnPackTextQr} | ||||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||||
| disabled={ | |||||
| loading || | |||||
| downloadingOnPack || | |||||
| downloadingOnPackExp || | |||||
| downloadingOnPackText || | |||||
| downloadingOnPackTextExp || | |||||
| jobOrders.length === 0 | |||||
| } | |||||
| > | > | ||||
| {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"} | {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"} | ||||
| </Button> | </Button> | ||||
| {canSeeOnPackAdmin && ( | |||||
| <Button | |||||
| variant="contained" | |||||
| color="secondary" | |||||
| startIcon={<Download />} | |||||
| onClick={handleDownloadOnPackTextQrWithExpiry} | |||||
| disabled={ | |||||
| loading || | |||||
| downloadingOnPack || | |||||
| downloadingOnPackExp || | |||||
| downloadingOnPackText || | |||||
| downloadingOnPackTextExp || | |||||
| jobOrders.length === 0 | |||||
| } | |||||
| > | |||||
| {downloadingOnPackTextExp ? "下載中..." : "下載 OnPack2023檸檬機(含到期日)"} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | </Stack> | ||||
| </Paper> | </Paper> | ||||
| @@ -436,6 +885,9 @@ const BagPrintSearch: React.FC = () => { | |||||
| const batch = getBatch(jo); | const batch = getBatch(jo); | ||||
| const qtyStr = formatQty(jo.reqQty); | const qtyStr = formatQty(jo.reqQty); | ||||
| const isSelected = selectedId === jo.id; | const isSelected = selectedId === jo.id; | ||||
| const codeKey = (jo.itemCode || "").trim().toUpperCase(); | |||||
| const juiceExpiryOk = juiceExpiryCodeSet.has(codeKey); | |||||
| const lemonOk = lemonCodeSet.has(codeKey); | |||||
| return ( | return ( | ||||
| <Paper | <Paper | ||||
| key={jo.id} | key={jo.id} | ||||
| @@ -471,6 +923,10 @@ const BagPrintSearch: React.FC = () => { | |||||
| <Typography variant="h6" sx={{ fontSize: "1.35rem" }}> | <Typography variant="h6" sx={{ fontSize: "1.35rem" }}> | ||||
| {jo.itemCode || "—"} | {jo.itemCode || "—"} | ||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}> | |||||
| {juiceExpiryOk ? <Chip size="small" label="汁水機" color="primary" /> : null} | |||||
| {lemonOk ? <Chip size="small" label="檸檬機" color="secondary" /> : null} | |||||
| </Stack> | |||||
| </Box> | </Box> | ||||
| <Box sx={{ flex: 1, minWidth: 0 }}> | <Box sx={{ flex: 1, minWidth: 0 }}> | ||||
| <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}> | <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}> | ||||
| @@ -632,13 +1088,254 @@ const BagPrintSearch: React.FC = () => { | |||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| <Dialog | |||||
| open={templatesOpen && canSeeOnPackAdmin} | |||||
| onClose={() => setTemplatesOpen(false)} | |||||
| maxWidth="xl" | |||||
| fullWidth | |||||
| scroll="paper" | |||||
| PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} | |||||
| > | |||||
| <DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle> | |||||
| <DialogContent | |||||
| sx={{ | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| overflow: "hidden", | |||||
| pt: 1, | |||||
| }} | |||||
| > | |||||
| <Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}> | |||||
| <Typography variant="subtitle2" color="primary"> | |||||
| 汁水機({expiryCodes.length}) | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。 | |||||
| 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。 | |||||
| </Typography> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <TextField | |||||
| label="新增品號" | |||||
| size="small" | |||||
| placeholder="例如 PP2211" | |||||
| value={expiryCodeInput} | |||||
| onChange={(e) => setExpiryCodeInput(e.target.value)} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === "Enter") { | |||||
| e.preventDefault(); | |||||
| void handleAddExpiryCode(); | |||||
| } | |||||
| }} | |||||
| sx={{ minWidth: 180 }} | |||||
| /> | |||||
| <Button variant="contained" onClick={() => void handleAddExpiryCode()}> | |||||
| 加入 | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| {expiryCodesLoading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 1 }}> | |||||
| <CircularProgress size={20} /> | |||||
| </Box> | |||||
| ) : expiryCodes.length === 0 ? ( | |||||
| <Typography color="text.secondary">清單空白</Typography> | |||||
| ) : ( | |||||
| <TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |||||
| <Table size="small" stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "itemCode"} | |||||
| direction={expirySortKey === "itemCode" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("itemCode")} | |||||
| > | |||||
| 品號 | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| <TableCell sx={{ fontWeight: 700 }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "name"} | |||||
| direction={expirySortKey === "name" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("name")} | |||||
| > | |||||
| 中文名稱+單位 | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "defaultDays"} | |||||
| direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("defaultDays")} | |||||
| > | |||||
| 冷藏 | |||||
| </TableSortLabel> | |||||
| <Typography variant="caption" display="block" color="text.secondary"> | |||||
| 天 | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "minus18Days"} | |||||
| direction={expirySortKey === "minus18Days" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("minus18Days")} | |||||
| > | |||||
| -18 | |||||
| </TableSortLabel> | |||||
| <Typography variant="caption" display="block" color="text.secondary"> | |||||
| 天 | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "useMinus18"} | |||||
| direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("useMinus18")} | |||||
| > | |||||
| 用 -18 | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "effectiveDays"} | |||||
| direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("effectiveDays")} | |||||
| > | |||||
| 列印 | |||||
| </TableSortLabel> | |||||
| <Typography variant="caption" display="block" color="text.secondary"> | |||||
| 天 | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="right" sx={{ fontWeight: 700 }}> | |||||
| 操作 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {sortedExpiryCodes.map((row) => { | |||||
| const draft = nameDrafts[row.itemCode] ?? ""; | |||||
| const savedName = row.printName || row.defaultPrintName || ""; | |||||
| const nameDirty = draft.trim() !== savedName.trim(); | |||||
| const hasShelf = row.defaultDays != null || row.minus18Days != null; | |||||
| const canUseMinus18 = row.minus18Days != null && row.minus18Days > 0; | |||||
| const missingHint = hasShelf | |||||
| ? canUseMinus18 | |||||
| ? "" | |||||
| : "此品號沒有 -18 天數" | |||||
| : "未設定保質期,請到設定 → 物品預設保質期新增"; | |||||
| return ( | |||||
| <TableRow key={row.itemCode} hover> | |||||
| <TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| {row.itemCode} | |||||
| </TableCell> | |||||
| <TableCell sx={{ minWidth: 280 }}> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <TextField | |||||
| size="small" | |||||
| value={draft} | |||||
| onChange={(e) => | |||||
| setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) | |||||
| } | |||||
| placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"} | |||||
| inputProps={{ maxLength: 255 }} | |||||
| fullWidth | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| disabled={!nameDirty} | |||||
| onClick={() => void handleSaveExpiryPrintName(row.itemCode)} | |||||
| > | |||||
| 儲存 | |||||
| </Button> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | |||||
| color={row.defaultDays == null ? "warning.main" : "text.primary"} | |||||
| > | |||||
| {daysLabel(row.defaultDays)} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | |||||
| color={row.minus18Days == null ? "warning.main" : "text.primary"} | |||||
| > | |||||
| {daysLabel(row.minus18Days)} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}> | |||||
| <span> | |||||
| <Switch | |||||
| size="small" | |||||
| checked={row.useMinus18 === true} | |||||
| disabled={!canUseMinus18} | |||||
| onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)} | |||||
| inputProps={{ "aria-label": `${row.itemCode} 用 -18` }} | |||||
| /> | |||||
| </span> | |||||
| </Tooltip> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ fontWeight: 800, lineHeight: 1.2 }} | |||||
| color={row.effectiveDays == null ? "warning.main" : "primary.main"} | |||||
| > | |||||
| {daysLabel(row.effectiveDays)} | |||||
| </Typography> | |||||
| {!hasShelf && ( | |||||
| <Typography variant="caption" color="warning.main" display="block"> | |||||
| 未設定 | |||||
| </Typography> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <Button | |||||
| size="small" | |||||
| color="error" | |||||
| onClick={() => void handleDeleteExpiryCode(row.itemCode)} | |||||
| > | |||||
| 移除 | |||||
| </Button> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| })} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}> | |||||
| 檸檬機到期日 ZIP 品號稍後加入。 | |||||
| </Typography> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setTemplatesOpen(false)}>關閉</Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Snackbar | <Snackbar | ||||
| open={snackbar.open} | open={snackbar.open} | ||||
| autoHideDuration={3000} | |||||
| autoHideDuration={snackbar.duration ?? 3000} | |||||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | ||||
| message={snackbar.message} | |||||
| anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | ||||
| /> | |||||
| > | |||||
| <Alert | |||||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | |||||
| severity={snackbar.severity ?? "info"} | |||||
| variant="filled" | |||||
| sx={{ width: "100%", maxWidth: 720 }} | |||||
| > | |||||
| {snackbar.message} | |||||
| </Alert> | |||||
| </Snackbar> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -24,6 +24,7 @@ const pathToLabelKey: { [path: string]: string } = { | |||||
| "/settings/user": "nav.settings.user", | "/settings/user": "nav.settings.user", | ||||
| "/settings/clientMonitor": "nav.settings.clientMonitor", | "/settings/clientMonitor": "nav.settings.clientMonitor", | ||||
| "/settings/items": "nav.settings.items", | "/settings/items": "nav.settings.items", | ||||
| "/settings/itemDefaultShelfLife": "nav.settings.itemDefaultShelfLife", | |||||
| "/settings/warehouse": "nav.settings.warehouse", | "/settings/warehouse": "nav.settings.warehouse", | ||||
| "/settings/qcCategory": "nav.settings.qcCategory", | "/settings/qcCategory": "nav.settings.qcCategory", | ||||
| "/settings/bomWeighting": "nav.settings.bomWeighting", | "/settings/bomWeighting": "nav.settings.bomWeighting", | ||||
| @@ -29,7 +29,7 @@ import { | |||||
| useForm, | useForm, | ||||
| useFormContext, | useFormContext, | ||||
| } from "react-hook-form"; | } from "react-hook-form"; | ||||
| import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; | |||||
| import { Check, Close, Error as ErrorIcon, RestartAlt } from "@mui/icons-material"; | |||||
| import { | import { | ||||
| UserInputs, | UserInputs, | ||||
| adminChangePassword, | adminChangePassword, | ||||
| @@ -46,8 +46,9 @@ interface Props { | |||||
| auths: auth[]; | auths: auth[]; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||||
| const CreateUser: React.FC<Props> = ({ rules, auths }) => { | const CreateUser: React.FC<Props> = ({ rules, auths }) => { | ||||
| console.log(auths); | |||||
| // console.log(auths); | |||||
| const { t } = useTranslation("user"); | const { t } = useTranslation("user"); | ||||
| const formProps = useForm<UserInputs>(); | const formProps = useForm<UserInputs>(); | ||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| @@ -172,7 +173,33 @@ const CreateUser: React.FC<Props> = ({ rules, auths }) => { | |||||
| router.replace("/settings/user"); | router.replace("/settings/user"); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.log(e); | console.log(e); | ||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| const msg = e instanceof Error ? e.message : String(e); | |||||
| if (msg.includes("USERNAME_NOT_AVAILABLE")) { | |||||
| const text = t("Username is already taken"); | |||||
| setServerError(text); | |||||
| formProps.setError("username", { message: text, type: "validate" }); | |||||
| } else if (msg.includes("NAME_NOT_AVAILABLE")) { | |||||
| const text = t("Name is already taken"); | |||||
| setServerError(text); | |||||
| formProps.setError("name", { message: text, type: "validate" }); | |||||
| } else if (msg.includes("STAFF_NO_NOT_AVAILABLE")) { | |||||
| const text = t("Staff No is already taken"); | |||||
| setServerError(text); | |||||
| formProps.setError("staffNo", { message: text, type: "validate" }); | |||||
| } else if (msg.includes("USER_WRONG_NEW_PWD")) { | |||||
| setServerError(t("New password does not meet the rules")); | |||||
| } else if (/\b400\b/.test(msg)) { | |||||
| setServerError(t("Invalid request. Please check your input")); | |||||
| } else if (/\b401\b/.test(msg) || /\b403\b/.test(msg)) { | |||||
| setServerError(t("Unauthorized or no permission")); | |||||
| } else if (/\b404\b/.test(msg)) { | |||||
| setServerError(t("User Not Found")); | |||||
| } else if (/\b500\b/.test(msg)) { | |||||
| setServerError(t("Server error. Please try again later")); | |||||
| } else { | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| } | |||||
| } | } | ||||
| }, | }, | ||||
| [router], | [router], | ||||
| @@ -212,7 +239,7 @@ const CreateUser: React.FC<Props> = ({ rules, auths }) => { | |||||
| label={t("User Detail")} | label={t("User Detail")} | ||||
| icon={ | icon={ | ||||
| hasErrorsInTab(0, errors) ? ( | hasErrorsInTab(0, errors) ? ( | ||||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||||
| <ErrorIcon sx={{ marginInlineEnd: 1 }} color="error" /> | |||||
| ) : undefined | ) : undefined | ||||
| } | } | ||||
| iconPosition="end" | iconPosition="end" | ||||
| @@ -35,6 +35,11 @@ const UserDetail: React.FC = () => { | |||||
| required: "username required!", | required: "username required!", | ||||
| })} | })} | ||||
| error={Boolean(errors.username)} | error={Boolean(errors.username)} | ||||
| helperText={ | |||||
| Boolean(errors.username) && errors.username?.message | |||||
| ? t(errors.username.message) | |||||
| : "" | |||||
| } | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| @@ -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 { | import { | ||||
| TraceGraphDetailField, | TraceGraphDetailField, | ||||
| TraceGraphDetailLabels, | TraceGraphDetailLabels, | ||||
| @@ -168,6 +168,11 @@ export interface TraceGraphNode { | |||||
| /** Raw status codes (for coloring). */ | /** Raw status codes (for coloring). */ | ||||
| processingStatus?: string; | processingStatus?: string; | ||||
| matchStatus?: string; | matchStatus?: string; | ||||
| /** Material-pick stock summary chips (JO pick cards). */ | |||||
| stockAvailableLabel?: string; | |||||
| stockStatusLabel?: string; | |||||
| bomReqQtyLabel?: string; | |||||
| stockReqQtyLabel?: string; | |||||
| } | } | ||||
| export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { | export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { | ||||
| @@ -255,6 +260,16 @@ export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { | |||||
| categoryTerminal: string; | categoryTerminal: string; | ||||
| processingStatus: string; | processingStatus: string; | ||||
| matchStatus: string; | matchStatus: string; | ||||
| detailBomReqQty: string; | |||||
| detailStockReqQty: string; | |||||
| detailStockAvailable: string; | |||||
| detailStockStatus: string; | |||||
| stockStatusSufficient: string; | |||||
| stockStatusInsufficient: string; | |||||
| /** Displayed when a JO pick-table field does not apply. */ | |||||
| na: string; | |||||
| /** Unpicked pick-order line (no stock-out yet). */ | |||||
| pendingPick: string; | |||||
| } | } | ||||
| const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => { | const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => { | ||||
| @@ -873,10 +888,7 @@ export const buildTraceGraphNodes = ( | |||||
| fieldIf(labels.detailRemarks, m.remarks), | fieldIf(labels.detailRemarks, m.remarks), | ||||
| ) | ) | ||||
| : detailsOf( | : detailsOf( | ||||
| field( | |||||
| labels.detailType, | |||||
| kind === "JO_CREATED" ? labels.nodeJoCreated : labels.tr.movementType(m.movementType), | |||||
| ), | |||||
| field(labels.detailType, labels.tr.movementType(m.movementType)), | |||||
| field(labels.detailDirection, labels.tr.direction(m.direction)), | field(labels.detailDirection, labels.tr.direction(m.direction)), | ||||
| field(labels.detailSourceDoc, m.refCode, { | field(labels.detailSourceDoc, m.refCode, { | ||||
| linkKind, | linkKind, | ||||
| @@ -60,7 +60,7 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||||
| /* | /* | ||||
| const handleAutoCompleteChange = useCallback( | const handleAutoCompleteChange = useCallback( | ||||
| (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | ||||
| console.log("BOM changed to:", value); | |||||
| // console.log("BOM changed to:", value); | |||||
| onChange(value.id); | onChange(value.id); | ||||
| // 重置倍数为 1 | // 重置倍数为 1 | ||||
| @@ -101,7 +101,7 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||||
| }, [bomCombo]); | }, [bomCombo]); | ||||
| const handleAutoCompleteChange = useCallback( | const handleAutoCompleteChange = useCallback( | ||||
| (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | ||||
| console.log("BOM changed to:", value); | |||||
| // console.log("BOM changed to:", value); | |||||
| onChange(value.id); | onChange(value.id); | ||||
| if (value.outputQty != null) { | if (value.outputQty != null) { | ||||
| @@ -272,13 +272,13 @@ const JoSearch: React.FC<Props> = ({ | |||||
| pageSize: pagingController.pageSize, | pageSize: pagingController.pageSize, | ||||
| }; | }; | ||||
| const response = await fetchJos(params); | const response = await fetchJos(params); | ||||
| console.log("newPageFetch params:", params) | |||||
| console.log("newPageFetch response:", response) | |||||
| // console.log("newPageFetch params:", params) | |||||
| // console.log("newPageFetch response:", response) | |||||
| if (response && response.records) { | if (response && response.records) { | ||||
| console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); | |||||
| // console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); | |||||
| setTotalCount(response.total); | setTotalCount(response.total); | ||||
| setFilteredJos(response.records); | setFilteredJos(response.records); | ||||
| console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); | |||||
| // console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); | |||||
| } else { | } else { | ||||
| console.warn("newPageFetch - no response or no records"); | console.warn("newPageFetch - no response or no records"); | ||||
| setFilteredJos([]); | setFilteredJos([]); | ||||
| @@ -273,13 +273,13 @@ const JoWorkbenchSearch: React.FC<Props> = ({ | |||||
| pageSize: pagingController.pageSize, | pageSize: pagingController.pageSize, | ||||
| }; | }; | ||||
| const response = await fetchJosForWorkbench(params); | const response = await fetchJosForWorkbench(params); | ||||
| console.log("newPageFetch params:", params) | |||||
| console.log("newPageFetch response:", response) | |||||
| // console.log("newPageFetch params:", params) | |||||
| // console.log("newPageFetch response:", response) | |||||
| if (response && response.records) { | if (response && response.records) { | ||||
| console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); | |||||
| // console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); | |||||
| setTotalCount(response.total); | setTotalCount(response.total); | ||||
| setFilteredJos(response.records); | setFilteredJos(response.records); | ||||
| console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); | |||||
| // console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); | |||||
| } else { | } else { | ||||
| console.warn("newPageFetch - no response or no records"); | console.warn("newPageFetch - no response or no records"); | ||||
| setFilteredJos([]); | setFilteredJos([]); | ||||
| @@ -618,7 +618,7 @@ const QrCodeModal: React.FC<{ | |||||
| ); | ); | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.1 | 2026-08-10 */ | |||||
| const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | ||||
| const workbenchMode = true; | const workbenchMode = true; | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| @@ -896,10 +896,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| data.pickOrderLines.forEach((line) => { | data.pickOrderLines.forEach((line) => { | ||||
| // 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次) | // 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次) | ||||
| const lotIdSet = new Set<number>(); | const lotIdSet = new Set<number>(); | ||||
| /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數),對齊 DO Workbench */ | |||||
| let lotsAllocatedSumForLine = 0; | |||||
| // lots:按 lotId 去重并合并 requiredQty(对齐 GoodPickExecutiondetail) | |||||
| // lots:按 lotId 去重并合并 requiredQty(对齐 DO Workbench / GoodPickExecutiondetail) | |||||
| if (line.lots && line.lots.length > 0) { | if (line.lots && line.lots.length > 0) { | ||||
| const lotMap = new Map<number, any>(); | const lotMap = new Map<number, any>(); | ||||
| @@ -916,7 +914,6 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| }); | }); | ||||
| lotMap.forEach((lot: any) => { | lotMap.forEach((lot: any) => { | ||||
| lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; | |||||
| if (lot.lotId != null) lotIdSet.add(lot.lotId); | if (lot.lotId != null) lotIdSet.add(lot.lotId); | ||||
| allLots.push({ | allLots.push({ | ||||
| @@ -945,20 +942,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| }); | }); | ||||
| } | } | ||||
| /** 工單 API 常在有揀貨後仍回傳 lots: [],缺口只在 stockouts;此時用非 noLot 的已揀量扣 POL(對齊實際剩餘) */ | |||||
| const stockoutsPickedSumNonNoLot = (line.stockouts ?? []).reduce( | |||||
| (acc: number, s: any) => { | |||||
| if (!s || s.noLot) return acc; | |||||
| return acc + (Number(s.qty) || 0); | |||||
| }, | |||||
| 0, | |||||
| ); | |||||
| const noLotRemainingBasis = | |||||
| lotsAllocatedSumForLine > 0 | |||||
| ? lotsAllocatedSumForLine | |||||
| : stockoutsPickedSumNonNoLot; | |||||
| // stockouts:用于“无 suggested lot / noLot”场景也显示并可 submit 0 闭环 | // stockouts:用于“无 suggested lot / noLot”场景也显示并可 submit 0 闭环 | ||||
| // 批號需求數:對齊 DO Workbench——用後端 stockout/SPL qty,不前端推 gap | |||||
| if (line.stockouts && line.stockouts.length > 0) { | if (line.stockouts && line.stockouts.length > 0) { | ||||
| line.stockouts.forEach((stockout: any) => { | line.stockouts.forEach((stockout: any) => { | ||||
| const hasLot = stockout.lotId != null; | const hasLot = stockout.lotId != null; | ||||
| @@ -970,6 +955,17 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| return; | return; | ||||
| } | } | ||||
| const stockoutRequiredQty = Number( | |||||
| stockout?.requiredQty ?? | |||||
| stockout?.suggestedPickQty ?? | |||||
| stockout?.suggestedPickLotQty, | |||||
| ); | |||||
| const effectiveStockoutRequiredQty = Number.isFinite( | |||||
| stockoutRequiredQty, | |||||
| ) | |||||
| ? stockoutRequiredQty | |||||
| : Number(line.requiredQty) || 0; | |||||
| allLots.push({ | allLots.push({ | ||||
| pickOrderLineId: line.id, | pickOrderLineId: line.id, | ||||
| itemId: line.itemId, | itemId: line.itemId, | ||||
| @@ -996,19 +992,13 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| expiryDate: null, | expiryDate: null, | ||||
| location: stockout.location || null, | location: stockout.location || null, | ||||
| availableQty: stockout.availableQty ?? 0, | availableQty: stockout.availableQty ?? 0, | ||||
| // 無批次列:有 SPL 時扣 suggested 合計;僅有 stockouts(lots 空)時扣已揀量(對齊 DO + workbench 僅 SOL 情境) | |||||
| requiredQty: stockout.noLot | |||||
| ? Math.max( | |||||
| 0, | |||||
| (Number(line.requiredQty) || 0) - noLotRemainingBasis, | |||||
| ) | |||||
| : Number(line.requiredQty) || 0, | |||||
| requiredQty: effectiveStockoutRequiredQty, | |||||
| actualPickQty: stockout.qty ?? 0, | actualPickQty: stockout.qty ?? 0, | ||||
| processingStatus: stockout.status || "pending", | processingStatus: stockout.status || "pending", | ||||
| lotAvailability: stockout.noLot | lotAvailability: stockout.noLot | ||||
| ? "insufficient_stock" | ? "insufficient_stock" | ||||
| : "available", | : "available", | ||||
| suggestedPickLotId: null, | |||||
| suggestedPickLotId: stockout.suggestedPickLotId ?? null, | |||||
| stockOutLineId: stockout.id || null, | stockOutLineId: stockout.id || null, | ||||
| stockOutLineQty: stockout.qty ?? 0, | stockOutLineQty: stockout.qty ?? 0, | ||||
| stockOutLineStatus: stockout.status || null, | stockOutLineStatus: stockout.status || null, | ||||
| @@ -3655,6 +3645,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| const isNoLotTailRow = (lot: any) => | const isNoLotTailRow = (lot: any) => | ||||
| lot.noLot === true || lot.lotId == null || lot.lotId === undefined; | lot.noLot === true || lot.lotId == null || lot.lotId === undefined; | ||||
| /** 同 POL 內對齊 DO Workbench:已掃/已完成在前,resuggest pending 在後 */ | |||||
| const statusRank = (lot: any) => { | |||||
| const st = String(lot?.stockOutLineStatus ?? "").toLowerCase(); | |||||
| if ( | |||||
| st === "completed" || | |||||
| st === "partially_completed" || | |||||
| st === "partially_complete" | |||||
| ) { | |||||
| return 0; | |||||
| } | |||||
| if (st === "checked") return 1; | |||||
| if (st === "pending") return 2; | |||||
| if (st === "rejected") return 3; | |||||
| return 9; | |||||
| }; | |||||
| const sortedData = [...sourceData].sort((a, b) => { | const sortedData = [...sourceData].sort((a, b) => { | ||||
| const efA = effectiveFloorOrder(a); | const efA = effectiveFloorOrder(a); | ||||
| const efB = effectiveFloorOrder(b); | const efB = effectiveFloorOrder(b); | ||||
| @@ -3674,6 +3680,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| const bName = String(b.itemName || ""); | const bName = String(b.itemName || ""); | ||||
| if (aName !== bName) return aName.localeCompare(bName); | if (aName !== bName) return aName.localeCompare(bName); | ||||
| const ra = statusRank(a); | |||||
| const rb = statusRank(b); | |||||
| if (ra !== rb) return ra - rb; | |||||
| const tailA = isNoLotTailRow(a) ? 1 : 0; | const tailA = isNoLotTailRow(a) ? 1 : 0; | ||||
| const tailB = isNoLotTailRow(b) ? 1 : 0; | const tailB = isNoLotTailRow(b) ? 1 : 0; | ||||
| if (tailA !== tailB) return tailA - tailB; | if (tailA !== tailB) return tailA - tailB; | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useEffect, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Alert, | Alert, | ||||
| Box, | Box, | ||||
| @@ -26,6 +26,7 @@ import { | |||||
| type LaserLastReceiveSuccess, | type LaserLastReceiveSuccess, | ||||
| JobOrderListItem, | JobOrderListItem, | ||||
| patchSetting, | patchSetting, | ||||
| expiryDateForLaserSend, | |||||
| sendLaserBag2Job, | sendLaserBag2Job, | ||||
| } from "@/app/api/laserPrint/actions"; | } from "@/app/api/laserPrint/actions"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -89,6 +90,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| const [settingsLoaded, setSettingsLoaded] = useState(false); | const [settingsLoaded, setSettingsLoaded] = useState(false); | ||||
| const [printerConnected, setPrinterConnected] = useState(false); | const [printerConnected, setPrinterConnected] = useState(false); | ||||
| const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); | const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); | ||||
| const sendInFlightRef = useRef(false); | |||||
| const loadSystemSettings = useCallback(async () => { | const loadSystemSettings = useCallback(async () => { | ||||
| try { | try { | ||||
| @@ -191,31 +193,38 @@ const LaserPrintSearch: React.FC = () => { | |||||
| jobOrderId: jo.id, | jobOrderId: jo.id, | ||||
| jobOrderNo: jo.code, | jobOrderNo: jo.code, | ||||
| lotNo: jo.lotNo, | lotNo: jo.lotNo, | ||||
| expiryDate: expiryDateForLaserSend(jo.expiryDate), | |||||
| source: "MANUAL", | source: "MANUAL", | ||||
| }); | }); | ||||
| const handleRowClick = async (jo: JobOrderListItem) => { | const handleRowClick = async (jo: JobOrderListItem) => { | ||||
| if (sendingJobId !== null) return; | |||||
| if (sendInFlightRef.current || sendingJobId !== null) return; | |||||
| if (!laserHost.trim()) { | if (!laserHost.trim()) { | ||||
| setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" }); | setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" }); | ||||
| return; | return; | ||||
| } | } | ||||
| sendInFlightRef.current = true; | |||||
| setSelectedId(jo.id); | setSelectedId(jo.id); | ||||
| setSendingJobId(jo.id); | setSendingJobId(jo.id); | ||||
| try { | try { | ||||
| let lastAck: string | undefined; | let lastAck: string | undefined; | ||||
| let anyReceiveAck = false; | let anyReceiveAck = false; | ||||
| let sentOk = 0; | |||||
| let laterFail: string | null = null; | |||||
| for (let i = 0; i < LASER_SEND_COUNT; i++) { | for (let i = 0; i < LASER_SEND_COUNT; i++) { | ||||
| const r = await sendOne(jo); | const r = await sendOne(jo); | ||||
| if (!r.success) { | if (!r.success) { | ||||
| setErrorSnackbar({ | |||||
| open: true, | |||||
| message: r.message || "檸檬機(激光機)未收到指令", | |||||
| }); | |||||
| return; | |||||
| const failMsg = r.message?.trim() || `第 ${i + 1} 次送出失敗`; | |||||
| if (sentOk === 0) { | |||||
| setErrorSnackbar({ open: true, message: failMsg }); | |||||
| return; | |||||
| } | |||||
| laterFail = failMsg; | |||||
| break; | |||||
| } | } | ||||
| sentOk += 1; | |||||
| if (r.printerAck) lastAck = r.printerAck; | if (r.printerAck) lastAck = r.printerAck; | ||||
| if (r.receiveAcknowledged) anyReceiveAck = true; | if (r.receiveAcknowledged) anyReceiveAck = true; | ||||
| if (i < LASER_SEND_COUNT - 1) { | if (i < LASER_SEND_COUNT - 1) { | ||||
| @@ -228,7 +237,11 @@ const LaserPrintSearch: React.FC = () => { | |||||
| : lastAck | : lastAck | ||||
| ? `(最後回覆:${lastAck})` | ? `(最後回覆:${lastAck})` | ||||
| : ""; | : ""; | ||||
| setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)${ackHint}`); | |||||
| setSuccessSignal( | |||||
| laterFail | |||||
| ? `已送出 ${sentOk} 次至檸檬機(激光機)${ackHint}(後續重送失敗:${laterFail})` | |||||
| : `已送出 ${sentOk} 次至檸檬機(激光機)${ackHint}`, | |||||
| ); | |||||
| await loadSystemSettings(); | await loadSystemSettings(); | ||||
| } catch (e) { | } catch (e) { | ||||
| setErrorSnackbar({ | setErrorSnackbar({ | ||||
| @@ -237,6 +250,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| setSendingJobId(null); | setSendingJobId(null); | ||||
| sendInFlightRef.current = false; | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -268,7 +282,12 @@ const LaserPrintSearch: React.FC = () => { | |||||
| {settingsLoaded && lastLaserReceive && ( | {settingsLoaded && lastLaserReceive && ( | ||||
| <Alert severity="info" sx={{ mb: 2 }}> | <Alert severity="info" sx={{ mb: 2 }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} {formatHongKongDateTime(lastLaserReceive.sentAt)} | |||||
| 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} | |||||
| {formatHongKongDateTime(lastLaserReceive.sentAt)} | |||||
| {lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""} | |||||
| </Typography> | |||||
| <Typography variant="body2" sx={{ mt: 0.5 }}> | |||||
| 此時間只會在檸檬機回覆 receive 時更新。之後送出失敗不會改這裡。 | |||||
| </Typography> | </Typography> | ||||
| </Alert> | </Alert> | ||||
| )} | )} | ||||
| @@ -40,6 +40,7 @@ import UploadFile from "@mui/icons-material/UploadFile"; | |||||
| import Sync from "@mui/icons-material/Sync"; | import Sync from "@mui/icons-material/Sync"; | ||||
| import Layers from "@mui/icons-material/Layers"; | import Layers from "@mui/icons-material/Layers"; | ||||
| import Devices from "@mui/icons-material/Devices"; | import Devices from "@mui/icons-material/Devices"; | ||||
| import EventAvailable from "@mui/icons-material/EventAvailable"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| @@ -61,6 +62,7 @@ interface NavigationItem { | |||||
| requiredAbility?: string | string[]; | requiredAbility?: string | string[]; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ | |||||
| const NavigationContent: React.FC = () => { | const NavigationContent: React.FC = () => { | ||||
| const { data: session, status } = useSession(); | const { data: session, status } = useSession(); | ||||
| const abilities = session?.user?.abilities ?? []; | const abilities = session?.user?.abilities ?? []; | ||||
| @@ -241,7 +243,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Sync />, | icon: <Sync />, | ||||
| labelKey: "nav.m18Sync", | labelKey: "nav.m18Sync", | ||||
| path: "/m18Syn", | path: "/m18Syn", | ||||
| requiredAbility: [AUTH.ADMIN], | |||||
| requiredAbility: [AUTH.M18_SYNC, AUTH.ADMIN], | |||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -324,6 +326,12 @@ const NavigationContent: React.FC = () => { | |||||
| labelKey: "nav.settings.items", | labelKey: "nav.settings.items", | ||||
| path: "/settings/items", | path: "/settings/items", | ||||
| }, | }, | ||||
| { | |||||
| id: "nav.settings.itemDefaultShelfLife", | |||||
| icon: <EventAvailable />, | |||||
| labelKey: "nav.settings.itemDefaultShelfLife", | |||||
| path: "/settings/itemDefaultShelfLife", | |||||
| }, | |||||
| { | { | ||||
| id: "nav.settings.equipment", | id: "nav.settings.equipment", | ||||
| icon: <Build />, | icon: <Build />, | ||||
| @@ -228,6 +228,16 @@ const isCheckedStatus = (status: string | undefined): boolean => | |||||
| const isRejectedStatus = (status: string | undefined): boolean => | const isRejectedStatus = (status: string | undefined): boolean => | ||||
| String(status || "").toLowerCase() === "rejected"; | String(status || "").toLowerCase() === "rejected"; | ||||
| const isPendingSolStatus = (status: string | undefined): boolean => { | |||||
| const s = String(status || "").toLowerCase(); | |||||
| return ( | |||||
| s === "pending" || | |||||
| s === "partially_completed" || | |||||
| s === "partially_complete" || | |||||
| s === "" | |||||
| ); | |||||
| }; | |||||
| function safeDisplayTargetDate(targetDate: string | number[]): string { | function safeDisplayTargetDate(targetDate: string | number[]): string { | ||||
| try { | try { | ||||
| if (Array.isArray(targetDate) && targetDate.length >= 3) { | if (Array.isArray(targetDate) && targetDate.length >= 3) { | ||||
| @@ -346,7 +356,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||||
| }); | }); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.3 | 2026-08-13 */ | |||||
| const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -1221,8 +1231,35 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| releaseProcessedQr(latest); | releaseProcessedQr(latest); | ||||
| return; | return; | ||||
| } | } | ||||
| const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom; | |||||
| let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0]; | |||||
| // Align DO/JO: when no active suggested lot, bind to pending noLot / unavailable | |||||
| // rows instead of a completed row that still has lotNo. | |||||
| let expectedRow: LotRow | undefined; | |||||
| if (activeForUom.length > 0) { | |||||
| expectedRow = pickExpectedRowForSubstitution(activeForUom) || allForUom[0]; | |||||
| } else { | |||||
| const switchable = allForUom.find( | |||||
| (r) => | |||||
| r.stockOutLineId > 0 && | |||||
| isPendingSolStatus(r.status) && | |||||
| !isCompletedStatus(r.status) && | |||||
| !isCheckedStatus(r.status) && | |||||
| (isNoLotWorkbenchRow(r) || | |||||
| isRejectedStatus(r.status) || | |||||
| isInventoryLotLineUnavailable(r) || | |||||
| isLotAvailabilityExpired(r)), | |||||
| ); | |||||
| expectedRow = | |||||
| switchable || | |||||
| allForUom.find( | |||||
| (r) => | |||||
| r.stockOutLineId > 0 && | |||||
| isPendingSolStatus(r.status) && | |||||
| !isCompletedStatus(r.status) && | |||||
| !isCheckedStatus(r.status), | |||||
| ) || | |||||
| pickExpectedRowForSubstitution(allForUom) || | |||||
| allForUom[0]; | |||||
| } | |||||
| if (!expectedRow) { | if (!expectedRow) { | ||||
| setError(t("Scanned item is not found in current line")); | setError(t("Scanned item is not found in current line")); | ||||
| startTransition(() => { | startTransition(() => { | ||||
| @@ -60,6 +60,7 @@ import { | |||||
| useContext, | useContext, | ||||
| useEffect, | useEffect, | ||||
| useMemo, | useMemo, | ||||
| useRef, | |||||
| useState, | useState, | ||||
| } from "react"; | } from "react"; | ||||
| import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; | import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; | ||||
| @@ -252,7 +253,7 @@ interface PolInputResult { | |||||
| dnQty: string, | dnQty: string, | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||||
| const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | ||||
| const cameras = useContext(CameraContext); | const cameras = useContext(CameraContext); | ||||
| const { data: session } = useSession(); | const { data: session } = useSession(); | ||||
| @@ -302,16 +303,43 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| const [selectedRow, setSelectedRow] = useState<PurchaseOrderLine | null>(null); | const [selectedRow, setSelectedRow] = useState<PurchaseOrderLine | null>(null); | ||||
| const [stockInLine, setStockInLine] = useState<StockInLine[]>([]); | const [stockInLine, setStockInLine] = useState<StockInLine[]>([]); | ||||
| const [processedQty, setProcessedQty] = useState(0); | const [processedQty, setProcessedQty] = useState(0); | ||||
| /** Tracks user/nav selection so query patches via history.replaceState stay authoritative. */ | |||||
| const selectedPolIdRef = useRef<number | null>(null); | |||||
| /** Patch PO edit query without Next soft-navigation (avoids scroll-to-top). */ | |||||
| const patchPoEditQuery = useCallback( | |||||
| (mutate: (params: URLSearchParams) => void) => { | |||||
| if (typeof window === "undefined") return; | |||||
| const params = new URLSearchParams(window.location.search); | |||||
| mutate(params); | |||||
| const qs = params.toString(); | |||||
| window.history.replaceState( | |||||
| window.history.state, | |||||
| "", | |||||
| qs ? `${pathname}?${qs}` : pathname, | |||||
| ); | |||||
| }, | |||||
| [pathname], | |||||
| ); | |||||
| /** Keep selection + bottom stock-in grid in sync with selected / URL `polId`. */ | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const polIdParam = searchParams.get("polId"); | |||||
| if (!polIdParam || rows.length === 0) return; | |||||
| const match = rows.find((r) => r.id.toString() === polIdParam); | |||||
| if (match) { | |||||
| setSelectedRow(match); | |||||
| setStockInLine(match.stockInLine); | |||||
| setProcessedQty(match.processed); | |||||
| } | |||||
| if (rows.length === 0) return; | |||||
| const urlPolId = searchParams.get("polId"); | |||||
| const preferredId = | |||||
| selectedPolIdRef.current ?? | |||||
| (urlPolId != null ? Number(urlPolId) : null); | |||||
| if (preferredId == null || Number.isNaN(preferredId)) return; | |||||
| const match = | |||||
| rows.find((r) => r.id === preferredId) ?? | |||||
| (urlPolId != null | |||||
| ? rows.find((r) => r.id.toString() === urlPolId) | |||||
| : undefined); | |||||
| if (!match) return; | |||||
| selectedPolIdRef.current = match.id; | |||||
| setSelectedRow(match); | |||||
| setStockInLine(match.stockInLine ?? []); | |||||
| setProcessedQty(match.processed); | |||||
| }, [rows, searchParams]); | }, [rows, searchParams]); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -465,9 +493,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| }); | }); | ||||
| setRows(result.pol || []); | setRows(result.pol || []); | ||||
| if (result.pol && result.pol.length > 0) { | if (result.pol && result.pol.length > 0) { | ||||
| const targetPolId = preferredPolId ?? selectedRow?.id; | |||||
| const targetPolId = preferredPolId ?? selectedPolIdRef.current ?? selectedRow?.id; | |||||
| const targetPol = | const targetPol = | ||||
| result.pol.find((p) => p.id === targetPolId) ?? result.pol[0]; | result.pol.find((p) => p.id === targetPolId) ?? result.pol[0]; | ||||
| selectedPolIdRef.current = targetPol.id; | |||||
| setSelectedRow(targetPol); | setSelectedRow(targetPol); | ||||
| setStockInLine(targetPol.stockInLine); | setStockInLine(targetPol.stockInLine); | ||||
| setProcessedQty(targetPol.processed); | setProcessedQty(targetPol.processed); | ||||
| @@ -482,6 +511,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| const handlePoSelect = useCallback( | const handlePoSelect = useCallback( | ||||
| async (selectedPo: PoResult) => { | async (selectedPo: PoResult) => { | ||||
| if (selectedPo.id === selectedPoId) return; | if (selectedPo.id === selectedPoId) return; | ||||
| selectedPolIdRef.current = null; | |||||
| setSelectedPoId(selectedPo.id); | setSelectedPoId(selectedPo.id); | ||||
| await fetchPoDetail(selectedPo.id.toString()); | await fetchPoDetail(selectedPo.id.toString()); | ||||
| const newSelectedIds = selectedIdsParam || selectedPo.id.toString(); | const newSelectedIds = selectedIdsParam || selectedPo.id.toString(); | ||||
| @@ -570,13 +600,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| () => returnWeightUnit(row.uom), | () => returnWeightUnit(row.uom), | ||||
| [row.uom], | [row.uom], | ||||
| ); | ); | ||||
| useEffect(() => { | |||||
| const polId = searchParams.get("polId") != null ? parseInt(searchParams.get("polId")!) : null | |||||
| if (polId) { | |||||
| setStockInLine(rows.find((r) => r.id == polId)!.stockInLine) | |||||
| } | |||||
| }, []); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| // `processedQty` comes from putAwayLines (stock unit). | // `processedQty` comes from putAwayLines (stock unit). | ||||
| // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. | // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. | ||||
| @@ -595,23 +618,22 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); | setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); | ||||
| }, [polInputList, row.id]); | }, [polInputList, row.id]); | ||||
| const handleRowSelect = () => { | |||||
| // setSelectedRowId(row.id); | |||||
| setSelectedRow(row); | |||||
| setStockInLine(row.stockInLine); | |||||
| setProcessedQty(row.processed); | |||||
| }; | |||||
| const changeStockInLines = useCallback( | const changeStockInLines = useCallback( | ||||
| (id: number) => { | (id: number) => { | ||||
| //rows = purchaseOrderLine | |||||
| const target = rows.find((r) => r.id === id) | |||||
| const stockInLine = target!.stockInLine | |||||
| setStockInLine(stockInLine) | |||||
| setSelectedRow(target!) | |||||
| // console.log(pathname) | |||||
| // router.replace(`/po/edit?id=${item.poId}&polId=${item.polId}&stockInLineId=${item.stockInLineId}`); | |||||
| const target = rows.find((r) => r.id === id); | |||||
| if (!target) return; | |||||
| selectedPolIdRef.current = id; | |||||
| setSelectedRow(target); | |||||
| setStockInLine(target.stockInLine ?? []); | |||||
| setProcessedQty(target.processed); | |||||
| // history.replaceState: keep URL in sync without scrolling to top | |||||
| patchPoEditQuery((params) => { | |||||
| params.set("polId", String(id)); | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| }, | }, | ||||
| [rows] | |||||
| [rows, patchPoEditQuery], | |||||
| ); | ); | ||||
| const handleStart = useCallback( | const handleStart = useCallback( | ||||
| @@ -644,7 +666,12 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| ...prev, | ...prev, | ||||
| [row.id]: { lotNo: "", dnQty: "" }, | [row.id]: { lotNo: "", dnQty: "" }, | ||||
| })); | })); | ||||
| selectedPolIdRef.current = row.id; | |||||
| setSelectedRow(row); | setSelectedRow(row); | ||||
| patchPoEditQuery((params) => { | |||||
| params.set("polId", String(row.id)); | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| fetchPoDetail(selectedPoId.toString(), true, row.id); | fetchPoDetail(selectedPoId.toString(), true, row.id); | ||||
| } | } | ||||
| console.log(res); | console.log(res); | ||||
| @@ -662,7 +689,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| doSubmit(); | doSubmit(); | ||||
| } | } | ||||
| }, | }, | ||||
| [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput], | |||||
| [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput, patchPoEditQuery], | |||||
| ); | ); | ||||
| const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { | const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { | ||||
| @@ -746,8 +773,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| )} | )} | ||||
| <Radio | <Radio | ||||
| checked={selectedRow?.id === row.id} | checked={selectedRow?.id === row.id} | ||||
| // onChange={handleRowSelect} | |||||
| // onClick={(e) => e.stopPropagation()} | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="left" sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemNo}> | <TableCell align="left" sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemNo}> | ||||
| @@ -36,7 +36,7 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | |||||
| import { PurchaseOrderLine } from "@/app/api/po"; | import { PurchaseOrderLine } from "@/app/api/po"; | ||||
| import { StockInLine } from "@/app/api/stockIn"; | import { StockInLine } from "@/app/api/stockIn"; | ||||
| import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions"; | import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions"; | ||||
| import { usePathname, useRouter, useSearchParams } from "next/navigation"; | |||||
| import { usePathname, useSearchParams } from "next/navigation"; | |||||
| import { | import { | ||||
| returnWeightUnit, | returnWeightUnit, | ||||
| calculateWeight, | calculateWeight, | ||||
| @@ -170,6 +170,7 @@ class ProcessRowUpdateError extends Error { | |||||
| } | } | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||||
| function PoInputGrid({ | function PoInputGrid({ | ||||
| // qc, | // qc, | ||||
| setRows, | setRows, | ||||
| @@ -204,7 +205,6 @@ function PoInputGrid({ | |||||
| StockInLine & { qcResult?: QcResult[] } & { escalationResult?: EscalationResult[] } | StockInLine & { qcResult?: QcResult[] } & { escalationResult?: EscalationResult[] } | ||||
| >(); | >(); | ||||
| const pathname = usePathname() | const pathname = usePathname() | ||||
| const router = useRouter(); | |||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| const [qcOpen, setQcOpen] = useState(false); | const [qcOpen, setQcOpen] = useState(false); | ||||
| @@ -384,15 +384,35 @@ function PoInputGrid({ | |||||
| // ); | // ); | ||||
| const [newOpen, setNewOpen] = useState(false); | const [newOpen, setNewOpen] = useState(false); | ||||
| const stockInLineId = searchParams.get("stockInLineId"); | |||||
| const stockInLineIdFromNext = searchParams.get("stockInLineId"); | |||||
| const poLineId = searchParams.get("poLineId"); | const poLineId = searchParams.get("poLineId"); | ||||
| const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => { | |||||
| const newParams = new URLSearchParams(searchParams.toString()); | |||||
| newParams.delete("stockInLineId"); | |||||
| const patchQuery = useCallback( | |||||
| (mutate: (params: URLSearchParams) => void) => { | |||||
| if (typeof window === "undefined") return; | |||||
| const params = new URLSearchParams(window.location.search); | |||||
| mutate(params); | |||||
| const qs = params.toString(); | |||||
| window.history.replaceState( | |||||
| window.history.state, | |||||
| "", | |||||
| qs ? `${pathname}?${qs}` : pathname, | |||||
| ); | |||||
| }, | |||||
| [pathname], | |||||
| ); | |||||
| const getLiveStockInLineId = useCallback((): string | null => { | |||||
| if (typeof window !== "undefined") { | if (typeof window !== "undefined") { | ||||
| window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`); | |||||
| return new URLSearchParams(window.location.search).get("stockInLineId"); | |||||
| } | } | ||||
| return stockInLineIdFromNext; | |||||
| }, [stockInLineIdFromNext]); | |||||
| const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => { | |||||
| patchQuery((params) => { | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| setNewOpen(false); | setNewOpen(false); | ||||
| if (updatedStockInLine?.id != null) { | if (updatedStockInLine?.id != null) { | ||||
| @@ -403,7 +423,7 @@ function PoInputGrid({ | |||||
| (prev || []).map((p) => (p.id === updatedStockInLine.id ? { ...p, ...updatedStockInLine } : p)) | (prev || []).map((p) => (p.id === updatedStockInLine.id ? { ...p, ...updatedStockInLine } : p)) | ||||
| ); | ); | ||||
| } | } | ||||
| }, [pathname, searchParams]); | |||||
| }, [patchQuery, setStockInLine]); | |||||
| // Open modal | // Open modal | ||||
| const openNewModal = useCallback(() => { | const openNewModal = useCallback(() => { | ||||
| @@ -413,42 +433,67 @@ function PoInputGrid({ | |||||
| // Button handler to update the URL and open the modal | // Button handler to update the URL and open the modal | ||||
| const handleNewQC = useCallback( | const handleNewQC = useCallback( | ||||
| (id: GridRowId, params: any) => async() => { | (id: GridRowId, params: any) => async() => { | ||||
| // setBtnIsLoading(true); | |||||
| if (!params?.row) return; | |||||
| setRowModesModel((prev) => ({ | setRowModesModel((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| [id]: { mode: GridRowModes.View }, | [id]: { mode: GridRowModes.View }, | ||||
| })); | })); | ||||
| // const qcResult = await fetchQcDefaultValue(id); | |||||
| // const escResult = await fetchEscalationLogsByStockInLines([Number(id)]); | |||||
| setModalInfo(() => ({ | setModalInfo(() => ({ | ||||
| ...params.row, | ...params.row, | ||||
| // qcResult: qcResult, | |||||
| // escResult: escResult, | |||||
| receivedQty: itemDetail.receivedQty, | receivedQty: itemDetail.receivedQty, | ||||
| })); | })); | ||||
| const newParams = new URLSearchParams(searchParams.toString()); | |||||
| newParams.set("stockInLineId", id.toString()); // Ensure `set` to avoid duplicates | |||||
| router.replace(`${pathname}?${newParams.toString()}`); | |||||
| openNewModal() | |||||
| // setTimeout(() => { | |||||
| // }, 200); | |||||
| // Avoid router.replace — it scrolls the page to top | |||||
| patchQuery((params) => { | |||||
| params.set("stockInLineId", id.toString()); | |||||
| }); | |||||
| openNewModal(); | |||||
| }, | }, | ||||
| [openNewModal, pathname, router, searchParams] | |||||
| [openNewModal, patchQuery, itemDetail.receivedQty], | |||||
| ); | ); | ||||
| // Open modal if `stockInLineId` exists in the URL | |||||
| const [firstCheckForSil, setFirstCheckForSil] = useState(false) | |||||
| // Open modal if `stockInLineId` exists in the live URL (and belongs to current grid) | |||||
| const [firstCheckForSil, setFirstCheckForSil] = useState(false); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (stockInLineId && itemDetail && !firstCheckForSil) { | |||||
| // console.log(stockInLineId) | |||||
| // console.log(apiRef.current.getRow(stockInLineId)) | |||||
| setFirstCheckForSil(true) | |||||
| const fn = handleNewQC(stockInLineId, {row: apiRef.current.getRow(stockInLineId)}); | |||||
| fn(); | |||||
| setFirstCheckForSil(false); | |||||
| }, [itemDetail.id]); | |||||
| useEffect(() => { | |||||
| if (!itemDetail || firstCheckForSil) return; | |||||
| const liveStockInLineId = getLiveStockInLineId(); | |||||
| if (!liveStockInLineId) { | |||||
| setFirstCheckForSil(true); | |||||
| return; | |||||
| } | |||||
| const row = apiRef.current.getRow(Number(liveStockInLineId)); | |||||
| if (!row) { | |||||
| // Stale query from another POL: drop it once current entries are known | |||||
| if ( | |||||
| entries.length > 0 && | |||||
| !entries.some((e) => String(e.id) === String(liveStockInLineId)) | |||||
| ) { | |||||
| patchQuery((params) => { | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| setFirstCheckForSil(true); | |||||
| } | |||||
| return; | |||||
| } | } | ||||
| }, [stockInLineId, poLineId, itemDetail]); | |||||
| setFirstCheckForSil(true); | |||||
| void handleNewQC(liveStockInLineId, { row })(); | |||||
| }, [ | |||||
| stockInLineIdFromNext, | |||||
| poLineId, | |||||
| itemDetail, | |||||
| firstCheckForSil, | |||||
| entries, | |||||
| handleNewQC, | |||||
| getLiveStockInLineId, | |||||
| patchQuery, | |||||
| ]); | |||||
| const handleEscalation = useCallback( | const handleEscalation = useCallback( | ||||
| (id: GridRowId, params: any) => () => { | (id: GridRowId, params: any) => () => { | ||||
| // setBtnIsLoading(true); | // setBtnIsLoading(true); | ||||
| @@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||||
| interface Props extends CommonProps { | interface Props extends CommonProps { | ||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||||
| const PoQcStockInModalVer2: React.FC<Props> = ({ | const PoQcStockInModalVer2: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -1,5 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useState, useEffect, useCallback, useRef } from "react"; | |||||
| import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Typography, | Typography, | ||||
| @@ -37,11 +37,17 @@ import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { | import { | ||||
| fetchDrinkProductionQty, | fetchDrinkProductionQty, | ||||
| fetchDrinkShipmentQty, | |||||
| DrinkProductionQtyResponse, | DrinkProductionQtyResponse, | ||||
| DrinkProductionQtyJobOrderDetail, | DrinkProductionQtyJobOrderDetail, | ||||
| DrinkShipmentQtyResponse, | |||||
| DrinkShipmentQtyDeliveryDetail, | |||||
| } from "@/app/api/jo/actions"; | } from "@/app/api/jo/actions"; | ||||
| import { arrayToDayjs } from "@/app/utils/formatUtil"; | import { arrayToDayjs } from "@/app/utils/formatUtil"; | ||||
| import { exportDrinkProductionQtyXlsx } from "@/components/ProductionProcess/exportDrinkProductionQtyXlsx"; | |||||
| import { | |||||
| exportDrinkProductionQtyXlsx, | |||||
| type DrinkViewMode, | |||||
| } from "@/components/ProductionProcess/exportDrinkProductionQtyXlsx"; | |||||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 | const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 | ||||
| @@ -55,8 +61,6 @@ const JO_STATUS_FILTER_VALUES = [ | |||||
| "completed", | "completed", | ||||
| ] as const; | ] as const; | ||||
| type DrinkViewMode = "actual" | "planned"; | |||||
| const formatQty = (qty: number | null | undefined): string => { | const formatQty = (qty: number | null | undefined): string => { | ||||
| if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; | if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; | ||||
| return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); | return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); | ||||
| @@ -100,10 +104,16 @@ const ProcessSummaryTimeText: React.FC<{ value: unknown }> = ({ value }) => { | |||||
| const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => | const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => | ||||
| `${row.itemCode || "unknown"}-${idx}`; | `${row.itemCode || "unknown"}-${idx}`; | ||||
| const getShipmentRowKey = (row: DrinkShipmentQtyResponse, idx: number): string => | |||||
| `ship-${row.itemCode || "unknown"}-${idx}`; | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | ||||
| const DrinkProductionQtyDashboard: React.FC = () => { | const DrinkProductionQtyDashboard: React.FC = () => { | ||||
| const { t } = useTranslation(["common", "jo", "productionProcess"]); | |||||
| const { t } = useTranslation(["common", "jo", "do", "productionProcess"]); | |||||
| const [data, setData] = useState<DrinkProductionQtyResponse[]>([]); | const [data, setData] = useState<DrinkProductionQtyResponse[]>([]); | ||||
| const [shipmentData, setShipmentData] = useState<DrinkShipmentQtyResponse[]>( | |||||
| [], | |||||
| ); | |||||
| const [loading, setLoading] = useState<boolean>(true); | const [loading, setLoading] = useState<boolean>(true); | ||||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | ||||
| const [joStatusFilter, setJoStatusFilter] = useState<string>(""); | const [joStatusFilter, setJoStatusFilter] = useState<string>(""); | ||||
| @@ -117,21 +127,62 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| ); | ); | ||||
| const isPlanned = viewMode === "planned"; | const isPlanned = viewMode === "planned"; | ||||
| const isShipment = viewMode === "shipment"; | |||||
| const qtyHeaders = ((): { left: string; right: string } => { | |||||
| switch (viewMode) { | |||||
| case "shipment": | |||||
| return { | |||||
| left: t("Shipment Order Qty"), | |||||
| right: t("Shipped Qty"), | |||||
| }; | |||||
| case "planned": | |||||
| return { | |||||
| left: t("Planned Output Qty"), | |||||
| right: t("Actual Output Qty"), | |||||
| }; | |||||
| case "actual": | |||||
| return { | |||||
| left: t("Stock Req. Qty"), | |||||
| right: t("Production Qty"), | |||||
| }; | |||||
| default: { | |||||
| const _exhaustive: never = viewMode; | |||||
| return _exhaustive; | |||||
| } | |||||
| } | |||||
| })(); | |||||
| const loadData = useCallback(async () => { | const loadData = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| const dateStr = selectedDate.format("YYYY-MM-DD"); | |||||
| try { | try { | ||||
| const result = await fetchDrinkProductionQty( | |||||
| selectedDate.format("YYYY-MM-DD"), | |||||
| viewMode, | |||||
| ); | |||||
| setData(result || []); | |||||
| switch (viewMode) { | |||||
| case "shipment": { | |||||
| const result = await fetchDrinkShipmentQty(dateStr); | |||||
| setShipmentData(result || []); | |||||
| setData([]); | |||||
| break; | |||||
| } | |||||
| case "actual": | |||||
| case "planned": { | |||||
| const result = await fetchDrinkProductionQty(dateStr, viewMode); | |||||
| setData(result || []); | |||||
| setShipmentData([]); | |||||
| break; | |||||
| } | |||||
| default: { | |||||
| const _exhaustive: never = viewMode; | |||||
| return _exhaustive; | |||||
| } | |||||
| } | |||||
| setExpandedRowKeys(new Set()); | setExpandedRowKeys(new Set()); | ||||
| setLastDataRefreshTime(dayjs()); | setLastDataRefreshTime(dayjs()); | ||||
| refreshCountRef.current += 1; | refreshCountRef.current += 1; | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error fetching drink production qty:", error); | console.error("Error fetching drink production qty:", error); | ||||
| setData([]); | setData([]); | ||||
| setShipmentData([]); | |||||
| setExpandedRowKeys(new Set()); | setExpandedRowKeys(new Set()); | ||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| @@ -158,12 +209,32 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| }); | }); | ||||
| }; | }; | ||||
| const filterJobOrders = ( | |||||
| jobOrders: DrinkProductionQtyJobOrderDetail[], | |||||
| ): DrinkProductionQtyJobOrderDetail[] => { | |||||
| if (!joStatusFilter) return jobOrders; | |||||
| return jobOrders.filter((jo) => jo.jobOrderStatus === joStatusFilter); | |||||
| }; | |||||
| const filteredItemRows = useMemo(() => { | |||||
| const rows: Array<{ | |||||
| row: DrinkProductionQtyResponse; | |||||
| jobOrders: DrinkProductionQtyJobOrderDetail[]; | |||||
| totalReqQty: number; | |||||
| totalQty: number; | |||||
| }> = []; | |||||
| for (const row of data) { | |||||
| const allJobOrders = row.jobOrders ?? []; | |||||
| const jobOrders = joStatusFilter | |||||
| ? allJobOrders.filter((jo) => jo.jobOrderStatus === joStatusFilter) | |||||
| : allJobOrders; | |||||
| if (joStatusFilter && jobOrders.length === 0) continue; | |||||
| rows.push({ | |||||
| row, | |||||
| jobOrders, | |||||
| totalReqQty: joStatusFilter | |||||
| ? jobOrders.reduce((sum, jo) => sum + (jo.reqQty ?? 0), 0) | |||||
| : row.totalReqQty, | |||||
| totalQty: joStatusFilter | |||||
| ? jobOrders.reduce((sum, jo) => sum + (jo.productionQty ?? 0), 0) | |||||
| : row.totalQty, | |||||
| }); | |||||
| } | |||||
| return rows; | |||||
| }, [data, joStatusFilter]); | |||||
| const renderActualEnd = (jo: DrinkProductionQtyJobOrderDetail) => { | const renderActualEnd = (jo: DrinkProductionQtyJobOrderDetail) => { | ||||
| const start = parseProcessTime(jo.startTime); | const start = parseProcessTime(jo.startTime); | ||||
| @@ -190,6 +261,33 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| ); | ); | ||||
| }; | }; | ||||
| const renderDoStatusChip = (status: string | null | undefined) => { | |||||
| if (!status) return <>—</>; | |||||
| const normalized = status.toLowerCase(); | |||||
| let label: string; | |||||
| switch (normalized) { | |||||
| case "pending": | |||||
| label = t("Drink do status pending"); | |||||
| break; | |||||
| case "receiving": | |||||
| label = t("Drink do status receiving"); | |||||
| break; | |||||
| case "completed": | |||||
| label = t("Drink do status completed"); | |||||
| break; | |||||
| default: | |||||
| label = t(status, { ns: "do", defaultValue: status }); | |||||
| break; | |||||
| } | |||||
| return ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={label} | |||||
| sx={{ height: 22 }} | |||||
| /> | |||||
| ); | |||||
| }; | |||||
| return ( | return ( | ||||
| <Card sx={{ mb: 2 }}> | <Card sx={{ mb: 2 }}> | ||||
| <CardContent> | <CardContent> | ||||
| @@ -216,29 +314,31 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| /> | /> | ||||
| </LocalizationProvider> | </LocalizationProvider> | ||||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||||
| <InputLabel id="drink-jo-status-filter-label"> | |||||
| {t("Job Order Status")} | |||||
| </InputLabel> | |||||
| <Select | |||||
| labelId="drink-jo-status-filter-label" | |||||
| id="drink-jo-status-filter" | |||||
| label={t("Job Order Status")} | |||||
| value={joStatusFilter} | |||||
| onChange={(e) => { | |||||
| setJoStatusFilter(String(e.target.value)); | |||||
| }} | |||||
| > | |||||
| <MenuItem value=""> | |||||
| <em>{t("All")}</em> | |||||
| </MenuItem> | |||||
| {JO_STATUS_FILTER_VALUES.map((v) => ( | |||||
| <MenuItem key={v} value={v}> | |||||
| {t(v, { ns: "jo" })} | |||||
| {!isShipment && ( | |||||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||||
| <InputLabel id="drink-jo-status-filter-label"> | |||||
| {t("Job Order Status")} | |||||
| </InputLabel> | |||||
| <Select | |||||
| labelId="drink-jo-status-filter-label" | |||||
| id="drink-jo-status-filter" | |||||
| label={t("Job Order Status")} | |||||
| value={joStatusFilter} | |||||
| onChange={(e) => { | |||||
| setJoStatusFilter(String(e.target.value)); | |||||
| }} | |||||
| > | |||||
| <MenuItem value=""> | |||||
| <em>{t("All")}</em> | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| {JO_STATUS_FILTER_VALUES.map((v) => ( | |||||
| <MenuItem key={v} value={v}> | |||||
| {t(v, { ns: "jo" })} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| )} | |||||
| <Box sx={{ flexGrow: 1 }} /> | <Box sx={{ flexGrow: 1 }} /> | ||||
| @@ -246,11 +346,14 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| variant="outlined" | variant="outlined" | ||||
| size="small" | size="small" | ||||
| startIcon={<FileDownloadIcon />} | startIcon={<FileDownloadIcon />} | ||||
| disabled={loading || data.length === 0} | |||||
| disabled={ | |||||
| loading || (isShipment ? shipmentData.length === 0 : data.length === 0) | |||||
| } | |||||
| sx={{ display: "none" }} | sx={{ display: "none" }} | ||||
| onClick={() => { | onClick={() => { | ||||
| exportDrinkProductionQtyXlsx({ | exportDrinkProductionQtyXlsx({ | ||||
| data, | data, | ||||
| shipmentData, | |||||
| viewMode, | viewMode, | ||||
| selectedDate: selectedDate.format("YYYY-MM-DD"), | selectedDate: selectedDate.format("YYYY-MM-DD"), | ||||
| statusFilter: joStatusFilter, | statusFilter: joStatusFilter, | ||||
| @@ -298,6 +401,9 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| <ToggleButton value="planned"> | <ToggleButton value="planned"> | ||||
| {t("Drink detail mode: planned")} | {t("Drink detail mode: planned")} | ||||
| </ToggleButton> | </ToggleButton> | ||||
| <ToggleButton value="shipment"> | |||||
| {t("Drink detail mode: shipment")} | |||||
| </ToggleButton> | |||||
| </ToggleButtonGroup> | </ToggleButtonGroup> | ||||
| </Stack> | </Stack> | ||||
| @@ -347,22 +453,185 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right" sx={{ width: 140 }}> | <TableCell align="right" sx={{ width: 140 }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {isPlanned | |||||
| ? t("Planned Output Qty") | |||||
| : t("Stock Req. Qty")} | |||||
| {qtyHeaders.left} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right" sx={{ width: 140 }}> | <TableCell align="right" sx={{ width: 140 }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {isPlanned | |||||
| ? t("Actual Output Qty") | |||||
| : t("Production Qty")} | |||||
| {qtyHeaders.right} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {data.length === 0 ? ( | |||||
| {isShipment ? ( | |||||
| shipmentData.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={6} align="center"> | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ py: 2, color: "text.secondary" }} | |||||
| > | |||||
| {t("No data available")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| shipmentData.map((row, idx) => { | |||||
| const rowKey = getShipmentRowKey(row, idx); | |||||
| const deliveries: DrinkShipmentQtyDeliveryDetail[] = | |||||
| row.deliveries ?? []; | |||||
| const isExpanded = expandedRowKeys.has(rowKey); | |||||
| const hasExpandable = deliveries.length > 0; | |||||
| return ( | |||||
| <React.Fragment key={rowKey}> | |||||
| <TableRow hover={hasExpandable}> | |||||
| <TableCell padding="checkbox"> | |||||
| {hasExpandable ? ( | |||||
| <IconButton | |||||
| size="small" | |||||
| aria-label={ | |||||
| isExpanded | |||||
| ? t("Collapse delivery order details") | |||||
| : t("Expand delivery order details") | |||||
| } | |||||
| onClick={() => toggleRowExpanded(rowKey)} | |||||
| > | |||||
| {isExpanded ? ( | |||||
| <ExpandLessIcon fontSize="small" /> | |||||
| ) : ( | |||||
| <ExpandMoreIcon fontSize="small" /> | |||||
| )} | |||||
| </IconButton> | |||||
| ) : null} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2"> | |||||
| {row.itemCode || "-"} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2"> | |||||
| {row.itemName || "-"} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2"> | |||||
| {row.uom || "-"} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <Typography variant="body2"> | |||||
| {formatQty(row.totalOrderQty)} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <Typography variant="body2"> | |||||
| {formatQty(row.totalShippedQty)} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| {hasExpandable && ( | |||||
| <TableRow> | |||||
| <TableCell | |||||
| colSpan={6} | |||||
| sx={{ py: 0, borderBottom: 0 }} | |||||
| > | |||||
| <Collapse | |||||
| in={isExpanded} | |||||
| timeout="auto" | |||||
| unmountOnExit | |||||
| > | |||||
| <Box sx={{ py: 1.5, pl: 6, pr: 2 }}> | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Delivery Order Code", { | |||||
| ns: "do", | |||||
| })} | |||||
| </TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Delivery Order Status", { | |||||
| ns: "do", | |||||
| })} | |||||
| </TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Shop Name", { ns: "do" })} | |||||
| </TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Delivery Date", { ns: "do" })} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Shipment Order Qty")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Shipped Qty")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {deliveries.map((delivery) => ( | |||||
| <TableRow | |||||
| key={`${rowKey}-do-${delivery.deliveryOrderId}`} | |||||
| > | |||||
| <TableCell> | |||||
| {delivery.deliveryOrderId > 0 ? ( | |||||
| <MuiLink | |||||
| component={NextLink} | |||||
| href={`/do/edit?id=${delivery.deliveryOrderId}`} | |||||
| underline="hover" | |||||
| > | |||||
| {delivery.deliveryOrderCode || | |||||
| `DO-${delivery.deliveryOrderId}`} | |||||
| </MuiLink> | |||||
| ) : ( | |||||
| delivery.deliveryOrderCode || | |||||
| "-" | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {renderDoStatusChip( | |||||
| delivery.deliveryOrderStatus, | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {delivery.shopName || | |||||
| delivery.shopCode || | |||||
| "-"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {formatProductionDate( | |||||
| delivery.deliveryDate, | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {formatQty(delivery.orderQty)} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {formatQty(delivery.shippedQty)} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </Box> | |||||
| </Collapse> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| )} | |||||
| </React.Fragment> | |||||
| ); | |||||
| }) | |||||
| ) | |||||
| ) : filteredItemRows.length === 0 ? ( | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={6} align="center"> | <TableCell colSpan={6} align="center"> | ||||
| <Typography | <Typography | ||||
| @@ -374,14 +643,10 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| data.map((row, idx) => { | |||||
| filteredItemRows.map(({ row, jobOrders, totalReqQty, totalQty }, idx) => { | |||||
| const rowKey = getRowKey(row, idx); | const rowKey = getRowKey(row, idx); | ||||
| const allJobOrders = row.jobOrders ?? []; | |||||
| const jobOrders = filterJobOrders(allJobOrders); | |||||
| const isExpanded = expandedRowKeys.has(rowKey); | const isExpanded = expandedRowKeys.has(rowKey); | ||||
| const hasExpandable = | |||||
| allJobOrders.length > 0 && | |||||
| (joStatusFilter === "" || jobOrders.length > 0); | |||||
| const hasExpandable = jobOrders.length > 0; | |||||
| return ( | return ( | ||||
| <React.Fragment key={rowKey}> | <React.Fragment key={rowKey}> | ||||
| @@ -422,12 +687,12 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {formatQty(row.totalReqQty)} | |||||
| {formatQty(totalReqQty)} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {formatQty(row.totalQty)} | |||||
| {formatQty(totalQty)} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -66,7 +66,7 @@ interface ProductProcessDetailProps { | |||||
| fromJosave?: boolean; | fromJosave?: boolean; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.0 | 2026-08-05 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.1 | 2026-08-10 */ | |||||
| const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | ||||
| jobOrderId, | jobOrderId, | ||||
| onBack, | onBack, | ||||
| @@ -77,7 +77,7 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||||
| const { t } = useTranslation(["productionProcess", "common"]); | const { t } = useTranslation(["productionProcess", "common"]); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | const abilities = session?.abilities ?? session?.user?.abilities ?? []; | ||||
| /** 「已完成」(Just Pass):僅 ADMIN */ | |||||
| /** 「跳過」(Just Pass):僅 ADMIN */ | |||||
| const canAdminPass = hasAbility(abilities, AUTH.ADMIN); | const canAdminPass = hasAbility(abilities, AUTH.ADMIN); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | ||||
| @@ -666,7 +666,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| const isPaused = statusLower === 'paused'; | const isPaused = statusLower === 'paused'; | ||||
| const isPending = statusLower === 'pending' || status === ''; | const isPending = statusLower === 'pending' || status === ''; | ||||
| const isPass = statusLower === 'pass'; | const isPass = statusLower === 'pass'; | ||||
| const isPassDisabled = isCompleted || isPass || !canAdminPass; | |||||
| const isAutoPass = statusLower === 'autopass' || statusLower === 'auto pass'; | |||||
| const isPassDisabled = isCompleted || isPass || isAutoPass || !canAdminPass; | |||||
| return ( | return ( | ||||
| <TableRow key={line.id}> | <TableRow key={line.id}> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -773,6 +774,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| <Chip label={t("Pending")} color="default" size="small" /> | <Chip label={t("Pending")} color="default" size="small" /> | ||||
| ) : isPaused ? ( | ) : isPaused ? ( | ||||
| <Chip label={t("Paused")} color="warning" size="small" /> | <Chip label={t("Paused")} color="warning" size="small" /> | ||||
| ) : isAutoPass ? ( | |||||
| <Chip label={t("Auto Pass")} color="default" size="small" /> | |||||
| ) : isPass ? ( | ) : isPass ? ( | ||||
| <Chip label={t("Just Pass")} color="success" size="small" /> | <Chip label={t("Just Pass")} color="success" size="small" /> | ||||
| ) : ( | ) : ( | ||||
| @@ -178,7 +178,7 @@ function isWaitingQcPutAway( | |||||
| return s !== "completed" && s !== "rejected"; | return s !== "completed" && s !== "rejected"; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.8 | 2026-08-09 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.9 | 2026-08-10 */ | |||||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | const ProductProcessList: React.FC<ProductProcessListProps> = ({ | ||||
| onSelectProcess, | onSelectProcess, | ||||
| printerCombo, | printerCombo, | ||||
| @@ -447,7 +447,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| return productionCache; | return productionCache; | ||||
| }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]); | }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]); | ||||
| // QC ready: same JO — all lines Completed/Pass (sibling 包裝 auto-Pass on backend) + has SIL | |||||
| // QC ready: same JO — all lines Completed/Pass/autoPass (sibling 包裝 autoPass on backend) + has SIL | |||||
| const jobOrderQcReadyById = useMemo(() => { | const jobOrderQcReadyById = useMemo(() => { | ||||
| const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); | const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); | ||||
| for (const p of tabProcesses) { | for (const p of tabProcesses) { | ||||
| @@ -459,8 +459,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const result = new Map<number, boolean>(); | const result = new Map<number, boolean>(); | ||||
| const isDone = (status: unknown) => { | const isDone = (status: unknown) => { | ||||
| const s = String(status ?? "").trim().toLowerCase(); | |||||
| return s === "completed" || s === "pass"; | |||||
| const s = String(status ?? "").trim().toLowerCase().replace(/\s+/g, ""); | |||||
| return s === "completed" || s === "pass" || s === "autopass"; | |||||
| }; | }; | ||||
| byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | ||||
| @@ -55,6 +55,7 @@ interface ProductionProcessStepExecutionProps { | |||||
| jobOrderId?: number; // ✅ 添加 | jobOrderId?: number; // ✅ 添加 | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.0 | 2026-08-10 */ | |||||
| const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionProps> = ({ | const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionProps> = ({ | ||||
| lineId, | lineId, | ||||
| onBack, | onBack, | ||||
| @@ -62,9 +63,18 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| allLines, // ✅ 添加 | allLines, // ✅ 添加 | ||||
| jobOrderId, // ✅ 添加 | jobOrderId, // ✅ 添加 | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation( ["common","jo"]); | |||||
| const { t } = useTranslation( ["common","jo","productionProcess"]); | |||||
| const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null); | const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null); | ||||
| const isCompleted = lineDetail?.status === "Completed" || lineDetail?.status === "Pass"; | |||||
| const lineStatusNorm = String(lineDetail?.status ?? "") | |||||
| .trim() | |||||
| .toLowerCase() | |||||
| .replace(/\s+/g, ""); | |||||
| const isCompleted = | |||||
| lineStatusNorm === "completed" || | |||||
| lineStatusNorm === "pass" || | |||||
| lineStatusNorm === "autopass"; | |||||
| const isPassStatus = lineStatusNorm === "pass"; | |||||
| const isAutoPassStatus = lineStatusNorm === "autopass"; | |||||
| const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & { | const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & { | ||||
| byproductName: string; | byproductName: string; | ||||
| byproductQty: number; | byproductQty: number; | ||||
| @@ -161,8 +171,16 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| }, [lineId]); | }, [lineId]); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| // Don't show time remaining if completed | |||||
| if (lineDetail?.status === "Completed" || lineDetail?.status === "Pass") { | |||||
| // Don't show time remaining if completed / pass / autoPass | |||||
| const statusNorm = String(lineDetail?.status ?? "") | |||||
| .trim() | |||||
| .toLowerCase() | |||||
| .replace(/\s+/g, ""); | |||||
| if ( | |||||
| statusNorm === "completed" || | |||||
| statusNorm === "pass" || | |||||
| statusNorm === "autopass" | |||||
| ) { | |||||
| console.log("Line is completed"); | console.log("Line is completed"); | ||||
| setRemainingTime(null); | setRemainingTime(null); | ||||
| setIsOverTime(false); | setIsOverTime(false); | ||||
| @@ -553,9 +571,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| {isCompleted ? ( | {isCompleted ? ( | ||||
| <Card sx={{ bgcolor: 'success.50', border: '2px solid', borderColor: 'success.main', mb: 3 }}> | <Card sx={{ bgcolor: 'success.50', border: '2px solid', borderColor: 'success.main', mb: 3 }}> | ||||
| <CardContent> | <CardContent> | ||||
| {lineDetail?.status === "Pass" ? ( | |||||
| {isAutoPassStatus ? ( | |||||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | ||||
| {t("Passed Step")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||||
| {t("Auto Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||||
| </Typography> | |||||
| ) : isPassStatus ? ( | |||||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | |||||
| {t("Just Pass")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||||
| </Typography> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | ||||
| @@ -618,13 +640,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(1)")}</Typography> | <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(1)")}</Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectQty}</Typography> | |||||
| <Typography>{lineDetail?.defectQty}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectUom || "-"}</Typography> | |||||
| <Typography>{lineDetail?.defectUom || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectDescription || "-"}</Typography> | |||||
| <Typography>{lineDetail?.defectDescription || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | <TableRow sx={{ bgcolor: 'warning.50' }}> | ||||
| @@ -632,13 +654,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography> | <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectQty2}</Typography> | |||||
| <Typography>{lineDetail?.defectQty2}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectUom2 || "-"}</Typography> | |||||
| <Typography>{lineDetail?.defectUom2 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectDescription2 || "-"}</Typography> | |||||
| <Typography>{lineDetail?.defectDescription2 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | <TableRow sx={{ bgcolor: 'warning.50' }}> | ||||
| @@ -646,13 +668,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography> | <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectQty3}</Typography> | |||||
| <Typography>{lineDetail?.defectQty3}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectUom3 || "-"}</Typography> | |||||
| <Typography>{lineDetail?.defectUom3 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectDescription3 || "-"}</Typography> | |||||
| <Typography>{lineDetail?.defectDescription3 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| <TableRow sx={{ bgcolor: 'error.50' }}> | <TableRow sx={{ bgcolor: 'error.50' }}> | ||||
| @@ -660,10 +682,10 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| <Typography fontWeight={500} color="error.dark">{t("Scrap")}</Typography> | <Typography fontWeight={500} color="error.dark">{t("Scrap")}</Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.scrapQty}</Typography> | |||||
| <Typography>{lineDetail?.scrapQty}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.scrapUom || "-"}</Typography> | |||||
| <Typography>{lineDetail?.scrapUom || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableBody> | </TableBody> | ||||
| @@ -4,9 +4,10 @@ import { exportMultiSheetToXlsx } from "@/app/(main)/chart/_components/exportCha | |||||
| import type { | import type { | ||||
| DrinkProductionQtyJobOrderDetail, | DrinkProductionQtyJobOrderDetail, | ||||
| DrinkProductionQtyResponse, | DrinkProductionQtyResponse, | ||||
| DrinkShipmentQtyResponse, | |||||
| } from "@/app/api/jo/actions"; | } from "@/app/api/jo/actions"; | ||||
| type DrinkViewMode = "actual" | "planned"; | |||||
| export type DrinkViewMode = "actual" | "planned" | "shipment"; | |||||
| const formatDateTime = (value: unknown): string => { | const formatDateTime = (value: unknown): string => { | ||||
| if (value == null || value === "") return ""; | if (value == null || value === "") return ""; | ||||
| @@ -27,28 +28,102 @@ const formatDate = (value: string | null | undefined): string => { | |||||
| export type ExportDrinkProductionQtyParams = { | export type ExportDrinkProductionQtyParams = { | ||||
| data: DrinkProductionQtyResponse[]; | data: DrinkProductionQtyResponse[]; | ||||
| shipmentData?: DrinkShipmentQtyResponse[]; | |||||
| viewMode: DrinkViewMode; | viewMode: DrinkViewMode; | ||||
| selectedDate: string; | selectedDate: string; | ||||
| statusFilter: string; | statusFilter: string; | ||||
| t: TFunction; | t: TFunction; | ||||
| }; | }; | ||||
| const viewModeLabel = (viewMode: DrinkViewMode, t: TFunction): string => { | |||||
| switch (viewMode) { | |||||
| case "planned": | |||||
| return t("Drink detail mode: planned"); | |||||
| case "shipment": | |||||
| return t("Drink detail mode: shipment"); | |||||
| case "actual": | |||||
| return t("Drink detail mode: actual"); | |||||
| default: { | |||||
| const _exhaustive: never = viewMode; | |||||
| return _exhaustive; | |||||
| } | |||||
| } | |||||
| }; | |||||
| const formatDoStatus = (status: string | null | undefined, t: TFunction): string => { | |||||
| if (!status) return ""; | |||||
| switch (status.toLowerCase()) { | |||||
| case "pending": | |||||
| return t("Drink do status pending"); | |||||
| case "receiving": | |||||
| return t("Drink do status receiving"); | |||||
| case "completed": | |||||
| return t("Drink do status completed"); | |||||
| default: | |||||
| return t(status, { ns: "do", defaultValue: status }); | |||||
| } | |||||
| }; | |||||
| export function exportDrinkProductionQtyXlsx({ | export function exportDrinkProductionQtyXlsx({ | ||||
| data, | data, | ||||
| shipmentData = [], | |||||
| viewMode, | viewMode, | ||||
| selectedDate, | selectedDate, | ||||
| statusFilter, | statusFilter, | ||||
| t, | t, | ||||
| }: ExportDrinkProductionQtyParams): void { | }: ExportDrinkProductionQtyParams): void { | ||||
| const viewLabel = | |||||
| viewMode === "planned" | |||||
| ? t("Drink detail mode: planned") | |||||
| : t("Drink detail mode: actual"); | |||||
| const viewLabel = viewModeLabel(viewMode, t); | |||||
| const statusFilterLabel = statusFilter | const statusFilterLabel = statusFilter | ||||
| ? t(statusFilter, { ns: "jo", defaultValue: statusFilter }) | ? t(statusFilter, { ns: "jo", defaultValue: statusFilter }) | ||||
| : t("All"); | : t("All"); | ||||
| const exportedAt = dayjs().format("YYYY-MM-DD HH:mm:ss"); | const exportedAt = dayjs().format("YYYY-MM-DD HH:mm:ss"); | ||||
| if (viewMode === "shipment") { | |||||
| const doRows: Record<string, unknown>[] = []; | |||||
| const itemRows = shipmentData.map((item) => { | |||||
| for (const delivery of item.deliveries ?? []) { | |||||
| doRows.push({ | |||||
| [t("Export meta: exported at")]: exportedAt, | |||||
| [t("Drink detail mode label")]: viewLabel, | |||||
| [t("Date")]: selectedDate, | |||||
| [t("Item Code")]: item.itemCode ?? "", | |||||
| [t("Goods Name")]: item.itemName ?? "", | |||||
| [t("Unit")]: item.uom ?? "", | |||||
| [t("Delivery Order Code", { ns: "do" })]: delivery.deliveryOrderCode ?? "", | |||||
| [t("Delivery Order Status", { ns: "do" })]: formatDoStatus( | |||||
| delivery.deliveryOrderStatus, | |||||
| t, | |||||
| ), | |||||
| [t("Shop Name", { ns: "do" })]: | |||||
| delivery.shopName || delivery.shopCode || "", | |||||
| [t("Delivery Date", { ns: "do" })]: formatDate(delivery.deliveryDate), | |||||
| [t("Shipment Order Qty")]: delivery.orderQty ?? 0, | |||||
| [t("Shipped Qty")]: delivery.shippedQty ?? 0, | |||||
| }); | |||||
| } | |||||
| return { | |||||
| [t("Export meta: exported at")]: exportedAt, | |||||
| [t("Drink detail mode label")]: viewLabel, | |||||
| [t("Date")]: selectedDate, | |||||
| [t("Item Code")]: item.itemCode ?? "", | |||||
| [t("Goods Name")]: item.itemName ?? "", | |||||
| [t("Unit")]: item.uom ?? "", | |||||
| [t("Shipment Order Qty")]: item.totalOrderQty ?? 0, | |||||
| [t("Shipped Qty")]: item.totalShippedQty ?? 0, | |||||
| [t("DO count")]: (item.deliveries ?? []).length, | |||||
| }; | |||||
| }); | |||||
| const filename = `DrinkProductionQty_shipment_${selectedDate}_${dayjs().format("HHmm")}`; | |||||
| exportMultiSheetToXlsx( | |||||
| [ | |||||
| { name: t("Excel sheet: DO detail"), rows: doRows }, | |||||
| { name: t("Excel sheet: item summary"), rows: itemRows }, | |||||
| ], | |||||
| filename, | |||||
| ); | |||||
| return; | |||||
| } | |||||
| const filterJos = ( | const filterJos = ( | ||||
| jobOrders: DrinkProductionQtyJobOrderDetail[], | jobOrders: DrinkProductionQtyJobOrderDetail[], | ||||
| ): DrinkProductionQtyJobOrderDetail[] => { | ): DrinkProductionQtyJobOrderDetail[] => { | ||||
| @@ -72,7 +72,7 @@ interface Props extends CommonProps { | |||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||||
| const QcStockInModal: React.FC<Props> = ({ | const QcStockInModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -328,7 +328,29 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to save user authorities", error); | console.error("Failed to save user authorities", error); | ||||
| setAllUsers(cloneUserList(savedUsers)); | setAllUsers(cloneUserList(savedUsers)); | ||||
| alert(t("Save failed. Please try again.", { defaultValue: "儲存失敗,請再試一次。" })); | |||||
| const msg = error instanceof Error ? error.message : String(error); | |||||
| let text = t("Save failed. Please try again.", { | |||||
| defaultValue: "儲存失敗,請再試一次。", | |||||
| }); | |||||
| if (msg.includes("USERNAME_NOT_AVAILABLE")) { | |||||
| text = t("Username is already taken"); | |||||
| } else if (msg.includes("USER_WRONG_NEW_PWD")) { | |||||
| text = t("New password does not meet the rules"); | |||||
| } else if (/\b400\b/.test(msg)) { | |||||
| text = t("Invalid request. Please check your input"); | |||||
| } else if ( | |||||
| msg.includes("Unauthorized") || | |||||
| /\b401\b/.test(msg) || | |||||
| /\b403\b/.test(msg) | |||||
| ) { | |||||
| text = t("Unauthorized or no permission"); | |||||
| } else if (/\b404\b/.test(msg)) { | |||||
| text = t("User Not Found"); | |||||
| } else if (/\b500\b/.test(msg)) { | |||||
| text = t("Server error. Please try again later"); | |||||
| } | |||||
| alert(text); | |||||
| } finally { | } finally { | ||||
| setIsSaving(false); | setIsSaving(false); | ||||
| saveInFlightRef.current = false; | saveInFlightRef.current = false; | ||||
| @@ -12,6 +12,7 @@ declare module "next-auth" { | |||||
| id?: string; | id?: string; | ||||
| /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ | /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ | ||||
| exp?: number; | exp?: number; | ||||
| locale?: string; | |||||
| } | } | ||||
| interface User { | interface User { | ||||
| @@ -19,6 +20,7 @@ declare module "next-auth" { | |||||
| accessToken: string | null; | accessToken: string | null; | ||||
| refreshToken?: string; | refreshToken?: string; | ||||
| abilities: string[]; | abilities: string[]; | ||||
| locale?: string; | |||||
| } | } | ||||
| } | } | ||||
| @@ -28,6 +30,7 @@ declare module "next-auth/jwt" { | |||||
| accessToken: string | null; | accessToken: string | null; | ||||
| refreshToken?: string; | refreshToken?: string; | ||||
| abilities: string[]; | abilities: string[]; | ||||
| locale?: string; | |||||
| } | } | ||||
| } | } | ||||
| @@ -70,13 +73,24 @@ export const authOptions: AuthOptions = { | |||||
| }, | }, | ||||
| callbacks: { | callbacks: { | ||||
| // Persist custom fields into the JWT token | // Persist custom fields into the JWT token | ||||
| async jwt({ token, user }) { | |||||
| async jwt({ token, user, trigger, session }) { | |||||
| // First sign-in: `user` is available | // First sign-in: `user` is available | ||||
| if (user) { | if (user) { | ||||
| token.id = user.id ?? token.sub; // fallback to sub if no id | token.id = user.id ?? token.sub; // fallback to sub if no id | ||||
| token.accessToken = user.accessToken; | token.accessToken = user.accessToken; | ||||
| token.refreshToken = user.refreshToken; | token.refreshToken = user.refreshToken; | ||||
| token.abilities = user.abilities ?? []; | token.abilities = user.abilities ?? []; | ||||
| const loginLocale = (user as { locale?: string }).locale; | |||||
| if (loginLocale) { | |||||
| token.locale = loginLocale; | |||||
| } | |||||
| } | |||||
| if (trigger === "update" && session && typeof session === "object" && "locale" in session) { | |||||
| const next = (session as { locale?: string }).locale; | |||||
| if (next === "zh" || next === "en") { | |||||
| token.locale = next; | |||||
| } | |||||
| } | } | ||||
| // On subsequent calls (token refresh, session access), user is not present | // On subsequent calls (token refresh, session access), user is not present | ||||
| @@ -91,6 +105,7 @@ export const authOptions: AuthOptions = { | |||||
| session.refreshToken = token.refreshToken as string | undefined; | session.refreshToken = token.refreshToken as string | undefined; | ||||
| session.abilities = token.abilities as string[]; | session.abilities = token.abilities as string[]; | ||||
| session.exp = token.exp as number | undefined; | session.exp = token.exp as number | undefined; | ||||
| session.locale = token.locale as string | undefined; | |||||
| // Also add abilities to session.user for easier client-side access | // Also add abilities to session.user for easier client-side access | ||||
| if (session.user) { | if (session.user) { | ||||
| @@ -107,5 +122,6 @@ export type SessionWithTokens = Session & { | |||||
| abilities: string[]; | abilities: string[]; | ||||
| /** Backend / JWT subject — often numeric string or number */ | /** Backend / JWT subject — often numeric string or number */ | ||||
| id?: string | number; | id?: string | number; | ||||
| locale?: string; | |||||
| }; | }; | ||||
| export default authOptions; | export default authOptions; | ||||
| @@ -17,6 +17,10 @@ export interface ReportField { | |||||
| allowInput?: boolean; // Allow user to input custom values (for select types) | allowInput?: boolean; // Allow user to input custom values (for select types) | ||||
| /** When checkbox is checked, disable these field names (by `name`) */ | /** When checkbox is checked, disable these field names (by `name`) */ | ||||
| disablesFieldsWhenChecked?: string[]; | disablesFieldsWhenChecked?: string[]; | ||||
| /** For date fields: restrict picker so value cannot be before today */ | |||||
| minDate?: 'today'; | |||||
| /** Disable the input (e.g. date locked to today) */ | |||||
| disabled?: boolean; | |||||
| } | } | ||||
| export type ReportResponseType = 'pdf' | 'excel'; | export type ReportResponseType = 'pdf' | 'excel'; | ||||
| @@ -30,6 +34,7 @@ export interface ReportDefinition { | |||||
| fields: ReportField[]; | fields: ReportField[]; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| export const REPORTS: ReportDefinition[] = [ | export const REPORTS: ReportDefinition[] = [ | ||||
| //{ | //{ | ||||
| // id: "rep-001", | // id: "rep-001", | ||||
| @@ -79,10 +84,37 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| title: "入倉追蹤報告", | title: "入倉追蹤報告", | ||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-in-traceability`, | apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-in-traceability`, | ||||
| fields: [ | fields: [ | ||||
| { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, | { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, | ||||
| { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, | { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | ||||
| { | |||||
| label: "樓層 Store ID", | |||||
| name: "storeId", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "1F", value: "1F" }, | |||||
| { label: "2F", value: "2F" }, | |||||
| { label: "3F", value: "3F" }, | |||||
| { label: "4F", value: "4F" }, | |||||
| ], | |||||
| }, | |||||
| { label: "倉庫 Warehouse", name: "warehouse", type: "text", required: false, placeholder: "e.g. W201" }, | |||||
| { label: "區域 Area", name: "area", type: "text", required: false, placeholder: "e.g. #A" }, | |||||
| { label: "儲位 Slot", name: "slot", type: "text", required: false, placeholder: "e.g. 01" }, | |||||
| { label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" }, | |||||
| { | |||||
| label: "PP/PF 分類", | |||||
| name: "poPrefix", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "PP", value: "PP" }, | |||||
| { label: "PF", value: "PF" }, | |||||
| ], | |||||
| }, | |||||
| ] | ] | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -167,6 +199,80 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| }, | }, | ||||
| ] | ] | ||||
| }, | }, | ||||
| /* Hidden for now: 庫存批次結餘報告 (rep-019) | |||||
| { | |||||
| id: "rep-019", | |||||
| title: "庫存批次結餘報告", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-balance`, | |||||
| responseType: "excel", | |||||
| fields: [ | |||||
| { label: "庫存日期 Stock Date(僅今天)", name: "stockDate", type: "date", required: true, disabled: true }, | |||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| { label: "倉位 Warehouse Code", name: "warehouseCode", type: "text", required: false, placeholder: "e.g. W200 or 2F-W200-#A-00" }, | |||||
| { | |||||
| label: "樓層 Store ID", | |||||
| name: "storeId", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "1F", value: "1F" }, | |||||
| { label: "2F", value: "2F" }, | |||||
| { label: "3F", value: "3F" }, | |||||
| { label: "4F", value: "4F" }, | |||||
| ], | |||||
| }, | |||||
| { label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" }, | |||||
| ], | |||||
| }, | |||||
| */ | |||||
| { | |||||
| id: "rep-021", | |||||
| title: "庫存批次現況報告", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-onhand`, | |||||
| responseType: "excel", | |||||
| fields: [ | |||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| { | |||||
| label: "樓層 Store ID", | |||||
| name: "storeId", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "1F", value: "1F" }, | |||||
| { label: "2F", value: "2F" }, | |||||
| { label: "3F", value: "3F" }, | |||||
| { label: "4F", value: "4F" }, | |||||
| ], | |||||
| }, | |||||
| { label: "倉庫 Warehouse", name: "warehouse", type: "text", required: false, placeholder: "e.g. W201" }, | |||||
| { label: "區域 Area", name: "area", type: "text", required: false, placeholder: "e.g. #A" }, | |||||
| { label: "儲位 Slot", name: "slot", type: "text", required: false, placeholder: "e.g. 02" }, | |||||
| { | |||||
| label: "盤點區域說明 Stock Take Section", | |||||
| name: "stockTakeSectionDescription", | |||||
| type: "select", | |||||
| required: false, | |||||
| dynamicOptions: true, | |||||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/warehouse/stockTakeSections`, | |||||
| options: [{ label: "全部", value: "All" }], | |||||
| }, | |||||
| { label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" }, | |||||
| { | |||||
| label: "來源 Lot Origin", | |||||
| name: "lotOrigin", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "PP", value: "PP" }, | |||||
| { label: "PF", value: "PF" }, | |||||
| { label: "其他", value: "other" }, | |||||
| ], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| { id: "rep-011", | { id: "rep-011", | ||||
| title: "庫存明細報告", | title: "庫存明細報告", | ||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-ledger`, | apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-ledger`, | ||||
| @@ -176,6 +282,19 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | ||||
| ] | ] | ||||
| }, | }, | ||||
| /* Hidden for now: 庫存流水帳報告 (rep-020) | |||||
| { | |||||
| id: "rep-020", | |||||
| title: "庫存流水帳報告", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-ledger`, | |||||
| responseType: "excel", | |||||
| fields: [ | |||||
| { label: "期間起 Period From(永遠該月1日)", name: "lastInDateStart", type: "date", required: true }, | |||||
| { label: "期間迄 Period To", name: "lastInDateEnd", type: "date", required: true }, | |||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| ], | |||||
| }, | |||||
| */ | |||||
| /* | /* | ||||
| { | { | ||||
| id: "rep-007", | id: "rep-007", | ||||
| @@ -205,6 +324,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | ||||
| ] | ] | ||||
| }, | }, | ||||
| { | { | ||||
| id: "rep-014", | id: "rep-014", | ||||
| @@ -233,19 +353,31 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| ] | ] | ||||
| }, | }, | ||||
| { id: "rep-010", | { id: "rep-010", | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| title: "庫存品質檢測報告", | title: "庫存品質檢測報告", | ||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-item-qc-fail`, | apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-item-qc-fail`, | ||||
| fields: [ | fields: [ | ||||
| { label: "QC 檢測日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, | |||||
| { label: "QC 檢測日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, | |||||
| { label: "QC 檢測日期:由 QC Date Start", name: "lastInDateStart", type: "date", required: false }, | |||||
| { label: "QC 檢測日期:至 QC Date End", name: "lastInDateEnd", type: "date", required: false }, | |||||
| { label: "QC 類型", name: "qcType", type: "select", required: false, | { label: "QC 類型", name: "qcType", type: "select", required: false, | ||||
| options: [ | options: [ | ||||
| { label: "全部", value: "" }, | |||||
| { label: "IQC", value: "IQC" }, | |||||
| { label: "EPQC", value: "EPQC" }, | |||||
| { label: "全部", value: "all" }, | |||||
| { label: "IQC(採購)", value: "IQC" }, | |||||
| { label: "EPQC(工單)", value: "EPQC" }, | |||||
| ] }, | ] }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | ||||
| { | |||||
| label: "QC 項目範圍", | |||||
| name: "qcItemScope", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部 QC 項目", value: "all" }, | |||||
| { label: "只包含溫度濕度", value: "measurable" }, | |||||
| ], | |||||
| }, | |||||
| ] | ] | ||||
| }, | }, | ||||
| { id: "rep-013", | { id: "rep-013", | ||||
| @@ -383,4 +515,31 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| { label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" }, | { label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" }, | ||||
| ], | ], | ||||
| }, | }, | ||||
| { | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ | |||||
| id: "rep-018", | |||||
| title: "送貨訂單與倉存單位不符報告", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-do-inventory-uom-mismatch`, | |||||
| responseType: "excel", | |||||
| fields: [ | |||||
| { | |||||
| label: "預計送貨日期 Estimated Arrival Date", | |||||
| name: "deliveryDate", | |||||
| type: "date", | |||||
| required: true, | |||||
| minDate: "today", | |||||
| }, | |||||
| { | |||||
| label: "送貨訂單樓層 Floor", | |||||
| name: "storeId", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "2F", value: "2F" }, | |||||
| { label: "4F", value: "4F" }, | |||||
| ], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| ] | ] | ||||
| @@ -7,6 +7,8 @@ | |||||
| "board_processLive": "Process Live Board", | "board_processLive": "Process Live Board", | ||||
| "dateRange_lastDays": "Last {{d}} days", | "dateRange_lastDays": "Last {{d}} days", | ||||
| "delivery_colAvgMin": "Avg Min/Order", | "delivery_colAvgMin": "Avg Min/Order", | ||||
| "delivery_colItemKindCount": "Item Kind Count", | |||||
| "delivery_colItemQtyPicked": "Item Qty Picked", | |||||
| "delivery_colPickCount": "Pick Count", | "delivery_colPickCount": "Pick Count", | ||||
| "delivery_colStaff": "Staff", | "delivery_colStaff": "Staff", | ||||
| "delivery_colTotalMin": "Total Min", | "delivery_colTotalMin": "Total Min", | ||||
| @@ -19,7 +21,7 @@ | |||||
| "delivery_ordersByDate": "Delivery Orders by Date", | "delivery_ordersByDate": "Delivery Orders by Date", | ||||
| "delivery_ordersByDate_export": "Delivery_Orders_By_Date", | "delivery_ordersByDate_export": "Delivery_Orders_By_Date", | ||||
| "delivery_staff": "Staff", | "delivery_staff": "Staff", | ||||
| "delivery_staffPerfCaption": "Per-person pick count & total duration for period", | |||||
| "delivery_staffPerfCaption": "Per-person pick count, item kinds, picked qty & total duration for period", | |||||
| "delivery_staffPerfDateError": "Staff performance start date cannot be later than end date", | "delivery_staffPerfDateError": "Staff performance start date cannot be later than end date", | ||||
| "delivery_staffPerformanceTitle": "Staff Delivery Performance (Daily Pick Count & Duration)", | "delivery_staffPerformanceTitle": "Staff Delivery Performance (Daily Pick Count & Duration)", | ||||
| "delivery_staffPlaceholder": "Leave empty for all", | "delivery_staffPlaceholder": "Leave empty for all", | ||||
| @@ -87,6 +87,7 @@ | |||||
| "Select Date": "選擇日期", | "Select Date": "選擇日期", | ||||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | "Session expired or unauthorized.": "工作階段已過期或未經授權。", | ||||
| "Sign out": "Sign out", | "Sign out": "Sign out", | ||||
| "Language": "Language", | |||||
| "Status": "狀態", | "Status": "狀態", | ||||
| "Stock Qty": "庫存數量", | "Stock Qty": "庫存數量", | ||||
| "Supporting Document": "證明文件", | "Supporting Document": "證明文件", | ||||
| @@ -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.storing": "Storing", | ||||
| "code.joStatus.PARTIAL": "Partial", | "code.joStatus.PARTIAL": "Partial", | ||||
| "code.joStatus.partial": "Partial", | "code.joStatus.partial": "Partial", | ||||
| "code.productionStatus.Pass": "Pass", | |||||
| "code.productionStatus.Pass": "Skip", | |||||
| "code.productionStatus.Completed": "Completed", | "code.productionStatus.Completed": "Completed", | ||||
| "code.productionStatus.Pending": "Pending", | "code.productionStatus.Pending": "Pending", | ||||
| "code.productionStatus.Paused": "Paused", | "code.productionStatus.Paused": "Paused", | ||||
| "code.productionStatus.InProgress": "In progress", | "code.productionStatus.InProgress": "In progress", | ||||
| "code.productionStatus.Skip": "Skip", | "code.productionStatus.Skip": "Skip", | ||||
| "code.productionStatus.autoPass": "Auto Skipped", | |||||
| "continuousScanBlocked": "Finish current scan first", | "continuousScanBlocked": "Finish current scan first", | ||||
| "nodeJoOut": "Job order material issue", | "nodeJoOut": "Job order material issue", | ||||
| "nodePoOut": "Purchase pick", | "nodePoOut": "Purchase pick", | ||||
| @@ -272,7 +272,9 @@ | |||||
| "Overview": "Overview", | "Overview": "Overview", | ||||
| "Packaging": "Packaging", | "Packaging": "Packaging", | ||||
| "Partial quantity submitted. Please submit more or complete the order.": "Partial quantity submitted. Please submit more or complete the order.", | "Partial quantity submitted. Please submit more or complete the order.": "Partial quantity submitted. Please submit more or complete the order.", | ||||
| "Pass": "Pass", | |||||
| "Pass": "Skip", | |||||
| "Just Pass": "Skip", | |||||
| "Auto Pass": "Auto Skipped", | |||||
| "Passed Step": "Passed Step", | "Passed Step": "Passed Step", | ||||
| "Pause": "Pause", | "Pause": "Pause", | ||||
| "Pause Reason": "Pause Reason", | "Pause Reason": "Pause Reason", | ||||
| @@ -36,6 +36,7 @@ | |||||
| "nav.settings.user": "User", | "nav.settings.user": "User", | ||||
| "nav.settings.clientMonitor": "Device Connection Monitor", | "nav.settings.clientMonitor": "Device Connection Monitor", | ||||
| "nav.settings.items": "Items", | "nav.settings.items": "Items", | ||||
| "nav.settings.itemDefaultShelfLife": "Item default shelf life", | |||||
| "nav.settings.equipment": "Equipment", | "nav.settings.equipment": "Equipment", | ||||
| "nav.settings.warehouse": "Warehouse", | "nav.settings.warehouse": "Warehouse", | ||||
| "nav.settings.printer": "Printer", | "nav.settings.printer": "Printer", | ||||
| @@ -99,6 +99,14 @@ | |||||
| "Drink detail mode label": "Detail display", | "Drink detail mode label": "Detail display", | ||||
| "Drink detail mode: actual": "Actual production", | "Drink detail mode: actual": "Actual production", | ||||
| "Drink detail mode: planned": "Planned production", | "Drink detail mode: planned": "Planned production", | ||||
| "Drink detail mode: shipment": "Shipment qty", | |||||
| "Shipment Order Qty": "Order qty", | |||||
| "Shipped Qty": "Shipped today", | |||||
| "Drink do status pending": "Pending", | |||||
| "Drink do status receiving": "Released", | |||||
| "Drink do status completed": "Completed", | |||||
| "Expand delivery order details": "Expand delivery order details", | |||||
| "Collapse delivery order details": "Collapse delivery order details", | |||||
| "Planned Output Qty": "Planned output", | "Planned Output Qty": "Planned output", | ||||
| "Actual Output Qty": "Actual output", | "Actual Output Qty": "Actual output", | ||||
| "Latest Start By": "Latest start by", | "Latest Start By": "Latest start by", | ||||
| @@ -112,9 +120,11 @@ | |||||
| "QC users": "QC users", | "QC users": "QC users", | ||||
| "Put away users": "Put-away users", | "Put away users": "Put-away users", | ||||
| "Excel sheet: JO detail": "JO detail", | "Excel sheet: JO detail": "JO detail", | ||||
| "Excel sheet: DO detail": "DO detail", | |||||
| "Excel sheet: process people": "Process people", | "Excel sheet: process people": "Process people", | ||||
| "Excel sheet: item summary": "Item summary", | "Excel sheet: item summary": "Item summary", | ||||
| "JO count": "JO count", | "JO count": "JO count", | ||||
| "DO count": "DO count", | |||||
| "Process seq": "Process seq", | "Process seq": "Process seq", | ||||
| "Handler": "Handler", | "Handler": "Handler", | ||||
| "Goods Name": "Goods Name", | "Goods Name": "Goods Name", | ||||
| @@ -1,13 +1,338 @@ | |||||
| { | { | ||||
| "Report": "Report", | |||||
| "title": "Report Management", | "title": "Report Management", | ||||
| "selectReport": "Select Report", | "selectReport": "Select Report", | ||||
| "reportList": "Report List", | "reportList": "Report List", | ||||
| "selectReportHelper": "Select a report", | "selectReportHelper": "Select a report", | ||||
| "searchCriteria": "Search Criteria", | "searchCriteria": "Search Criteria", | ||||
| "searchCriteriaWithTitle": "Search Criteria: {{title}}", | |||||
| "downloadPdf": "Download Report (PDF)", | "downloadPdf": "Download Report (PDF)", | ||||
| "downloadExcel": "Download Report (Excel)", | "downloadExcel": "Download Report (Excel)", | ||||
| "generatingPdf": "Generating PDF...", | "generatingPdf": "Generating PDF...", | ||||
| "generatingExcel": "Generating Excel...", | "generatingExcel": "Generating Excel...", | ||||
| "generatingReport": "Generating report..." | |||||
| "generatingReport": "Generating report...", | |||||
| "generateError": "An error occurred while generating the report. Please try again.", | |||||
| "noDataFound": "No data found", | |||||
| "noDataFoundTitle": "No Data Found / 查無資料", | |||||
| "noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.", | |||||
| "ok": "OK", | |||||
| "missingRequired": "Missing required fields:\n- {{fields}}", | |||||
| "dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}", | |||||
| "selectOrEnterItemCode": "Select or enter item code", | |||||
| "cancel": "Cancel", | |||||
| "confirmDownloadPdf": "Confirm download PDF", | |||||
| "confirmDownloadExcel": "Confirm download Excel", | |||||
| "semiFgConfirmTitle": "Selected item codes — FG / Semi-FG Production Analysis Report", | |||||
| "semiFgConfirmHint": "Please confirm the selected item codes and their categories:", | |||||
| "semiFgColItem": "Item code and name", | |||||
| "semiFgColCategory": "Category", | |||||
| "qcScopeHelpAll": "Export all QC inspection items (including temperature / humidity).", | |||||
| "qcScopeHelpMeasurable": "Export temperature / humidity QC items only (same as the previous production default).", | |||||
| "categories": { | |||||
| "inventory": "Inventory Management", | |||||
| "inbound-outbound": "Inbound / Outbound", | |||||
| "production": "Production & Trends" | |||||
| }, | |||||
| "options": { | |||||
| "All": "All", | |||||
| "all": "All", | |||||
| "pending": "Pending", | |||||
| "completed": "Approved", | |||||
| "success": "Success", | |||||
| "failed": "Failed", | |||||
| "measurable": "Temperature & humidity only" | |||||
| }, | |||||
| "reports": { | |||||
| "rep-004": { | |||||
| "title": "Stock-in Traceability Report", | |||||
| "fields": { | |||||
| "lastInDateStart": "Last In Date Start", | |||||
| "lastInDateEnd": "Last In Date End", | |||||
| "itemCode": "Item Code", | |||||
| "storeId": "Floor", | |||||
| "warehouse": "Warehouse", | |||||
| "area": "Area", | |||||
| "slot": "Slot", | |||||
| "lotNo": "Lot No", | |||||
| "poPrefix": "PP/PF" | |||||
| }, | |||||
| "options": { | |||||
| "storeId": { | |||||
| "All": "All" | |||||
| }, | |||||
| "poPrefix": { | |||||
| "All": "All", | |||||
| "PP": "PP", | |||||
| "PF": "PF" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-008": { | |||||
| "title": "Finished Goods Delivery Report", | |||||
| "fields": { | |||||
| "lastOutDateStart": "Last Out Date Start", | |||||
| "lastOutDateEnd": "Last Out Date End", | |||||
| "year": "Year", | |||||
| "itemCode": "Item Code" | |||||
| } | |||||
| }, | |||||
| "rep-012": { | |||||
| "title": "Stock Take Report", | |||||
| "fields": { | |||||
| "stockTakeRoundId": "Stock Take Round (multi-select)", | |||||
| "itemCode": "Item Code", | |||||
| "store_id": "Warehouse Floor", | |||||
| "status": "Status", | |||||
| "type": "Type" | |||||
| }, | |||||
| "options": { | |||||
| "store_id": { | |||||
| "All": "All" | |||||
| }, | |||||
| "status": { | |||||
| "All": "All", | |||||
| "pending": "Pending", | |||||
| "completed": "Approved" | |||||
| }, | |||||
| "type": { | |||||
| "All": "All", | |||||
| "PP": "PP", | |||||
| "PF": "PF", | |||||
| "TOA": "TOA", | |||||
| "工廠生產": "Factory production", | |||||
| "倉存調整": "Inventory adjustment", | |||||
| "期初存貨": "Opening inventory", | |||||
| "採購入倉": "Purchase inbound", | |||||
| "其他入倉": "Other inbound" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-011": { | |||||
| "title": "Stock Ledger Report", | |||||
| "fields": { | |||||
| "lastInDateStart": "Stock Date Start", | |||||
| "lastInDateEnd": "Stock Date End", | |||||
| "itemCode": "Item Code" | |||||
| } | |||||
| }, | |||||
| "rep-007": { | |||||
| "title": "Stock Balance Report", | |||||
| "fields": { | |||||
| "stockDate": "Stock Date", | |||||
| "itemCode": "Item Code" | |||||
| } | |||||
| }, | |||||
| "rep-014": { | |||||
| "title": "PO Goods Receipt Report", | |||||
| "fields": { | |||||
| "receiptDateStart": "Receipt Date Start", | |||||
| "receiptDateEnd": "Receipt Date End", | |||||
| "itemCode": "Item Code" | |||||
| } | |||||
| }, | |||||
| "rep-009": { | |||||
| "title": "Finished Goods Stock-out Traceability Report", | |||||
| "fields": { | |||||
| "lastOutDateStart": "Last Out Date Start", | |||||
| "lastOutDateEnd": "Last Out Date End", | |||||
| "itemCode": "Item Code", | |||||
| "handler": "Handler" | |||||
| } | |||||
| }, | |||||
| "rep-010": { | |||||
| "title": "Inventory QC Report", | |||||
| "fields": { | |||||
| "lastInDateStart": "QC Date Start", | |||||
| "lastInDateEnd": "QC Date End", | |||||
| "qcType": "QC Type", | |||||
| "itemCode": "Item Code", | |||||
| "qcItemScope": "QC Item Scope" | |||||
| }, | |||||
| "options": { | |||||
| "qcType": { | |||||
| "all": "All", | |||||
| "IQC": "IQC (Purchase)", | |||||
| "EPQC": "EPQC (Job Order)" | |||||
| }, | |||||
| "qcItemScope": { | |||||
| "all": "All QC items", | |||||
| "measurable": "Temperature & humidity only" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-013": { | |||||
| "title": "Material Stock-out Traceability Report", | |||||
| "fields": { | |||||
| "lastOutDateStart": "Last Out Date Start", | |||||
| "lastOutDateEnd": "Last Out Date End", | |||||
| "itemCode": "Item Code", | |||||
| "handler": "Handler" | |||||
| } | |||||
| }, | |||||
| "rep-006": { | |||||
| "title": "Stock Item Consumption Trend Report", | |||||
| "fields": { | |||||
| "lastOutDateStart": "Consumption Date Start", | |||||
| "lastOutDateEnd": "Consumption Date End", | |||||
| "year": "Year", | |||||
| "stockCategory": "Category", | |||||
| "itemCode": "Item Code" | |||||
| } | |||||
| }, | |||||
| "rep-005": { | |||||
| "title": "FG / Semi-FG Production Analysis Report", | |||||
| "fields": { | |||||
| "lastOutDateStart": "Production Complete Date Start", | |||||
| "lastOutDateEnd": "Production Complete Date End", | |||||
| "year": "Year", | |||||
| "stockCategory": "Category", | |||||
| "itemCode": "Item Code" | |||||
| } | |||||
| }, | |||||
| "rep-015": { | |||||
| "title": "M18 BOM Shop Sync History", | |||||
| "fields": { | |||||
| "syncDateStart": "Sync Date Start", | |||||
| "syncDateEnd": "Sync Date End", | |||||
| "finishedItemCode": "Finished Item Code", | |||||
| "syncStatus": "Sync Status" | |||||
| }, | |||||
| "options": { | |||||
| "syncStatus": { | |||||
| "all": "All", | |||||
| "success": "Success", | |||||
| "failed": "Failed" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-016": { | |||||
| "title": "FG Delivery Pick Compliance Report", | |||||
| "fields": { | |||||
| "dateStart": "Date", | |||||
| "handler": "Handler", | |||||
| "ticketNo": "Ticket No.", | |||||
| "itemCode": "Item Code", | |||||
| "storeId": "Floor" | |||||
| } | |||||
| }, | |||||
| "rep-017": { | |||||
| "title": "Shop Order Replenishment Record", | |||||
| "fields": { | |||||
| "shopOrderDateStart": "Shop Order Date Start", | |||||
| "shopOrderDateEnd": "Shop Order Date End", | |||||
| "shopCode": "Shop Code" | |||||
| } | |||||
| }, | |||||
| "rep-018": { | |||||
| "title": "Delivery Order vs Inventory UOM Mismatch Report", | |||||
| "fields": { | |||||
| "deliveryDate": "Estimated Arrival Date", | |||||
| "storeId": "Delivery Order Floor" | |||||
| }, | |||||
| "options": { | |||||
| "storeId": { | |||||
| "All": "All" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-021": { | |||||
| "title": "Stock Lot On-hand Report", | |||||
| "fields": { | |||||
| "itemCode": "Item Code", | |||||
| "storeId": "Floor", | |||||
| "warehouse": "Warehouse", | |||||
| "area": "Area", | |||||
| "slot": "Slot", | |||||
| "stockTakeSectionDescription": "Stock Take Section", | |||||
| "lotNo": "Lot No", | |||||
| "lotOrigin": "Lot Origin" | |||||
| }, | |||||
| "options": { | |||||
| "storeId": { | |||||
| "All": "All" | |||||
| }, | |||||
| "stockTakeSectionDescription": { | |||||
| "All": "All" | |||||
| }, | |||||
| "lotOrigin": { | |||||
| "All": "All", | |||||
| "PP": "PP", | |||||
| "PF": "PF", | |||||
| "other": "Other" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "excel": { | |||||
| "noData": "(No records in the selected range)", | |||||
| "grn": { | |||||
| "sheetDetail": "PO Goods Receipt", | |||||
| "sheetListedPo": "Listed PO Amounts", | |||||
| "noCompletedPo": "No completed PO lines in the selected range", | |||||
| "categoryCurrencyTotal": "Currency total", | |||||
| "categoryPo": "PO", | |||||
| "poNo": "PO No.", | |||||
| "deliveryNoteNo": "Delivery Note No.", | |||||
| "receiptDate": "Receipt Date", | |||||
| "itemCode": "Item Code", | |||||
| "itemName": "Item Name", | |||||
| "qty": "Qty", | |||||
| "demandQty": "Demand Qty", | |||||
| "uom": "UOM", | |||||
| "supplierLotNo": "Supplier Lot No.", | |||||
| "expiryDate": "Expiry Date", | |||||
| "supplierCode": "Supplier Code", | |||||
| "supplier": "Supplier", | |||||
| "status": "Stock-in Status", | |||||
| "unitPrice": "Unit Price", | |||||
| "currency": "Currency", | |||||
| "amount": "Amount", | |||||
| "grnCode": "GRN Code / M18 Receipt No.", | |||||
| "grnId": "GRN Id / M18 Record Id", | |||||
| "poCreator": "PO creator (M18)", | |||||
| "note": "Note", | |||||
| "category": "Category", | |||||
| "totalAmount": "Total Amount", | |||||
| "grnCodes": "GRN Code(s) / M18 Receipt No." | |||||
| }, | |||||
| "bomSync": { | |||||
| "sheetSync": "BOM Sync Log", | |||||
| "sheetMaterials": "BOM Material Lines", | |||||
| "syncTime": "Sync Time", | |||||
| "finishedItemCode": "Finished Item Code", | |||||
| "finishedItemName": "Finished Item Name", | |||||
| "bomRoutingCode": "BOM Routing Code", | |||||
| "version": "Version", | |||||
| "status": "Status", | |||||
| "failureReason": "Failure Reason", | |||||
| "message": "Message", | |||||
| "lineNo": "Line No.", | |||||
| "materialName": "Material Name", | |||||
| "uom": "UOM", | |||||
| "qty": "Qty", | |||||
| "statusSuccess": "Success", | |||||
| "statusSkipped": "Skipped (unchanged)", | |||||
| "statusFailed": "Failed" | |||||
| }, | |||||
| "shopReplenishment": { | |||||
| "sheetName": "Shop Order Replenishment", | |||||
| "shopCode": "Shop Code", | |||||
| "shopName": "Shop Name", | |||||
| "shopOrderDate": "Shop Order Date", | |||||
| "shopOrderNo": "Shop Order No.", | |||||
| "itemCode": "Item Code", | |||||
| "itemName": "Item Name", | |||||
| "firstOrderQty": "Original Order Qty", | |||||
| "firstOrderActualPickQty": "Original Actual Pick Qty", | |||||
| "firstOrderPicker": "Original Picker", | |||||
| "reorderQty": "Replenish Qty", | |||||
| "reorderDate": "Replenish Date", | |||||
| "reason": "Replenish Reason", | |||||
| "actualDeliveredQty": "Actual Replenish Qty", | |||||
| "actualDeliveredHandler": "Actual Replenish Handler", | |||||
| "deliveredDate": "Delivery Date", | |||||
| "reasonQuality": "Quality issue", | |||||
| "reasonOutOfStock": "Out of stock", | |||||
| "reasonOther": "Other" | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -46,5 +46,12 @@ | |||||
| "Failed to search by name": "Failed to search by name", | "Failed to search by name": "Failed to search by name", | ||||
| "Failed to search by username": "Failed to search by username", | "Failed to search by username": "Failed to search by username", | ||||
| "Staff No is required": "Staff No is required", | "Staff No is required": "Staff No is required", | ||||
| "User Not Found": "User Not Found" | |||||
| "User Not Found": "User Not Found", | |||||
| "Username is already taken": "Username is already taken. Please choose another.", | |||||
| "Name is already taken": "Name is already taken. Please choose another.", | |||||
| "Staff No is already taken": "Staff No is already taken. Please choose another.", | |||||
| "New password does not meet the rules": "New password does not meet the rules. Please try again.", | |||||
| "Invalid request. Please check your input": "Invalid request. Please check your input.", | |||||
| "Unauthorized or no permission": "Unauthorized or no permission.", | |||||
| "Server error. Please try again later": "Server error. Please try again later." | |||||
| } | } | ||||
| @@ -2,12 +2,14 @@ import { cookies, headers } from "next/headers"; | |||||
| import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; | import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; | ||||
| import resourcesToBackend from "i18next-resources-to-backend"; | import resourcesToBackend from "i18next-resources-to-backend"; | ||||
| import { getServerSession } from "next-auth"; | import { getServerSession } from "next-auth"; | ||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { authOptions, SessionWithTokens } from "@/config/authConfig"; | |||||
| import I18nClientProvider from "./I18nClientProvider"; | import I18nClientProvider from "./I18nClientProvider"; | ||||
| import universalLanguageDetect from "@unly/universal-language-detector"; | import universalLanguageDetect from "@unly/universal-language-detector"; | ||||
| const FALLBACK_LANG = "zh"; | |||||
| const SUPPORTED_LANGUAGES = ["zh"]; | |||||
| import { | |||||
| FALLBACK_LANG, | |||||
| SUPPORTED_LANGUAGES, | |||||
| normalizeAppLanguage, | |||||
| } from "./locale"; | |||||
| export const detectLanguage = async (): Promise<string> => { | export const detectLanguage = async (): Promise<string> => { | ||||
| // Logic to get language preference from cookies/headers/session | // Logic to get language preference from cookies/headers/session | ||||
| @@ -21,11 +23,13 @@ export const detectLanguage = async (): Promise<string> => { | |||||
| const headersList = headers(); | const headersList = headers(); | ||||
| //console.time("[i18n] detectLanguage total"); | //console.time("[i18n] detectLanguage total"); | ||||
| //console.time("[i18n] getServerSession"); | //console.time("[i18n] getServerSession"); | ||||
| const session = await getServerSession(authOptions); | |||||
| //console.timeEnd("[i18n] getServerSession"); | |||||
| //console.time("[i18n] universalLanguageDetect"); | |||||
| const session = (await getServerSession(authOptions)) as SessionWithTokens | null; | |||||
| const fromSession = normalizeAppLanguage(session?.locale); | |||||
| if (fromSession) { | |||||
| return fromSession; | |||||
| } | |||||
| const lang = universalLanguageDetect({ | const lang = universalLanguageDetect({ | ||||
| supportedLanguages: SUPPORTED_LANGUAGES, | |||||
| supportedLanguages: [...SUPPORTED_LANGUAGES], | |||||
| fallbackLanguage: FALLBACK_LANG, | fallbackLanguage: FALLBACK_LANG, | ||||
| acceptLanguageHeader: headersList.get("accept-language") || undefined, | acceptLanguageHeader: headersList.get("accept-language") || undefined, | ||||
| serverCookies: cookiesObj, | serverCookies: cookiesObj, | ||||
| @@ -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": "工序即時看板", | "board_processLive": "工序即時看板", | ||||
| "dateRange_lastDays": "最近 {{d}} 天", | "dateRange_lastDays": "最近 {{d}} 天", | ||||
| "delivery_colAvgMin": "平均分鐘/單", | "delivery_colAvgMin": "平均分鐘/單", | ||||
| "delivery_colItemKindCount": "總揀貨款數", | |||||
| "delivery_colItemQtyPicked": "揀貨數量", | |||||
| "delivery_colPickCount": "揀單數", | "delivery_colPickCount": "揀單數", | ||||
| "delivery_colStaff": "員工", | "delivery_colStaff": "員工", | ||||
| "delivery_colTotalMin": "總分鐘", | "delivery_colTotalMin": "總分鐘", | ||||
| @@ -19,7 +21,7 @@ | |||||
| "delivery_ordersByDate": "按日期發貨單數量", | "delivery_ordersByDate": "按日期發貨單數量", | ||||
| "delivery_ordersByDate_export": "發貨單數量_按日期", | "delivery_ordersByDate_export": "發貨單數量_按日期", | ||||
| "delivery_staff": "員工", | "delivery_staff": "員工", | ||||
| "delivery_staffPerfCaption": "週期內每人揀單數及總耗時(首揀至完成)", | |||||
| "delivery_staffPerfCaption": "週期內每人揀單數、總揀貨款數、揀貨數量及總耗時(首揀至完成)", | |||||
| "delivery_staffPerfDateError": "員工發貨績效的起始日期不能晚於結束日期", | "delivery_staffPerfDateError": "員工發貨績效的起始日期不能晚於結束日期", | ||||
| "delivery_staffPerformanceTitle": "員工發貨績效(每日揀貨數量與耗時)", | "delivery_staffPerformanceTitle": "員工發貨績效(每日揀貨數量與耗時)", | ||||
| "delivery_staffPlaceholder": "不選則全部", | "delivery_staffPlaceholder": "不選則全部", | ||||
| @@ -90,6 +90,7 @@ | |||||
| "Select Date": "選擇日期", | "Select Date": "選擇日期", | ||||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | "Session expired or unauthorized.": "工作階段已過期或未經授權。", | ||||
| "Sign out": "登出", | "Sign out": "登出", | ||||
| "Language": "語言", | |||||
| "Status": "狀態", | "Status": "狀態", | ||||
| "Stock Qty": "庫存數量", | "Stock Qty": "庫存數量", | ||||
| "Supporting Document": "證明文件", | "Supporting Document": "證明文件", | ||||
| @@ -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.storing": "待QC上架", | ||||
| "code.joStatus.PARTIAL": "部分完成", | "code.joStatus.PARTIAL": "部分完成", | ||||
| "code.joStatus.partial": "部分完成", | "code.joStatus.partial": "部分完成", | ||||
| "code.productionStatus.Pass": "通過", | |||||
| "code.productionStatus.Pass": "跳過", | |||||
| "code.productionStatus.Completed": "完成", | "code.productionStatus.Completed": "完成", | ||||
| "code.productionStatus.Pending": "待處理", | "code.productionStatus.Pending": "待處理", | ||||
| "code.productionStatus.Paused": "已暫停", | "code.productionStatus.Paused": "已暫停", | ||||
| "code.productionStatus.InProgress": "進行中", | "code.productionStatus.InProgress": "進行中", | ||||
| "code.productionStatus.Skip": "跳過", | "code.productionStatus.Skip": "跳過", | ||||
| "code.productionStatus.autoPass": "已自動跳過", | |||||
| "continuousScanBlocked": "請先完成目前掃描", | "continuousScanBlocked": "請先完成目前掃描", | ||||
| "nodeJoOut": "工單提料", | "nodeJoOut": "工單提料", | ||||
| "nodePoOut": "採購提料", | "nodePoOut": "採購提料", | ||||
| @@ -10,7 +10,8 @@ | |||||
| "Actual Pick Qty": "實際提料數量", | "Actual Pick Qty": "實際提料數量", | ||||
| "Add Bag": "新增包裝袋", | "Add Bag": "新增包裝袋", | ||||
| "Add Record": "添加記錄", | "Add Record": "添加記錄", | ||||
| "Just Pass": "通過", | |||||
| "Just Pass": "跳過", | |||||
| "Auto Pass": "已自動跳過", | |||||
| "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", | "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", | ||||
| "Add some entries!": "請添加條目", | "Add some entries!": "請添加條目", | ||||
| "All": "全部", | "All": "全部", | ||||
| @@ -283,7 +284,7 @@ | |||||
| "Overview": "總覽", | "Overview": "總覽", | ||||
| "Packaging": "提料中", | "Packaging": "提料中", | ||||
| "Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。", | "Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。", | ||||
| "Pass": "通過", | |||||
| "Pass": "跳過", | |||||
| "Passed Step": "通過步驟", | "Passed Step": "通過步驟", | ||||
| "Pause": "暫停", | "Pause": "暫停", | ||||
| "Pause Reason": "暫停原因", | "Pause Reason": "暫停原因", | ||||
| @@ -79,6 +79,7 @@ | |||||
| "nav.settings.importExcel": "Excel 匯入", | "nav.settings.importExcel": "Excel 匯入", | ||||
| "nav.settings.importTesting": "匯入測試", | "nav.settings.importTesting": "匯入測試", | ||||
| "nav.settings.items": "物品", | "nav.settings.items": "物品", | ||||
| "nav.settings.itemDefaultShelfLife": "物品預設保質期", | |||||
| "nav.settings.masterDataIssues": "BOM / 物料單位問題", | "nav.settings.masterDataIssues": "BOM / 物料單位問題", | ||||
| "nav.settings.priceInquiry": "價格查詢", | "nav.settings.priceInquiry": "價格查詢", | ||||
| "nav.settings.printer": "列印機", | "nav.settings.printer": "列印機", | ||||
| @@ -104,6 +104,14 @@ | |||||
| "Drink detail mode label": "明細顯示", | "Drink detail mode label": "明細顯示", | ||||
| "Drink detail mode: actual": "實際生產", | "Drink detail mode: actual": "實際生產", | ||||
| "Drink detail mode: planned": "預計生產", | "Drink detail mode: planned": "預計生產", | ||||
| "Drink detail mode: shipment": "出貨數量", | |||||
| "Shipment Order Qty": "預期出貨數量", | |||||
| "Shipped Qty": "當前出貨數量", | |||||
| "Drink do status pending": "待處理", | |||||
| "Drink do status receiving": "已放單", | |||||
| "Drink do status completed": "已完成", | |||||
| "Expand delivery order details": "展開出貨明細", | |||||
| "Collapse delivery order details": "收合出貨明細", | |||||
| "Planned Output Qty": "預計生產數量", | "Planned Output Qty": "預計生產數量", | ||||
| "Actual Output Qty": "實際生產數量", | "Actual Output Qty": "實際生產數量", | ||||
| "Latest Start By": "最晚開工時間", | "Latest Start By": "最晚開工時間", | ||||
| @@ -117,9 +125,11 @@ | |||||
| "QC users": "QC人員", | "QC users": "QC人員", | ||||
| "Put away users": "上架人員", | "Put away users": "上架人員", | ||||
| "Excel sheet: JO detail": "JO明細", | "Excel sheet: JO detail": "JO明細", | ||||
| "Excel sheet: DO detail": "DO明細", | |||||
| "Excel sheet: process people": "工序人員", | "Excel sheet: process people": "工序人員", | ||||
| "Excel sheet: item summary": "貨品彙總", | "Excel sheet: item summary": "貨品彙總", | ||||
| "JO count": "工單筆數", | "JO count": "工單筆數", | ||||
| "DO count": "送貨單筆數", | |||||
| "Process seq": "工序序號", | "Process seq": "工序序號", | ||||
| "Handler": "處理人", | "Handler": "處理人", | ||||
| "Goods Name": "貨品名稱", | "Goods Name": "貨品名稱", | ||||
| @@ -1,13 +1,338 @@ | |||||
| { | { | ||||
| "Report": "報告", | |||||
| "title": "報告管理", | "title": "報告管理", | ||||
| "selectReport": "選擇報告", | "selectReport": "選擇報告", | ||||
| "reportList": "報告列表", | "reportList": "報告列表", | ||||
| "selectReportHelper": "選擇報告", | "selectReportHelper": "選擇報告", | ||||
| "searchCriteria": "搜索條件", | "searchCriteria": "搜索條件", | ||||
| "searchCriteriaWithTitle": "搜索條件: {{title}}", | |||||
| "downloadPdf": "下載報告 (PDF)", | "downloadPdf": "下載報告 (PDF)", | ||||
| "downloadExcel": "下載報告 (Excel)", | "downloadExcel": "下載報告 (Excel)", | ||||
| "generatingPdf": "生成 PDF...", | "generatingPdf": "生成 PDF...", | ||||
| "generatingExcel": "生成 Excel...", | "generatingExcel": "生成 Excel...", | ||||
| "generatingReport": "生成報告..." | |||||
| "generatingReport": "生成報告...", | |||||
| "generateError": "產生報告時發生錯誤,請再試一次。", | |||||
| "noDataFound": "查無資料", | |||||
| "noDataFoundTitle": "查無資料", | |||||
| "noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。", | |||||
| "ok": "確定", | |||||
| "missingRequired": "缺少必填條件:\n- {{fields}}", | |||||
| "dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}", | |||||
| "selectOrEnterItemCode": "選擇或輸入物料編號", | |||||
| "cancel": "取消", | |||||
| "confirmDownloadPdf": "確認下載 PDF", | |||||
| "confirmDownloadExcel": "確認下載 Excel", | |||||
| "semiFgConfirmTitle": "已選擇的物料編號以及列印成品/半成品生產分析報告", | |||||
| "semiFgConfirmHint": "請確認以下已選擇的物料編號及其類別:", | |||||
| "semiFgColItem": "物料編號及名稱", | |||||
| "semiFgColCategory": "類別", | |||||
| "qcScopeHelpAll": "匯出全部 QC 檢驗項目(含溫度/濕度)。", | |||||
| "qcScopeHelpMeasurable": "僅匯出溫度/濕度 QC 項目(與 production 舊預設相同)。", | |||||
| "categories": { | |||||
| "inventory": "庫存管理", | |||||
| "inbound-outbound": "出入倉作業", | |||||
| "production": "生產與趨勢" | |||||
| }, | |||||
| "options": { | |||||
| "All": "全部", | |||||
| "all": "全部", | |||||
| "pending": "待盤點", | |||||
| "completed": "已審核", | |||||
| "success": "成功", | |||||
| "failed": "失敗", | |||||
| "measurable": "只包含溫度濕度" | |||||
| }, | |||||
| "reports": { | |||||
| "rep-004": { | |||||
| "title": "入倉追蹤報告", | |||||
| "fields": { | |||||
| "lastInDateStart": "入倉日期:由", | |||||
| "lastInDateEnd": "入倉日期:至", | |||||
| "itemCode": "貨品編號", | |||||
| "storeId": "樓層", | |||||
| "warehouse": "倉庫", | |||||
| "area": "區域", | |||||
| "slot": "儲位", | |||||
| "lotNo": "批號", | |||||
| "poPrefix": "PP/PF 分類" | |||||
| }, | |||||
| "options": { | |||||
| "storeId": { | |||||
| "All": "全部" | |||||
| }, | |||||
| "poPrefix": { | |||||
| "All": "全部", | |||||
| "PP": "PP", | |||||
| "PF": "PF" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-008": { | |||||
| "title": "成品出倉報告", | |||||
| "fields": { | |||||
| "lastOutDateStart": "出貨日期:由", | |||||
| "lastOutDateEnd": "出貨日期:至", | |||||
| "year": "年份", | |||||
| "itemCode": "貨品編號" | |||||
| } | |||||
| }, | |||||
| "rep-012": { | |||||
| "title": "庫存盤點報告", | |||||
| "fields": { | |||||
| "stockTakeRoundId": "盤點輪次(可多選)", | |||||
| "itemCode": "貨品編號", | |||||
| "store_id": "倉庫樓層", | |||||
| "status": "狀態", | |||||
| "type": "類型" | |||||
| }, | |||||
| "options": { | |||||
| "store_id": { | |||||
| "All": "全部" | |||||
| }, | |||||
| "status": { | |||||
| "All": "全部", | |||||
| "pending": "待盤點", | |||||
| "completed": "已審核" | |||||
| }, | |||||
| "type": { | |||||
| "All": "全部", | |||||
| "PP": "PP", | |||||
| "PF": "PF", | |||||
| "TOA": "TOA", | |||||
| "工廠生產": "工廠生產", | |||||
| "倉存調整": "倉存調整", | |||||
| "期初存貨": "期初存貨", | |||||
| "採購入倉": "採購入倉", | |||||
| "其他入倉": "其他入倉" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-011": { | |||||
| "title": "庫存明細報告", | |||||
| "fields": { | |||||
| "lastInDateStart": "庫存日期:由", | |||||
| "lastInDateEnd": "庫存日期:至", | |||||
| "itemCode": "貨品編號" | |||||
| } | |||||
| }, | |||||
| "rep-007": { | |||||
| "title": "庫存結餘報告", | |||||
| "fields": { | |||||
| "stockDate": "庫存日期", | |||||
| "itemCode": "貨品編號" | |||||
| } | |||||
| }, | |||||
| "rep-014": { | |||||
| "title": "PO入倉記錄報告", | |||||
| "fields": { | |||||
| "receiptDateStart": "收貨日期:由", | |||||
| "receiptDateEnd": "收貨日期:至", | |||||
| "itemCode": "貨品編號" | |||||
| } | |||||
| }, | |||||
| "rep-009": { | |||||
| "title": "成品出倉追蹤報告", | |||||
| "fields": { | |||||
| "lastOutDateStart": "出貨日期:由", | |||||
| "lastOutDateEnd": "出貨日期:至", | |||||
| "itemCode": "貨品編號", | |||||
| "handler": "提料員" | |||||
| } | |||||
| }, | |||||
| "rep-010": { | |||||
| "title": "庫存品質檢測報告", | |||||
| "fields": { | |||||
| "lastInDateStart": "QC 檢測日期:由", | |||||
| "lastInDateEnd": "QC 檢測日期:至", | |||||
| "qcType": "QC 類型", | |||||
| "itemCode": "貨品編號", | |||||
| "qcItemScope": "QC 項目範圍" | |||||
| }, | |||||
| "options": { | |||||
| "qcType": { | |||||
| "all": "全部", | |||||
| "IQC": "IQC(採購)", | |||||
| "EPQC": "EPQC(工單)" | |||||
| }, | |||||
| "qcItemScope": { | |||||
| "all": "全部 QC 項目", | |||||
| "measurable": "只包含溫度濕度" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-013": { | |||||
| "title": "貨品出倉追蹤報告", | |||||
| "fields": { | |||||
| "lastOutDateStart": "出倉日期:由", | |||||
| "lastOutDateEnd": "出倉日期:至", | |||||
| "itemCode": "貨品編號", | |||||
| "handler": "提料人" | |||||
| } | |||||
| }, | |||||
| "rep-006": { | |||||
| "title": "庫存材料消耗趨勢報告", | |||||
| "fields": { | |||||
| "lastOutDateStart": "材料消耗日期:由", | |||||
| "lastOutDateEnd": "材料消耗日期:至", | |||||
| "year": "年份", | |||||
| "stockCategory": "類別", | |||||
| "itemCode": "貨品編號" | |||||
| } | |||||
| }, | |||||
| "rep-005": { | |||||
| "title": "成品/半成品生產分析報告", | |||||
| "fields": { | |||||
| "lastOutDateStart": "完成生產日期:由", | |||||
| "lastOutDateEnd": "完成生產日期:至", | |||||
| "year": "年份", | |||||
| "stockCategory": "類別", | |||||
| "itemCode": "貨品編號" | |||||
| } | |||||
| }, | |||||
| "rep-015": { | |||||
| "title": "M18 BOM Shop 同步記錄", | |||||
| "fields": { | |||||
| "syncDateStart": "同步日期:由", | |||||
| "syncDateEnd": "同步日期:至", | |||||
| "finishedItemCode": "成品貨號", | |||||
| "syncStatus": "同步狀態" | |||||
| }, | |||||
| "options": { | |||||
| "syncStatus": { | |||||
| "all": "全部", | |||||
| "success": "成功", | |||||
| "failed": "失敗" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-016": { | |||||
| "title": "成品出倉揀貨合規報告", | |||||
| "fields": { | |||||
| "dateStart": "日期", | |||||
| "handler": "提料人", | |||||
| "ticketNo": "提票號碼", | |||||
| "itemCode": "貨品編號", | |||||
| "storeId": "樓層" | |||||
| } | |||||
| }, | |||||
| "rep-017": { | |||||
| "title": "店鋪訂單補貨記錄", | |||||
| "fields": { | |||||
| "shopOrderDateStart": "店鋪訂單日期:由", | |||||
| "shopOrderDateEnd": "店鋪訂單日期:至", | |||||
| "shopCode": "店鋪編號" | |||||
| } | |||||
| }, | |||||
| "rep-018": { | |||||
| "title": "送貨訂單與倉存單位不符報告", | |||||
| "fields": { | |||||
| "deliveryDate": "預計送貨日期", | |||||
| "storeId": "送貨訂單樓層" | |||||
| }, | |||||
| "options": { | |||||
| "storeId": { | |||||
| "All": "全部" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "rep-021": { | |||||
| "title": "庫存批次現況報告", | |||||
| "fields": { | |||||
| "itemCode": "貨品編號", | |||||
| "storeId": "樓層", | |||||
| "warehouse": "倉庫", | |||||
| "area": "區域", | |||||
| "slot": "儲位", | |||||
| "stockTakeSectionDescription": "盤點區域說明", | |||||
| "lotNo": "批號", | |||||
| "lotOrigin": "來源" | |||||
| }, | |||||
| "options": { | |||||
| "storeId": { | |||||
| "All": "全部" | |||||
| }, | |||||
| "stockTakeSectionDescription": { | |||||
| "All": "全部" | |||||
| }, | |||||
| "lotOrigin": { | |||||
| "All": "全部", | |||||
| "PP": "PP", | |||||
| "PF": "PF", | |||||
| "other": "其他" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "excel": { | |||||
| "noData": "(篩選範圍內無資料)", | |||||
| "grn": { | |||||
| "sheetDetail": "PO入倉記錄", | |||||
| "sheetListedPo": "已上架PO金額", | |||||
| "noCompletedPo": "(篩選範圍內無已完成之 PO 行)", | |||||
| "categoryCurrencyTotal": "貨幣小計", | |||||
| "categoryPo": "訂單", | |||||
| "poNo": "訂單編號", | |||||
| "deliveryNoteNo": "送貨單編號", | |||||
| "receiptDate": "收貨日期", | |||||
| "itemCode": "物料編號", | |||||
| "itemName": "物料名稱", | |||||
| "qty": "數量", | |||||
| "demandQty": "訂單數量", | |||||
| "uom": "單位", | |||||
| "supplierLotNo": "供應商批次", | |||||
| "expiryDate": "到期日", | |||||
| "supplierCode": "供應商編號", | |||||
| "supplier": "供應商", | |||||
| "status": "入倉狀態", | |||||
| "unitPrice": "單價", | |||||
| "currency": "貨幣", | |||||
| "amount": "金額", | |||||
| "grnCode": "M18 入倉單號", | |||||
| "grnId": "M18 記錄編號", | |||||
| "poCreator": "PO建立者(M18)", | |||||
| "note": "備註", | |||||
| "category": "類別", | |||||
| "totalAmount": "金額", | |||||
| "grnCodes": "M18 入倉單號" | |||||
| }, | |||||
| "bomSync": { | |||||
| "sheetSync": "BOM同步記錄", | |||||
| "sheetMaterials": "BOM物料明細", | |||||
| "syncTime": "同步時間", | |||||
| "finishedItemCode": "成品貨號", | |||||
| "finishedItemName": "成品名稱", | |||||
| "bomRoutingCode": "BOM路由編號", | |||||
| "version": "版本", | |||||
| "status": "狀態", | |||||
| "failureReason": "失敗原因", | |||||
| "message": "訊息", | |||||
| "lineNo": "行號", | |||||
| "materialName": "物料名稱", | |||||
| "uom": "單位", | |||||
| "qty": "用量", | |||||
| "statusSuccess": "成功", | |||||
| "statusSkipped": "略過(內容未變)", | |||||
| "statusFailed": "失敗" | |||||
| }, | |||||
| "shopReplenishment": { | |||||
| "sheetName": "店鋪訂單補貨記錄", | |||||
| "shopCode": "店鋪編號", | |||||
| "shopName": "店鋪名稱", | |||||
| "shopOrderDate": "店鋪訂單日期", | |||||
| "shopOrderNo": "店鋪訂單編號", | |||||
| "itemCode": "貨品編號", | |||||
| "itemName": "貨品名稱", | |||||
| "firstOrderQty": "原訂單數量", | |||||
| "firstOrderActualPickQty": "原單實際提料數量", | |||||
| "firstOrderPicker": "原單提料人", | |||||
| "reorderQty": "補貨數量", | |||||
| "reorderDate": "補貨日期", | |||||
| "reason": "補貨原因", | |||||
| "actualDeliveredQty": "實際補貨數量", | |||||
| "actualDeliveredHandler": "實際補貨提料人", | |||||
| "deliveredDate": "送貨日期", | |||||
| "reasonQuality": "質素問題", | |||||
| "reasonOutOfStock": "缺貨", | |||||
| "reasonOther": "其他" | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -46,5 +46,12 @@ | |||||
| "Failed to search by name": "依名稱搜尋失敗", | "Failed to search by name": "依名稱搜尋失敗", | ||||
| "Failed to search by username": "依使用者名稱搜尋失敗", | "Failed to search by username": "依使用者名稱搜尋失敗", | ||||
| "Staff No is required": "員工編號必填", | "Staff No is required": "員工編號必填", | ||||
| "User Not Found": "用戶不存在" | |||||
| "User Not Found": "用戶不存在", | |||||
| "Username is already taken": "用戶名稱已被使用,請換一個。", | |||||
| "Name is already taken": "姓名已被使用,請換一個。", | |||||
| "Staff No is already taken": "員工編號已被使用,請換一個。", | |||||
| "New password does not meet the rules": "新密碼不符合規則,請重新輸入。", | |||||
| "Invalid request. Please check your input": "請求資料不正確,請檢查後再試。", | |||||
| "Unauthorized or no permission": "未授權或沒有權限。", | |||||
| "Server error. Please try again later": "伺服器錯誤,請稍後再試。" | |||||
| } | } | ||||