From 59c464ae3fecbb898be92b66afeba51522bb96b0 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 10 Jun 2026 12:35:59 +0800 Subject: [PATCH] job order bom status and do merge --- src/app/api/bom/client.ts | 11 +- src/app/api/bom/index.ts | 5 + src/app/api/doworkbench/actions.ts | 59 ++- src/components/DoSearch/DoSearch.tsx | 28 +- .../DoSearchWorkbench/DoSearchWorkbench.tsx | 22 +- .../DoWorkbench/DoWorkbenchTabs.tsx | 30 +- .../DoWorkbench/WorkbenchEtraMergeDialog.tsx | 453 ++++++++++++++++++ .../DoWorkbench/WorkbenchFloorLanePanel.tsx | 19 + .../DoWorkbench/workbenchLanePanelPrefs.ts | 2 +- .../ImportBom/ImportBomDetailTab.tsx | 89 +++- .../WorkbenchPickExecution.tsx | 321 ++++++++++--- src/i18n/en/do.json | 1 + src/i18n/en/importBom.json | 17 +- src/i18n/en/navigation.json | 2 + src/i18n/en/pickOrder.json | 21 + src/i18n/zh/do.json | 20 + src/i18n/zh/importBom.json | 22 +- src/i18n/zh/navigation.json | 2 + src/i18n/zh/pickOrder.json | 27 +- src/utils/workbenchPickLotUtils.ts | 16 +- src/utils/workbenchReleaseType.ts | 3 +- 21 files changed, 1052 insertions(+), 118 deletions(-) create mode 100644 src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts index 2ec626a..8d1db36 100644 --- a/src/app/api/bom/client.ts +++ b/src/app/api/bom/client.ts @@ -75,9 +75,16 @@ export const fetchBomScoresClient = async (): Promise => { return response.data; }; -export async function fetchBomComboClient(): Promise { +export async function fetchBomComboClient(options?: { + includeInactive?: boolean; +}): Promise { const response = await axiosInstance.get( - `${NEXT_PUBLIC_API_URL}/bom/combo` + `${NEXT_PUBLIC_API_URL}/bom/combo`, + { + params: { + includeInactive: options?.includeInactive ?? false, + }, + }, ); return response.data; } diff --git a/src/app/api/bom/index.ts b/src/app/api/bom/index.ts index e6aef7b..89ff2a4 100644 --- a/src/app/api/bom/index.ts +++ b/src/app/api/bom/index.ts @@ -2,6 +2,8 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; +export type BomStatus = "active" | "inactive"; + export interface BomCombo { id: number; value: number; @@ -9,6 +11,7 @@ export interface BomCombo { outputQty: number; outputQtyUom: string; description: string; + status?: BomStatus; } export type BomComboIssueCode = @@ -118,6 +121,7 @@ export interface BomDetailResponse { description?: string; outputQty?: number; outputQtyUom?: string; + status?: BomStatus; materials: BomMaterialDto[]; processes: BomProcessDto[]; } @@ -139,6 +143,7 @@ export interface EditBomRequest { complexity?: number; isDrink?: boolean; isPowderMixture?: boolean; + status?: BomStatus; materials?: EditBomMaterialRequest[]; processes?: EditBomProcessRequest[]; diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index ab61c4d..b9353cd 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -41,13 +41,14 @@ export async function startWorkbenchBatchReleaseAsync(data: { export async function startWorkbenchBatchReleaseAsyncV2(data: { ids: number[]; userId: number; + mergeExtraIntoLaneTicket?: boolean; }): Promise { - const { ids, userId } = data; + const { ids, userId, mergeExtraIntoLaneTicket = true } = data; return serverFetchJson( `${BASE_API_URL}/doPickOrder/workbench/batch-release/async-v2?userId=${userId}`, { method: "POST", - body: JSON.stringify(ids), + body: JSON.stringify({ ids, mergeExtraIntoLaneTicket }), headers: { "Content-Type": "application/json" }, } ); @@ -275,6 +276,60 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( return Array.isArray(response) ? response : []; } +export type WorkbenchMergeTicketCandidate = { + id: number; + ticketNo: string | null; + releaseType: string | null; + shopId: number | null; + shopCode: string | null; + shopName: string | null; + storeId: string | null; + truckId: number | null; + requiredDeliveryDate: string | null; + truckLanceCode: string | null; + truckDepartureTime: string | null; + loadingSequence: number | null; + deliveryOrderCodes: string[]; + laneKey: string; +}; + +export type WorkbenchMergeTicketCandidatesResponse = { + batchFamilyTickets: WorkbenchMergeTicketCandidate[]; + isExtraTickets: WorkbenchMergeTicketCandidate[]; +}; + +export async function fetchWorkbenchMergeTicketCandidates(data: { + requiredDate: string; + shopSearch?: string; +}): Promise { + const params = new URLSearchParams(); + params.set("requiredDate", data.requiredDate); + if (data.shopSearch?.trim()) params.set("shopSearch", data.shopSearch.trim()); + const url = `${BASE_API_URL}/doPickOrder/workbench/merge-ticket-candidates?${params.toString()}`; + const res = await serverFetchJson(url, { + method: "GET", + cache: "no-store", + }); + return { + batchFamilyTickets: res?.batchFamilyTickets ?? [], + isExtraTickets: res?.isExtraTickets ?? [], + }; +} + +export async function mergeWorkbenchTickets(data: { + batchOrSingleDopoId: number; + isExtraDopoId: number; +}): Promise { + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/merge-tickets`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }, + ); +} + /** Same body as `/doPickOrder/assign-by-lane` but resolves `delivery_order_pick_order`. */ export async function assignWorkbenchByLane(data: { userId: number; diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index 31beb22..67a6589 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -572,12 +572,15 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea return; } + const showMergeExtraOption = isWorkbench && activeTab === "ETRA"; + const mergeCheckboxDefault = false; + // 显示确认对话框 const result = await Swal.fire({ icon: "question", title: t("Batch Release"), html: ` -
+

${t("Selected Shop(s): ")}${idsToRelease.length}

@@ -586,20 +589,39 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} ${status ? `${t("Status")}: ${t(status)} ` : ""}

+ ${ + showMergeExtraOption + ? `` + : "" + }
`, showCancelButton: true, confirmButtonText: t("Confirm"), cancelButtonText: t("Cancel"), confirmButtonColor: "#8dba00", - cancelButtonColor: "#F04438" + cancelButtonColor: "#F04438", + preConfirm: () => { + if (!showMergeExtraOption) return { mergeExtraIntoLaneTicket: true }; + const el = document.getElementById("mergeExtraIntoLaneTicket") as HTMLInputElement | null; + return { mergeExtraIntoLaneTicket: el?.checked ?? mergeCheckboxDefault }; + }, }); if (result.isConfirmed) { try { let startRes ; + const mergeExtraIntoLaneTicket = + (result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? true; if(isWorkbench){ - startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, userId: currentUserId ?? 1 }); + startRes = await startWorkbenchBatchReleaseAsyncV2({ + ids: idsToRelease, + userId: currentUserId ?? 1, + mergeExtraIntoLaneTicket, + }); } else{ startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); diff --git a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx index 8f979b2..3814770 100644 --- a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx +++ b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx @@ -625,27 +625,41 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { icon: "question", title: t("Batch Release"), html: ` -
+

${t("Selected Shop(s): ")}${idsToRelease.length}

${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""} ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} - ${status ? `${t("Status")}: ${status} ` : ""} + ${status ? `${t("Status")}: ${t(status)} ` : ""}

+
`, showCancelButton: true, confirmButtonText: t("Confirm"), cancelButtonText: t("Cancel"), confirmButtonColor: "#8dba00", - cancelButtonColor: "#F04438" + cancelButtonColor: "#F04438", + preConfirm: () => { + const el = document.getElementById("mergeExtraIntoLaneTicket") as HTMLInputElement | null; + return { mergeExtraIntoLaneTicket: el?.checked ?? false }; + }, }); if (result.isConfirmed) { try { - const startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, userId: currentUserId ?? 1 }); + const mergeExtraIntoLaneTicket = + (result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? false; + const startRes = await startWorkbenchBatchReleaseAsyncV2({ + ids: idsToRelease, + userId: currentUserId ?? 1, + mergeExtraIntoLaneTicket, + }); const startEntity = startRes?.entity as { jobId?: string } | undefined; const jobId = startEntity?.jobId; diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 0e2824b..67eb27a 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -33,10 +33,12 @@ import { DEFAULT_WORKBENCH_LANE_PANEL_PREFS, type WorkbenchLanePanelPrefs, } from "./workbenchLanePanelPrefs"; +import { + normalizeWorkbenchTabFromUrl, + WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE, +} from "./workbenchTabConstants"; -const ALLOWED_WORKBENCH_TABS = new Set([0, 1, 2, 3, 4, 5, 6]); - -/** Backend Etra summary: each lane `total` = distinct incomplete (`pending`/`released`) `delivery_order_pick_order` rows for that day. */ +/** Backend Etra summary: each lane `total` = incomplete (`pending`/`released`) tickets for that day. */ function sumIncompleteEtraDopoTickets(groups: WorkbenchEtraShopLaneGroup[]): number { let n = 0; for (const g of groups) { @@ -83,7 +85,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom const [labelPrinter, setLabelPrinter] = React.useState(null); const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0); - const { t } = useTranslation( ); + const { t } = useTranslation(); + const a4Printers = React.useMemo( () => (printerCombo || []).filter((printer) => printer.type === "A4"), [printerCombo], @@ -124,7 +127,6 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom return () => window.removeEventListener("pickOrderAssigned", onAssigned); }, [refreshWorkbenchCounts]); - /** Opening Etra tab refreshes badge (completion does not always dispatch `pickOrderAssigned`). */ const etraTabMountSkipRef = React.useRef(false); React.useEffect(() => { if (!etraTabMountSkipRef.current) { @@ -137,8 +139,10 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom React.useEffect(() => { if (urlTabStr == null || urlTabStr === "") return; const n = parseInt(urlTabStr, 10); - if (!Number.isNaN(n) && ALLOWED_WORKBENCH_TABS.has(n)) { - setTab(n); + if (Number.isNaN(n)) return; + const normalized = normalizeWorkbenchTabFromUrl(n); + if (normalized != null) { + setTab(normalized); } }, [urlTabStr]); @@ -147,8 +151,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom setTab(newTab); const params = new URLSearchParams(searchParams.toString()); params.set("tab", String(newTab)); - /* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */ - if (newTab !== 2) { + if (newTab !== WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE) { params.delete("ticketNo"); params.delete("targetDate"); } @@ -283,10 +286,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom /> )} /> - @@ -300,7 +300,6 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom columnGap: 2, rowGap: 1, }, - /* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */ "& .MuiTab-root": { overflow: "visible", minWidth: "auto", @@ -313,7 +312,6 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom value={1} sx={{ overflow: "visible", - /* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */ pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, }} label={ @@ -404,7 +402,6 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom - ); }; @@ -422,4 +419,3 @@ const DoWorkbenchTabs: React.FC = (props) => ( ); export default DoWorkbenchTabs; - diff --git a/src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx b/src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx new file mode 100644 index 0000000..d4bbc61 --- /dev/null +++ b/src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx @@ -0,0 +1,453 @@ +"use client"; + +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MobileStepper, + Paper, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { KeyboardArrowLeft, KeyboardArrowRight } from "@mui/icons-material"; +import dayjs from "dayjs"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Swal from "sweetalert2"; +import { + fetchWorkbenchMergeTicketCandidates, + mergeWorkbenchTickets, + type WorkbenchMergeTicketCandidate, +} from "@/app/api/doworkbench/actions"; + +const SWAL_ABOVE_DIALOG = { + customClass: { container: "swal2-custom-zindex" }, +} as const; + +type Props = { + open: boolean; + onClose: () => void; + /** YYYY-MM-DD when dialog opens (from Etra tab date). */ + initialDateYmd: string; + onMerged?: () => void; +}; + +function floorLabel(storeId: string | null | undefined, t: (k: string) => string): string { + const s = (storeId ?? "").trim().replace(/\//g, "").toUpperCase(); + if (s === "2F") return "2/F"; + if (s === "4F") return "4/F"; + if (!s) return t("Truck X"); + return storeId ?? ""; +} + +function shopPrimary(c: WorkbenchMergeTicketCandidate, t: (k: string) => string): string { + const code = (c.shopCode ?? "").trim(); + const name = (c.shopName ?? "").trim(); + if (code && name && code !== name) return `${code} · ${name}`; + return name || code || t("Shop"); +} + +function laneSecondary(c: WorkbenchMergeTicketCandidate, t: (k: string) => string): string { + const dep = (c.truckDepartureTime ?? "").trim(); + const lane = (c.truckLanceCode ?? "").trim(); + const floor = floorLabel(c.storeId, t); + const is4F = (c.storeId ?? "").trim().replace(/\//g, "").toUpperCase() === "4F"; + const seq = + is4F && c.loadingSequence != null + ? `${t("Loading sequence")} ${c.loadingSequence} · ` + : ""; + return `${seq}${dep ? `${dep} ` : ""}${lane} (${floor})`; +} + +type CarouselProps = { + title: string; + items: WorkbenchMergeTicketCandidate[]; + selectedId: number | null; + onSelect: (id: number) => void; + emptyText: string; + accent: "primary" | "secondary"; +}; + +const MERGE_TICKETS_PER_PAGE = 3; + +function MergeTicketCard({ + ticket, + selected, + accent, + onSelect, +}: { + ticket: WorkbenchMergeTicketCandidate; + selected: boolean; + accent: "primary" | "secondary"; + onSelect: (id: number) => void; +}) { + const { t } = useTranslation("pickOrder"); + return ( + onSelect(ticket.id)} + sx={{ + p: 1.5, + cursor: "pointer", + borderWidth: selected ? 2 : 1, + borderColor: selected ? `${accent}.main` : "divider", + bgcolor: "background.paper", + }} + > + + + + {ticket.ticketNo ?? `#${ticket.id}`} + + + + + + {shopPrimary(ticket, t)} + + + {laneSecondary(ticket, t)} + + + {ticket.deliveryOrderCodes.length > 0 + ? `${t("Delivery Order Code")}: ${ticket.deliveryOrderCodes.join(", ")}` + : t("No delivery orders on ticket")} + + + + ); +} + +function MergeTicketCarousel({ + title, + items, + selectedId, + onSelect, + emptyText, + accent, +}: CarouselProps) { + const { t } = useTranslation("pickOrder"); + const [page, setPage] = useState(0); + + const pageCount = Math.max(1, Math.ceil(items.length / MERGE_TICKETS_PER_PAGE)); + + const pageItems = useMemo(() => { + const start = page * MERGE_TICKETS_PER_PAGE; + return items.slice(start, start + MERGE_TICKETS_PER_PAGE); + }, [items, page]); + + useEffect(() => { + setPage(0); + }, [items]); + + useEffect(() => { + if (items.length === 0 || selectedId == null) return; + const idx = items.findIndex((x) => x.id === selectedId); + if (idx >= 0) setPage(Math.floor(idx / MERGE_TICKETS_PER_PAGE)); + }, [selectedId, items]); + + useEffect(() => { + if (page > pageCount - 1) setPage(Math.max(0, pageCount - 1)); + }, [page, pageCount]); + + return ( + + + {title} + + {items.length === 0 ? ( + + + {emptyText} + + + ) : ( + <> + + {pageItems.map((ticket) => ( + + ))} + + {pageCount > 1 && ( + <> + = pageCount - 1} + onClick={() => setPage((p) => Math.min(p + 1, pageCount - 1))} + > + {t("Next")} + + + } + backButton={ + + } + /> + + {page + 1} / {pageCount} + + + )} + + )} + + ); +} + +const WorkbenchEtraMergeDialog: React.FC = ({ + open, + onClose, + initialDateYmd, + onMerged, +}) => { + const { t } = useTranslation("pickOrder"); + const [shopSearch, setShopSearch] = useState(""); + const [requiredDate, setRequiredDate] = useState(initialDateYmd); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [batchTickets, setBatchTickets] = useState([]); + const [extraTickets, setExtraTickets] = useState([]); + const [selectedBatchId, setSelectedBatchId] = useState(null); + const [selectedExtraId, setSelectedExtraId] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + + const selectedBatch = useMemo( + () => batchTickets.find((x) => x.id === selectedBatchId) ?? null, + [batchTickets, selectedBatchId], + ); + + const filteredExtraTickets = useMemo(() => { + if (!selectedBatch) return []; + return extraTickets.filter((x) => x.laneKey === selectedBatch.laneKey); + }, [extraTickets, selectedBatch]); + + const loadCandidates = useCallback(async () => { + setLoading(true); + try { + const data = await fetchWorkbenchMergeTicketCandidates({ + requiredDate, + shopSearch: shopSearch.trim() || undefined, + }); + setBatchTickets(data.batchFamilyTickets); + setExtraTickets(data.isExtraTickets); + setSelectedBatchId(null); + setSelectedExtraId(null); + setHasSearched(true); + } catch (e) { + console.error("fetchWorkbenchMergeTicketCandidates:", e); + setBatchTickets([]); + setExtraTickets([]); + setHasSearched(true); + await Swal.fire({ + ...SWAL_ABOVE_DIALOG, + icon: "error", + title: t("Error"), + text: e instanceof Error ? e.message : t("Merge Etra ticket search failed"), + }); + } finally { + setLoading(false); + } + }, [requiredDate, shopSearch]); + + useEffect(() => { + if (!open) return; + setRequiredDate(initialDateYmd); + setShopSearch(""); + setSelectedBatchId(null); + setSelectedExtraId(null); + setBatchTickets([]); + setExtraTickets([]); + setHasSearched(false); + }, [open, initialDateYmd]); + + useEffect(() => { + if (selectedExtraId == null) return; + if (!filteredExtraTickets.some((x) => x.id === selectedExtraId)) { + setSelectedExtraId(null); + } + }, [filteredExtraTickets, selectedExtraId]); + + const canConfirm = selectedBatchId != null && selectedExtraId != null && !submitting; + + const handleConfirm = async () => { + if (!canConfirm || selectedBatchId == null || selectedExtraId == null) return; + const batch = batchTickets.find((x) => x.id === selectedBatchId); + const extra = filteredExtraTickets.find((x) => x.id === selectedExtraId); + if (!batch || !extra) return; + + const confirm = await Swal.fire({ + ...SWAL_ABOVE_DIALOG, + icon: "question", + title: t("Merge Etra ticket confirm title"), + html: ` +
+

${t("Batch/Single ticket")}: ${batch.ticketNo ?? batch.id}

+

${t("Etra ticket")}: ${extra.ticketNo ?? extra.id}

+

${t("Merge Etra ticket confirm hint")}

+
+ `, + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438", + }); + if (!confirm.isConfirmed) return; + + setSubmitting(true); + try { + const res = await mergeWorkbenchTickets({ + batchOrSingleDopoId: selectedBatchId, + isExtraDopoId: selectedExtraId, + }); + if ((res.code ?? "").toUpperCase() !== "SUCCESS") { + await Swal.fire({ + ...SWAL_ABOVE_DIALOG, + icon: "error", + title: t("Error"), + text: res.message ?? t("Merge Etra ticket failed"), + }); + return; + } + await Swal.fire({ + ...SWAL_ABOVE_DIALOG, + icon: "success", + title: t("Merge Etra ticket success"), + }); + onMerged?.(); + onClose(); + } catch (e) { + console.error("mergeWorkbenchTickets:", e); + await Swal.fire({ + ...SWAL_ABOVE_DIALOG, + icon: "error", + title: t("Error"), + text: t("Merge Etra ticket failed"), + }); + } finally { + setSubmitting(false); + } + }; + + return ( + + {t("Merge Etra ticket dialog title")} + + + + setShopSearch(e.target.value)} + placeholder={t("Shop Name")} + disabled={loading} + /> + setRequiredDate(e.target.value || dayjs().format("YYYY-MM-DD"))} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + disabled={loading} + /> + + + + + {t("Merge Etra ticket lane hint")} + + + {loading ? ( + + + + ) : ( + + { + setSelectedBatchId(id); + setSelectedExtraId(null); + }} + emptyText={ + !hasSearched + ? t("Merge Etra ticket search prompt") + : t("No mergeable batch tickets") + } + accent="primary" + /> + + + )} + + + + + + + + ); +}; + +export default WorkbenchEtraMergeDialog; diff --git a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx index 54132c1..0c1e3f3 100644 --- a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx +++ b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx @@ -22,6 +22,7 @@ import { DEFAULT_WORKBENCH_LANE_PANEL_PREFS, type WorkbenchLanePanelPrefs, } from "./workbenchLanePanelPrefs"; +import WorkbenchEtraMergeDialog from "./WorkbenchEtraMergeDialog"; interface Props { onPickOrderAssigned?: () => void; @@ -86,6 +87,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ const [modalInitialShopSearch, setModalInitialShopSearch] = useState(undefined); const defaultTruckCount = summary4F?.defaultTruckCount ?? 0; const etraEnterInFlightRef = useRef(false); + const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false); const inEtraUi = useMemo(() => etraOnly || isExtraView, [etraOnly, isExtraView]); @@ -393,6 +395,13 @@ const WorkbenchFloorLanePanel: React.FC = ({ + {inEtraUi && ( + + + + )} {!inEtraUi && ( <> @@ -748,6 +757,16 @@ const WorkbenchFloorLanePanel: React.FC = ({ onSwitchToDetailTab?.(); }} /> + setEtraMergeDialogOpen(false)} + initialDateYmd={selectedDeliveryDateYmd} + onMerged={() => { + void loadEtraSummaries(); + onPickOrderAssigned?.(); + window.dispatchEvent(new Event("pickOrderAssigned")); + }} + /> ); }; diff --git a/src/components/DoWorkbench/workbenchLanePanelPrefs.ts b/src/components/DoWorkbench/workbenchLanePanelPrefs.ts index 5e83df9..88c9b13 100644 --- a/src/components/DoWorkbench/workbenchLanePanelPrefs.ts +++ b/src/components/DoWorkbench/workbenchLanePanelPrefs.ts @@ -1,7 +1,7 @@ export type WorkbenchLaneDateKey = "today" | "tomorrow" | "dayAfterTomorrow"; export type WorkbenchLaneFloor = "2/F" | "4/F"; -/** Tab 0/1 lane assignment filters — lifted to DoWorkbenchTabs so they survive tab switches. */ +/** Tab 0 lane assignment filters — lifted to DoWorkbenchTabs so they survive tab switches. */ export type WorkbenchLanePanelPrefs = { selectedDate: WorkbenchLaneDateKey; ticketFloor: WorkbenchLaneFloor; diff --git a/src/components/ImportBom/ImportBomDetailTab.tsx b/src/components/ImportBom/ImportBomDetailTab.tsx index 3f89316..3467f12 100644 --- a/src/components/ImportBom/ImportBomDetailTab.tsx +++ b/src/components/ImportBom/ImportBomDetailTab.tsx @@ -22,7 +22,7 @@ import { FormControlLabel, IconButton, } from "@mui/material"; -import type { BomCombo, BomDetailResponse } from "@/app/api/bom"; +import type { BomCombo, BomDetailResponse, BomStatus } from "@/app/api/bom"; import { editBomClient, fetchBomComboClient, @@ -60,6 +60,9 @@ function resolveEquipmentCode( return byPair?.code ?? null; } +/** Full BOM field edit (materials/processes) — hidden until re-enabled. */ +const SHOW_BOM_FULL_EDIT = false; + const ImportBomDetailTab: React.FC = () => { const { t } = useTranslation(["importBom", "common"]); const [bomList, setBomList] = useState([]); @@ -130,6 +133,9 @@ const ImportBomDetailTab: React.FC = () => { ProcessMasterRow[] >([]); const [editMasterLoading, setEditMasterLoading] = useState(false); + const [statusDraft, setStatusDraft] = useState("active"); + const [statusSaving, setStatusSaving] = useState(false); + const [statusError, setStatusError] = useState(null); // Process add form (uses dropdown selections from master tables). const [processAddForm, setProcessAddForm] = useState<{ @@ -178,7 +184,7 @@ const ImportBomDetailTab: React.FC = () => { const loadList = async () => { setLoadingList(true); try { - const list = await fetchBomComboClient(); + const list = await fetchBomComboClient({ includeInactive: true }); setBomList(list); } finally { setLoadingList(false); @@ -209,6 +215,8 @@ const ImportBomDetailTab: React.FC = () => { try { const d = await fetchBomDetailClient(id); setDetail(d); + setStatusDraft(d.status ?? "active"); + setStatusError(null); } finally { setLoadingDetail(false); loadDetailInFlightRef.current = false; @@ -273,6 +281,38 @@ const ImportBomDetailTab: React.FC = () => { if (v === "WIP") return "半成品"; return "-"; }; + + const renderBomStatus = (v?: BomStatus | string) => { + if (v === "active") return t("BOM Status Active"); + if (v === "inactive") return t("BOM Status Inactive"); + return "-"; + }; + + const handleSaveStatus = useCallback(async () => { + if (!detail?.id) return; + setStatusSaving(true); + setStatusError(null); + try { + const updated = await editBomClient(detail.id, { status: statusDraft }); + setDetail(updated); + setStatusDraft(updated.status ?? statusDraft); + setBomList((prev) => + prev.map((b) => + b.id === updated.id ? { ...b, status: updated.status ?? statusDraft } : b, + ), + ); + setCurrentBom((prev) => + prev?.id === updated.id + ? { ...prev, status: updated.status ?? statusDraft } + : prev, + ); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to update BOM status"; + setStatusError(message); + } finally { + setStatusSaving(false); + } + }, [detail?.id, statusDraft]); const renderComplexity = (v?: number) => { if (v === 10) return "簡單"; @@ -576,7 +616,7 @@ const ImportBomDetailTab: React.FC = () => { disabled={loadingDetail} onClick={() => void loadBomDetail(b.id)} > - {String(b.label ?? b.id)} ({renderType(b.description)}) + {String(b.label ?? b.id)} ({renderType(b.description)}, {renderBomStatus(b.status)}) ))} @@ -606,7 +646,7 @@ const ImportBomDetailTab: React.FC = () => { {t("Basic Info")} - {!isEditing ? ( + {SHOW_BOM_FULL_EDIT && !isEditing ? ( - ) : ( + ) : SHOW_BOM_FULL_EDIT ? ( - )} + ) : null} {editError && ( @@ -653,12 +693,43 @@ const ImportBomDetailTab: React.FC = () => { )} {!isEditing && ( - + + + + {t("BOM Status")} + + + + + {statusError && ( + + {statusError} + + )} + {/* 第一行:輸出數量 + 類型 */} {t("Output Quantity")}: {detail.outputQty} {detail.outputQtyUom} {" "} {t("Type")}: {detail.description ?? "-"} + {" "} + {t("BOM Status")}: {renderBomStatus(detail.status)} {/* 第二行:各種指標,排成一行 key:value, key:value */} @@ -1050,7 +1121,7 @@ const ImportBomDetailTab: React.FC = () => { {t("Sequence")} {t("Process Name")} {t("Process Description")} - {t("Process Code")} + {/* {t("Process Code")}*/} 設備(說明/名稱) {t("Duration (Minutes)")} {t("Prep Time (Minutes)")} @@ -1226,7 +1297,7 @@ const ImportBomDetailTab: React.FC = () => { {p.seqNo} {p.processName} {p.processDescription} - {p.processCode ?? "-"} + {/*{p.processCode ?? "-"}*/} {p.equipmentCode ?? p.equipmentName} {p.durationInMinute} {p.prepTimeInMinute} diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index 134b991..b89a416 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -41,6 +41,18 @@ import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLa import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; import ScanStatusAlert from "@/components/common/ScanStatusAlert"; +import { + buildUnpickableScanRowPatch, + getWorkbenchSourceLotStatusSummary, + inferUnpickableScanAvailability, + isExpiredWorkbenchReminderMessage, + isInventoryLotLineUnavailable, + isLotAvailabilityExpired, + isWorkbenchSourceLotExpired, + isWorkbenchZeroCompleteLot, + translateWorkbenchRejectMessage, + type UnpickableScanAvailability, +} from "@/utils/workbenchPickLotUtils"; dayjs.extend(arraySupport); @@ -201,20 +213,6 @@ const isCheckedStatus = (status: string | undefined): boolean => const isRejectedStatus = (status: string | undefined): boolean => String(status || "").toLowerCase() === "rejected"; -const isInventoryLotLineUnavailable = (row: LotRow): boolean => { - const solSt = String(row.status || "").toLowerCase(); - if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") return false; - if (String(row.lotAvailability || "").toLowerCase() === "status_unavailable") return true; - return String(row.lotStatus || "").toLowerCase() === "unavailable"; -}; - -const isLotExpired = (row: LotRow): boolean => { - if (String(row.lotAvailability || "").toLowerCase() === "expired") return true; - if (!row.expiryDate) return false; - const d = dayjs(row.expiryDate).startOf("day"); - return d.isValid() && d.isBefore(dayjs().startOf("day")); -}; - const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => { const c = String(code || "").toUpperCase(); const m = String(message || ""); @@ -324,6 +322,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = useState(null); const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = useState<{ itemId: number; stockInLineId: number } | null>(null); + const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] = useState(null); const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false); const [lotConfirmationError, setLotConfirmationError] = useState(null); const [expectedLotData, setExpectedLotData] = useState(null); @@ -360,10 +359,16 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { for (const row of lotRows) { const itemId = Number(row.itemId); const stockInLineId = Number(row.stockInLineId); + const isRejected = isRejectedStatus(row.status) || String(row.lotAvailability || "").toLowerCase() === "rejected"; + const isUnavailable = isInventoryLotLineUnavailable(row); + const isExpired = isLotAvailabilityExpired(row) || isWorkbenchSourceLotExpired(row); const isActive = row.stockOutLineId > 0 && !isCompletedStatus(row.status) && - !isCheckedStatus(row.status); + !isCheckedStatus(row.status) && + !isRejected && + !isUnavailable && + !isExpired; if (Number.isFinite(itemId) && itemId > 0) { if (!byItemId.has(itemId)) byItemId.set(itemId, []); @@ -649,6 +654,29 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { [hasQtyOverrideBySolId, resolveSingleSubmitQty], ); + const resolveLockedSubmitQtyDisplay = useCallback( + (lot: LotRow): number => { + if (isWorkbenchZeroCompleteLot(lot)) return 0; + return resolveSingleSubmitQty(lot); + }, + [resolveSingleSubmitQty], + ); + + const workbenchLotLabelStatusBanner = useMemo(() => { + if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) { + return { + text: undefined as string | undefined, + severity: undefined as "success" | "warning" | "error" | undefined, + }; + } + const reminder = workbenchLotLabelReminderText?.trim() ?? ""; + if (reminder && isExpiredWorkbenchReminderMessage(reminder)) { + return { text: "此批號狀態:已過期", severity: "error" as const }; + } + const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); + return { text: s.text, severity: s.severity }; + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot, workbenchLotLabelReminderText]); + const handleJustComplete = useCallback( async (row: LotRow) => { if (!row.stockOutLineId) { @@ -657,36 +685,20 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { } const lotNo = String(row.lotNo || "").trim(); - const isUnavailable = isInventoryLotLineUnavailable(row); - const isExpired = isLotExpired(row); + const isZeroComplete = isWorkbenchZeroCompleteLot(row); const hasExplicitOverride = hasQtyOverrideBySolId(row.stockOutLineId); const explicitQty = hasExplicitOverride ? Number(qtyBySolId[row.stockOutLineId]) : NaN; const qtyPayload = workbenchScanPickQtyFromLot(row); const wbJustQty = qtyPayload.qty; const canPostScanPick = - isUnavailable || + isZeroComplete || (lotNo !== "" && ((hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) || (wbJustQty != null && wbJustQty > 0))); if (!canPostScanPick) { - const msg = t( - "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.", - ); - setError(msg); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(msg); - }); - return; - } - - if (isExpired && !isUnavailable) { - const msg = t( - "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.", - ); + const msg = t("Just Completed (workbench): requires a valid lot number and quantity."); setError(msg); startTransition(() => { setQrScanError(true); @@ -697,7 +709,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { } const qtyToSend = - isUnavailable || (hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) + isZeroComplete || (hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) ? 0 : Number(wbJustQty); @@ -743,18 +755,82 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { [loadLineDetailV2, selectedPickOrderLineId], ); - const openWorkbenchLotLabelModalForLot = useCallback((lot: LotRow) => { - const itemId = Number(lot.itemId); - const stockInLineId = Number(lot.stockInLineId); - setWorkbenchLotLabelContextLot(lot); - if (Number.isFinite(itemId) && itemId > 0 && Number.isFinite(stockInLineId) && stockInLineId > 0) { - setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); - } else { - setWorkbenchLotLabelInitialPayload(null); - } - setWorkbenchLotLabelModalOpen(true); + const openWorkbenchLotLabelModalForLot = useCallback( + (lot: LotRow, reminderText?: string | null) => { + const itemId = Number(lot.itemId); + const stockInLineId = Number(lot.stockInLineId); + const solId = Number(lot.stockOutLineId); + if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { + return; + } + setWorkbenchLotLabelContextLot(lot); + if (Number.isFinite(stockInLineId) && stockInLineId > 0) { + setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); + } else { + setWorkbenchLotLabelInitialPayload(null); + } + setWorkbenchLotLabelReminderText( + reminderText ? translateWorkbenchRejectMessage(reminderText, t) : null, + ); + setQrScanSuccess(false); + setWorkbenchLotLabelModalOpen(true); + }, + [t], + ); + + const patchLotRowForUnpickableScan = useCallback( + ( + pickRow: LotRow, + scannedLot: LotRow | null | undefined, + availability: UnpickableScanAvailability, + ) => { + const solId = Number(pickRow.stockOutLineId); + if (!solId) return; + const rowPatch = buildUnpickableScanRowPatch(scannedLot, availability) as Partial; + setLotRows((prev) => + prev.map((row) => (Number(row.stockOutLineId) === solId ? { ...row, ...rowPatch } : row)), + ); + }, + [], + ); + + const markUnpickableScanSessionHandled = useCallback((latestQr: string) => { + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); }, []); + const openUnpickableScanLotLabelModal = useCallback( + ( + pickRow: LotRow, + scannedLot: LotRow | null | undefined, + reminderText: string, + latestQr: string, + ) => { + const fromMsg = inferUnpickableScanAvailability(reminderText); + const availability = + fromMsg ?? + (isWorkbenchSourceLotExpired(scannedLot ?? pickRow) + ? "expired" + : isInventoryLotLineUnavailable(scannedLot ?? pickRow) + ? "status_unavailable" + : null); + const mergedPickRow = + availability != null + ? ({ ...pickRow, ...buildUnpickableScanRowPatch(scannedLot, availability) } as LotRow) + : pickRow; + if (availability != null) { + patchLotRowForUnpickableScan(pickRow, scannedLot, availability); + } + openWorkbenchLotLabelModalForLot(mergedPickRow, reminderText); + markUnpickableScanSessionHandled(latestQr); + }, + [ + markUnpickableScanSessionHandled, + openWorkbenchLotLabelModalForLot, + patchLotRowForUnpickableScan, + ], + ); + const handleWorkbenchLotLabelScanPick = useCallback( async ({ inventoryLotLineId, lotNo, qty }: { inventoryLotLineId: number; lotNo: string; qty?: number }) => { if (!userId) throw new Error(t("User not found")); @@ -762,7 +838,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { throw new Error(t("No stock out line for this lot")); } const fallbackQty = Number( - resolveSingleSubmitQty(workbenchLotLabelContextLot), + resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot), ); const res = await workbenchScanPick({ stockOutLineId: workbenchLotLabelContextLot.stockOutLineId, @@ -782,12 +858,13 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); } setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelReminderText(null); setWorkbenchLotLabelContextLot(null); setWorkbenchLotLabelInitialPayload(null); }, [ loadLineDetailV2, - qtyBySolId, + resolveLockedSubmitQtyDisplay, selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta, @@ -1172,23 +1249,26 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { setQrScanSuccess(false); }); setMessage(t("This lot is unavailable, please scan another lot.")); - openWorkbenchLotLabelModalForLot(scannedRowInItem); + openUnpickableScanLotLabelModal( + scannedRowInItem, + scannedRowInItem, + t("This lot is not available, please scan another lot."), + latest, + ); return; } - if (scannedRowInItem && isLotExpired(scannedRowInItem)) { - const expiredMsg = t("Lot is expired"); - setError(expiredMsg); + if (scannedRowInItem && isWorkbenchSourceLotExpired(scannedRowInItem)) { startTransition(() => { - setQrScanError(true); + setQrScanError(false); setQrScanSuccess(false); - setQrScanErrorMsg( - scannedRowInItem.expiryDate - ? `${expiredMsg} (expiry=${scannedRowInItem.expiryDate})` - : expiredMsg, - ); }); - openWorkbenchLotLabelModalForLot(scannedRowInItem); + openUnpickableScanLotLabelModal( + scannedRowInItem, + scannedRowInItem, + `Lot is expired (expiry=${scannedRowInItem.expiryDate || "-"})`, + latest, + ); return; } @@ -1252,6 +1332,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { lotConfirmationOpen, pickExpectedRowForSubstitution, lotRowIndexes, + openUnpickableScanLotLabelModal, resetScan, submitRow, t, @@ -1301,6 +1382,8 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { return; } + if (workbenchLotLabelModalOpen && latest === lastProcessedQrRef.current) return; + if (latest === lastProcessedQrRef.current || processedQrCodesRef.current.has(latest)) return; lastProcessedQrRef.current = latest; processedQrCodesRef.current.add(latest); @@ -1343,6 +1426,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { processOutsideQrCode, qrValues, resetScan, + workbenchLotLabelModalOpen, ]); return ( @@ -1469,7 +1553,20 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { - {paginatedLotRows.map((r, idx) => ( + {paginatedLotRows.map((r, idx) => { + const lockedSubmitQty = resolveLockedSubmitQtyDisplay(r); + const hasQtyOverride = hasQtyOverrideBySolId(r.stockOutLineId); + const submitQtyDisplay = hasQtyOverride + ? Number(qtyBySolId[r.stockOutLineId]) + : lockedSubmitQty; + const rowStatus = String(r.status || "").toLowerCase(); + const isRowRejected = + rowStatus === "rejected" || String(r.lotAvailability || "").toLowerCase() === "rejected"; + const isRowExpired = + isWorkbenchSourceLotExpired(r) && !isRowRejected; + const isRowUnavailable = isInventoryLotLineUnavailable(r); + + return ( {idx === 0 ? lotPagingController.pageNum * lotPagingController.pageSize + 1 : ""} @@ -1485,8 +1582,35 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { {r.location || "-"} - - {r.lotNo || "-"} + + + {r.lotNo ? ( + isRowExpired || isLotAvailabilityExpired(r) ? ( + <> + {r.lotNo}{" "} + {t("is expired. Please check around have available QR code or not.")} + + ) : isRowUnavailable ? ( + <> + {r.lotNo}{" "} + {t("is unavable. Please check around have available QR code or not.")} + + ) : ( + r.lotNo + ) + ) : ( + "-" + )} + {r.stockOutLineId > 0 ? (