| @@ -75,9 +75,16 @@ export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => { | |||
| return response.data; | |||
| }; | |||
| export async function fetchBomComboClient(): Promise<BomCombo[]> { | |||
| export async function fetchBomComboClient(options?: { | |||
| includeInactive?: boolean; | |||
| }): Promise<BomCombo[]> { | |||
| const response = await axiosInstance.get<BomCombo[]>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/combo` | |||
| `${NEXT_PUBLIC_API_URL}/bom/combo`, | |||
| { | |||
| params: { | |||
| includeInactive: options?.includeInactive ?? false, | |||
| }, | |||
| }, | |||
| ); | |||
| return response.data; | |||
| } | |||
| @@ -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[]; | |||
| @@ -41,13 +41,14 @@ export async function startWorkbenchBatchReleaseAsync(data: { | |||
| export async function startWorkbenchBatchReleaseAsyncV2(data: { | |||
| ids: number[]; | |||
| userId: number; | |||
| mergeExtraIntoLaneTicket?: boolean; | |||
| }): Promise<WorkbenchMessageResponse> { | |||
| const { ids, userId } = data; | |||
| const { ids, userId, mergeExtraIntoLaneTicket = true } = data; | |||
| return serverFetchJson<WorkbenchMessageResponse>( | |||
| `${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<WorkbenchMergeTicketCandidatesResponse> { | |||
| 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<WorkbenchMergeTicketCandidatesResponse>(url, { | |||
| method: "GET", | |||
| cache: "no-store", | |||
| }); | |||
| return { | |||
| batchFamilyTickets: res?.batchFamilyTickets ?? [], | |||
| isExtraTickets: res?.isExtraTickets ?? [], | |||
| }; | |||
| } | |||
| export async function mergeWorkbenchTickets(data: { | |||
| batchOrSingleDopoId: number; | |||
| isExtraDopoId: number; | |||
| }): Promise<WorkbenchMessageResponse> { | |||
| return serverFetchJson<WorkbenchMessageResponse>( | |||
| `${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; | |||
| @@ -572,12 +572,15 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| return; | |||
| } | |||
| const showMergeExtraOption = isWorkbench && activeTab === "ETRA"; | |||
| const mergeCheckboxDefault = false; | |||
| // 显示确认对话框 | |||
| const result = await Swal.fire({ | |||
| icon: "question", | |||
| title: t("Batch Release"), | |||
| html: ` | |||
| <div> | |||
| <div style="text-align: left;"> | |||
| <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | |||
| <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | |||
| @@ -586,20 +589,39 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} | |||
| ${status ? `${t("Status")}: ${t(status)} ` : ""} | |||
| </p> | |||
| ${ | |||
| showMergeExtraOption | |||
| ? `<label style="display:flex;align-items:flex-start;gap:8px;margin-top:16px;font-size:0.95em;cursor:pointer;"> | |||
| <input type="checkbox" id="mergeExtraIntoLaneTicket" ${mergeCheckboxDefault ? "checked" : ""} style="margin-top:3px;" /> | |||
| <span>${t("Merge extra orders into lane batch ticket")}</span> | |||
| </label>` | |||
| : "" | |||
| } | |||
| </div> | |||
| `, | |||
| 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 }); | |||
| @@ -625,27 +625,41 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| icon: "question", | |||
| title: t("Batch Release"), | |||
| html: ` | |||
| <div> | |||
| <div style="text-align: left;"> | |||
| <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | |||
| <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | |||
| ${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)} ` : ""} | |||
| </p> | |||
| <label style="display:flex;align-items:flex-start;gap:8px;margin-top:16px;font-size:0.95em;cursor:pointer;"> | |||
| <input type="checkbox" id="mergeExtraIntoLaneTicket" style="margin-top:3px;" /> | |||
| <span>${t("Merge extra orders into lane batch ticket")}</span> | |||
| </label> | |||
| </div> | |||
| `, | |||
| 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; | |||
| @@ -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<Props> = ({ defaultTabIndex = 0, printerCom | |||
| const [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ defaultTabIndex = 0, printerCom | |||
| /> | |||
| )} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => void handleAllDraft()} | |||
| > | |||
| <Button variant="contained" onClick={() => void handleAllDraft()}> | |||
| {`${t("Print All Draft")} (${releasedOrderCount})`} | |||
| </Button> | |||
| </Stack> | |||
| @@ -300,7 +300,6 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ defaultTabIndex = 0, printerCom | |||
| <TabPanel value={tab} index={6}> | |||
| <TruckRoutingSummaryTabWorkbench /> | |||
| </TabPanel> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -422,4 +419,3 @@ const DoWorkbenchTabs: React.FC<Props> = (props) => ( | |||
| ); | |||
| export default DoWorkbenchTabs; | |||
| @@ -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 ( | |||
| <Paper | |||
| variant="outlined" | |||
| onClick={() => onSelect(ticket.id)} | |||
| sx={{ | |||
| p: 1.5, | |||
| cursor: "pointer", | |||
| borderWidth: selected ? 2 : 1, | |||
| borderColor: selected ? `${accent}.main` : "divider", | |||
| bgcolor: "background.paper", | |||
| }} | |||
| > | |||
| <Stack spacing={0.75}> | |||
| <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap"> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 700, wordBreak: "break-all" }}> | |||
| {ticket.ticketNo ?? `#${ticket.id}`} | |||
| </Typography> | |||
| <Chip | |||
| size="small" | |||
| label={t(ticket.releaseType ?? "").trim() || "—"} | |||
| color={accent} | |||
| variant="outlined" | |||
| /> | |||
| <Chip size="small" label={floorLabel(ticket.storeId, t)} variant="outlined" /> | |||
| </Stack> | |||
| <Typography variant="body2" sx={{ fontWeight: 600 }}> | |||
| {shopPrimary(ticket, t)} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {laneSecondary(ticket, t)} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-all" }}> | |||
| {ticket.deliveryOrderCodes.length > 0 | |||
| ? `${t("Delivery Order Code")}: ${ticket.deliveryOrderCodes.join(", ")}` | |||
| : t("No delivery orders on ticket")} | |||
| </Typography> | |||
| </Stack> | |||
| </Paper> | |||
| ); | |||
| } | |||
| 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 ( | |||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}> | |||
| {title} | |||
| </Typography> | |||
| {items.length === 0 ? ( | |||
| <Paper variant="outlined" sx={{ p: 2, minHeight: 160, display: "flex", alignItems: "center" }}> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {emptyText} | |||
| </Typography> | |||
| </Paper> | |||
| ) : ( | |||
| <> | |||
| <Stack spacing={1} sx={{ minHeight: 160 }}> | |||
| {pageItems.map((ticket) => ( | |||
| <MergeTicketCard | |||
| key={ticket.id} | |||
| ticket={ticket} | |||
| selected={selectedId === ticket.id} | |||
| accent={accent} | |||
| onSelect={onSelect} | |||
| /> | |||
| ))} | |||
| </Stack> | |||
| {pageCount > 1 && ( | |||
| <> | |||
| <MobileStepper | |||
| variant="dots" | |||
| steps={pageCount} | |||
| position="static" | |||
| activeStep={page} | |||
| sx={{ mt: 0.5, bgcolor: "transparent", px: 0 }} | |||
| nextButton={ | |||
| <Button | |||
| size="small" | |||
| disabled={page >= pageCount - 1} | |||
| onClick={() => setPage((p) => Math.min(p + 1, pageCount - 1))} | |||
| > | |||
| {t("Next")} | |||
| <KeyboardArrowRight /> | |||
| </Button> | |||
| } | |||
| backButton={ | |||
| <Button | |||
| size="small" | |||
| disabled={page <= 0} | |||
| onClick={() => setPage((p) => Math.max(p - 1, 0))} | |||
| > | |||
| <KeyboardArrowLeft /> | |||
| {t("Back")} | |||
| </Button> | |||
| } | |||
| /> | |||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block", textAlign: "center" }}> | |||
| {page + 1} / {pageCount} | |||
| </Typography> | |||
| </> | |||
| )} | |||
| </> | |||
| )} | |||
| </Box> | |||
| ); | |||
| } | |||
| const WorkbenchEtraMergeDialog: React.FC<Props> = ({ | |||
| 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<WorkbenchMergeTicketCandidate[]>([]); | |||
| const [extraTickets, setExtraTickets] = useState<WorkbenchMergeTicketCandidate[]>([]); | |||
| const [selectedBatchId, setSelectedBatchId] = useState<number | null>(null); | |||
| const [selectedExtraId, setSelectedExtraId] = useState<number | null>(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: ` | |||
| <div style="text-align:left;font-size:0.95em;"> | |||
| <p><b>${t("Batch/Single ticket")}:</b> ${batch.ticketNo ?? batch.id}</p> | |||
| <p><b>${t("Etra ticket")}:</b> ${extra.ticketNo ?? extra.id}</p> | |||
| <p style="margin-top:0.75em;color:#666;">${t("Merge Etra ticket confirm hint")}</p> | |||
| </div> | |||
| `, | |||
| 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 ( | |||
| <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> | |||
| <DialogTitle>{t("Merge Etra ticket dialog title")}</DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ pt: 0.5 }}> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "flex-end" }}> | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| label={t("Shop search")} | |||
| value={shopSearch} | |||
| onChange={(e) => setShopSearch(e.target.value)} | |||
| placeholder={t("Shop Name")} | |||
| disabled={loading} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| type="date" | |||
| label={t("Required Date")} | |||
| value={requiredDate} | |||
| onChange={(e) => setRequiredDate(e.target.value || dayjs().format("YYYY-MM-DD"))} | |||
| InputLabelProps={{ shrink: true }} | |||
| sx={{ minWidth: 160 }} | |||
| disabled={loading} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => void loadCandidates()} | |||
| disabled={loading} | |||
| sx={{ minWidth: 100, flexShrink: 0 }} | |||
| > | |||
| {loading ? t("Loading...") : t("Confirm Search")} | |||
| </Button> | |||
| </Stack> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Merge Etra ticket lane hint")} | |||
| </Typography> | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | |||
| <CircularProgress size={32} /> | |||
| </Box> | |||
| ) : ( | |||
| <Stack direction={{ xs: "column", md: "row" }} spacing={2}> | |||
| <MergeTicketCarousel | |||
| title={t("Batch/Single ticket")} | |||
| items={batchTickets} | |||
| selectedId={selectedBatchId} | |||
| onSelect={(id) => { | |||
| setSelectedBatchId(id); | |||
| setSelectedExtraId(null); | |||
| }} | |||
| emptyText={ | |||
| !hasSearched | |||
| ? t("Merge Etra ticket search prompt") | |||
| : t("No mergeable batch tickets") | |||
| } | |||
| accent="primary" | |||
| /> | |||
| <MergeTicketCarousel | |||
| title={t("Etra ticket")} | |||
| items={filteredExtraTickets} | |||
| selectedId={selectedExtraId} | |||
| onSelect={setSelectedExtraId} | |||
| emptyText={ | |||
| !hasSearched | |||
| ? t("Merge Etra ticket search prompt") | |||
| : selectedBatch | |||
| ? t("No isExtra on same lane") | |||
| : t("Select batch ticket first for isExtra") | |||
| } | |||
| accent="secondary" | |||
| /> | |||
| </Stack> | |||
| )} | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions sx={{ px: 3, pb: 2 }}> | |||
| <Button onClick={onClose} disabled={submitting}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" color="secondary" disabled={!canConfirm} onClick={() => void handleConfirm()}> | |||
| {submitting ? t("Loading...") : t("Merge Etra ticket confirm")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| ); | |||
| }; | |||
| export default WorkbenchEtraMergeDialog; | |||
| @@ -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<Props> = ({ | |||
| const [modalInitialShopSearch, setModalInitialShopSearch] = useState<string | undefined>(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<Props> = ({ | |||
| </Select> | |||
| </FormControl> | |||
| </Box> | |||
| {inEtraUi && ( | |||
| <Box sx={{ display: "flex", alignItems: "center", pt: 0.5 }}> | |||
| <Button variant="contained" color="secondary" onClick={() => setEtraMergeDialogOpen(true)}> | |||
| {t("Merge Etra ticket")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| {!inEtraUi && ( | |||
| <> | |||
| <Box sx={{ minWidth: 140, maxWidth: 300 }}> | |||
| @@ -748,6 +757,16 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| onSwitchToDetailTab?.(); | |||
| }} | |||
| /> | |||
| <WorkbenchEtraMergeDialog | |||
| open={etraMergeDialogOpen} | |||
| onClose={() => setEtraMergeDialogOpen(false)} | |||
| initialDateYmd={selectedDeliveryDateYmd} | |||
| onMerged={() => { | |||
| void loadEtraSummaries(); | |||
| onPickOrderAssigned?.(); | |||
| window.dispatchEvent(new Event("pickOrderAssigned")); | |||
| }} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -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; | |||
| @@ -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<BomCombo[]>([]); | |||
| @@ -130,6 +133,9 @@ const ImportBomDetailTab: React.FC = () => { | |||
| ProcessMasterRow[] | |||
| >([]); | |||
| const [editMasterLoading, setEditMasterLoading] = useState(false); | |||
| const [statusDraft, setStatusDraft] = useState<BomStatus>("active"); | |||
| const [statusSaving, setStatusSaving] = useState(false); | |||
| const [statusError, setStatusError] = useState<string | null>(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)}) | |||
| </Button> | |||
| ))} | |||
| </Stack> | |||
| @@ -606,7 +646,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| {t("Basic Info")} | |||
| </Typography> | |||
| {!isEditing ? ( | |||
| {SHOW_BOM_FULL_EDIT && !isEditing ? ( | |||
| <Button | |||
| size="small" | |||
| startIcon={ | |||
| @@ -622,7 +662,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| > | |||
| {editMasterLoading ? t("Loading...") : t("Edit")} | |||
| </Button> | |||
| ) : ( | |||
| ) : SHOW_BOM_FULL_EDIT ? ( | |||
| <Stack direction="row" spacing={1}> | |||
| <Button | |||
| size="small" | |||
| @@ -643,7 +683,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| {t("Cancel")} | |||
| </Button> | |||
| </Stack> | |||
| )} | |||
| ) : null} | |||
| </Stack> | |||
| {editError && ( | |||
| @@ -653,12 +693,43 @@ const ImportBomDetailTab: React.FC = () => { | |||
| )} | |||
| {!isEditing && ( | |||
| <Stack spacing={0.5}> | |||
| <Stack spacing={1}> | |||
| <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap"> | |||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||
| <InputLabel>{t("BOM Status")}</InputLabel> | |||
| <Select | |||
| label={t("BOM Status")} | |||
| value={statusDraft} | |||
| onChange={(e) => setStatusDraft(e.target.value as BomStatus)} | |||
| disabled={statusSaving} | |||
| > | |||
| <MenuItem value="active">{t("BOM Status Active")}</MenuItem> | |||
| <MenuItem value="inactive">{t("BOM Status Inactive")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| startIcon={statusSaving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} | |||
| disabled={statusSaving || statusDraft === (detail.status ?? "active")} | |||
| onClick={() => void handleSaveStatus()} | |||
| > | |||
| {statusSaving ? t("Saving...") : t("Save Status")} | |||
| </Button> | |||
| </Stack> | |||
| {statusError && ( | |||
| <Typography variant="body2" color="error"> | |||
| {statusError} | |||
| </Typography> | |||
| )} | |||
| {/* 第一行:輸出數量 + 類型 */} | |||
| <Typography variant="body2"> | |||
| {t("Output Quantity")}: {detail.outputQty} {detail.outputQtyUom} | |||
| {" "} | |||
| {t("Type")}: {detail.description ?? "-"} | |||
| {" "} | |||
| {t("BOM Status")}: {renderBomStatus(detail.status)} | |||
| </Typography> | |||
| {/* 第二行:各種指標,排成一行 key:value, key:value */} | |||
| @@ -1050,7 +1121,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| <TableCell> {t("Sequence")}</TableCell> | |||
| <TableCell> {t("Process Name")}</TableCell> | |||
| <TableCell> {t("Process Description")}</TableCell> | |||
| <TableCell> {t("Process Code")}</TableCell> | |||
| {/*<TableCell> {t("Process Code")}</TableCell>*/} | |||
| <TableCell>設備(說明/名稱)</TableCell> | |||
| <TableCell align="right"> {t("Duration (Minutes)")}</TableCell> | |||
| <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | |||
| @@ -1226,7 +1297,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| <TableCell>{p.seqNo}</TableCell> | |||
| <TableCell>{p.processName}</TableCell> | |||
| <TableCell>{p.processDescription}</TableCell> | |||
| <TableCell>{p.processCode ?? "-"}</TableCell> | |||
| {/*<TableCell>{p.processCode ?? "-"}</TableCell>*/} | |||
| <TableCell>{p.equipmentCode ?? p.equipmentName}</TableCell> | |||
| <TableCell align="right">{p.durationInMinute}</TableCell> | |||
| <TableCell align="right">{p.prepTimeInMinute}</TableCell> | |||
| @@ -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<Props> = ({ filterArgs }) => { | |||
| const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = useState<LotRow | null>(null); | |||
| const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = | |||
| useState<{ itemId: number; stockInLineId: number } | null>(null); | |||
| const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] = useState<string | null>(null); | |||
| const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false); | |||
| const [lotConfirmationError, setLotConfirmationError] = useState<string | null>(null); | |||
| const [expectedLotData, setExpectedLotData] = useState<ConfirmLotState | null>(null); | |||
| @@ -360,10 +359,16 @@ const WorkbenchPickExecution: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<LotRow>; | |||
| 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ filterArgs }) => { | |||
| lotConfirmationOpen, | |||
| pickExpectedRowForSubstitution, | |||
| lotRowIndexes, | |||
| openUnpickableScanLotLabelModal, | |||
| resetScan, | |||
| submitRow, | |||
| t, | |||
| @@ -1301,6 +1382,8 @@ const WorkbenchPickExecution: React.FC<Props> = ({ 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<Props> = ({ filterArgs }) => { | |||
| processOutsideQrCode, | |||
| qrValues, | |||
| resetScan, | |||
| workbenchLotLabelModalOpen, | |||
| ]); | |||
| return ( | |||
| @@ -1469,7 +1553,20 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {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 ( | |||
| <TableRow key={r.key}> | |||
| <TableCell>{idx === 0 ? lotPagingController.pageNum * lotPagingController.pageSize + 1 : ""}</TableCell> | |||
| <TableCell> | |||
| @@ -1485,8 +1582,35 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| </TableCell> | |||
| <TableCell>{r.location || "-"}</TableCell> | |||
| <TableCell> | |||
| <Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between"> | |||
| <Typography variant="body2">{r.lotNo || "-"}</Typography> | |||
| <Stack direction="row" spacing={1} alignItems="flex-start" justifyContent="space-between"> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ | |||
| color: isRowUnavailable | |||
| ? "error.main" | |||
| : isRowExpired || isLotAvailabilityExpired(r) | |||
| ? "warning.main" | |||
| : "inherit", | |||
| }} | |||
| > | |||
| {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 | |||
| ) | |||
| ) : ( | |||
| "-" | |||
| )} | |||
| </Typography> | |||
| {r.stockOutLineId > 0 ? ( | |||
| <Button | |||
| variant="outlined" | |||
| @@ -1506,24 +1630,66 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| ).toLocaleString()}(${r.uomDesc || ""})`} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Checkbox | |||
| checked={isCompletedStatus(r.status) || isCheckedStatus(r.status)} | |||
| disabled | |||
| size="small" | |||
| sx={{ | |||
| color: isCompletedStatus(r.status) ? "success.main" : isCheckedStatus(r.status) ? "warning.main" : "action.disabled", | |||
| "&.Mui-checked": { | |||
| color: isCompletedStatus(r.status) ? "success.main" : isCheckedStatus(r.status) ? "warning.main" : "action.disabled", | |||
| }, | |||
| }} | |||
| /> | |||
| {(() => { | |||
| if (isRowRejected && r.lotNo) { | |||
| return ( | |||
| <Checkbox | |||
| checked | |||
| disabled | |||
| size="small" | |||
| sx={{ | |||
| color: "error.main", | |||
| "&.Mui-checked": { color: "error.main" }, | |||
| }} | |||
| /> | |||
| ); | |||
| } | |||
| if (isRowExpired) { | |||
| return ( | |||
| <Checkbox | |||
| checked | |||
| disabled | |||
| size="small" | |||
| sx={{ | |||
| color: "warning.main", | |||
| "&.Mui-checked": { color: "warning.main" }, | |||
| }} | |||
| /> | |||
| ); | |||
| } | |||
| return ( | |||
| <Checkbox | |||
| checked={isCompletedStatus(r.status) || isCheckedStatus(r.status)} | |||
| disabled | |||
| size="small" | |||
| sx={{ | |||
| color: isCompletedStatus(r.status) | |||
| ? "success.main" | |||
| : isCheckedStatus(r.status) | |||
| ? "warning.main" | |||
| : "action.disabled", | |||
| "&.Mui-checked": { | |||
| color: isCompletedStatus(r.status) | |||
| ? "success.main" | |||
| : isCheckedStatus(r.status) | |||
| ? "warning.main" | |||
| : "action.disabled", | |||
| }, | |||
| }} | |||
| /> | |||
| ); | |||
| })()} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Stack direction="row" spacing={1} justifyContent="center" alignItems="center"> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={qtyBySolId[r.stockOutLineId] ?? Number(r.requiredQty)} | |||
| value={ | |||
| qtyEditableBySolId[r.stockOutLineId] === true && hasQtyOverride | |||
| ? String(qtyBySolId[r.stockOutLineId]) | |||
| : String(submitQtyDisplay) | |||
| } | |||
| onKeyDown={(e) => { | |||
| const editable = qtyEditableBySolId[r.stockOutLineId] === true; | |||
| if (!editable) return; | |||
| @@ -1587,7 +1753,8 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| </Stack> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| ); | |||
| })} | |||
| {lotRows.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={9} align="center" sx={{ textAlign: "center" }}> | |||
| @@ -1620,17 +1787,21 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| open={workbenchLotLabelModalOpen} | |||
| onClose={() => { | |||
| setWorkbenchLotLabelModalOpen(false); | |||
| setWorkbenchLotLabelReminderText(null); | |||
| setWorkbenchLotLabelContextLot(null); | |||
| setWorkbenchLotLabelInitialPayload(null); | |||
| }} | |||
| initialPayload={workbenchLotLabelInitialPayload} | |||
| initialItemId={workbenchLotLabelContextLot?.itemId ?? null} | |||
| hideScanSection={workbenchLotLabelInitialPayload != null || workbenchLotLabelContextLot != null} | |||
| reminderText={workbenchLotLabelReminderText ?? undefined} | |||
| statusTitleText={workbenchLotLabelStatusBanner.text} | |||
| statusTitleSeverity={workbenchLotLabelStatusBanner.severity} | |||
| triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} | |||
| triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} | |||
| submitQty={ | |||
| workbenchLotLabelContextLot?.stockOutLineId | |||
| ? Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) | |||
| ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) | |||
| : null | |||
| } | |||
| onSubmitQtyChange={(qty) => { | |||
| @@ -31,6 +31,7 @@ | |||
| "Estimated Arrival From": "Estimated Arrival From", | |||
| "Estimated Arrival To": "Estimated Arrival To", | |||
| "Etra": "Etra", | |||
| "Merge extra orders into lane batch ticket": "Merge into lane merge ticket (isExtrabatch, TI-M- prefix)", | |||
| "Expiry Date": "Expiry Date", | |||
| "Failed to assign pick orders. Please try again later.": "Failed to assign pick orders. Please try again later.", | |||
| "Failed to release pick orders. Please try again later.": "Failed to release pick orders. Please try again later.", | |||
| @@ -8,5 +8,20 @@ | |||
| "Is Drink": "Is Drink", | |||
| "Drink": "Drink", | |||
| "Powder_Mixture": "Powder Mixture", | |||
| "Base Score": "Base Score" | |||
| "Base Score": "Base Score", | |||
| "BOM Status": "BOM Status", | |||
| "BOM Status Active": "Active", | |||
| "BOM Status Inactive": "Inactive", | |||
| "Save Status": "Save Status", | |||
| "Saving...": "Saving...", | |||
| "Code": "BOM Code", | |||
| "Name": "BOM Name", | |||
| "Output Quantity": "Output Quantity", | |||
| "Type": "Type", | |||
| "Loading BOM Detail...": "Loading BOM Detail...", | |||
| "Basic Info": "Basic Info", | |||
| "Edit": "Edit", | |||
| "Loading...": "Loading...", | |||
| "Save": "Save", | |||
| "Cancel": "Cancel" | |||
| } | |||
| @@ -12,6 +12,8 @@ | |||
| "nav.store.stockRecord": "Stock Record", | |||
| "nav.store.doWorkbench": "DO Workbench", | |||
| "nav.deliveryOrder": "Delivery Order", | |||
| "nav.deliveryOrder.search": "Search Delivery Order", | |||
| "nav.deliveryOrder.replenish": "DO Replenishment", | |||
| "nav.scheduling": "Scheduling", | |||
| "nav.jobOrderManagement": "Management Job Order", | |||
| "nav.jobOrder.searchCreate": "Search Job Order/ Create Job Order", | |||
| @@ -151,6 +151,25 @@ | |||
| "Enter isExtra workbench view?": "Enter isExtra workbench view?", | |||
| "Etra view groups all add-on tickets by shop and lane for the selected date.": "Etra view groups all add-on tickets by shop and lane for the selected date.", | |||
| "Etra Ticket Notice": "Etra Ticket Notice", | |||
| "Merge Etra ticket": "Merge Etra ticket", | |||
| "Merge Etra ticket dialog title": "Merge Etra into batch ticket", | |||
| "Merge Etra ticket lane hint": "Only unassigned tickets on the same shop, floor (2/F, 4/F, or Truck X), truck lane, and departure time can be merged. A new TI-M ticket will be created.", | |||
| "Batch/Single ticket": "Batch / Single ticket", | |||
| "Etra ticket": "Etra ticket", | |||
| "No mergeable batch tickets": "No unassigned batch or single tickets", | |||
| "No isExtra on same lane": "No isExtra tickets on the same lane", | |||
| "Select batch ticket first for isExtra": "Select a batch/single ticket on the left first", | |||
| "No delivery orders on ticket": "No delivery orders on this ticket", | |||
| "Merge Etra ticket confirm": "Confirm merge", | |||
| "Merge Etra ticket confirm title": "Confirm merge batch and Etra tickets?", | |||
| "Merge Etra ticket confirm hint": "A new TI-M ticket will be created; the original batch and Etra tickets will be archived.", | |||
| "Merge Etra ticket success": "Merge completed", | |||
| "Merge Etra ticket failed": "Merge failed", | |||
| "Shop search": "Shop search", | |||
| "Confirm Search": "Search", | |||
| "Merge Etra ticket search prompt": "Enter shop (optional) and date, then click Search to load merge candidates.", | |||
| "Merge Etra ticket search failed": "Failed to load merge candidates. Ensure the backend is updated and restarted.", | |||
| "Truck X": "Truck X", | |||
| "Pick Order": "Pick Order", | |||
| "Type": "Type", | |||
| "Product Type": "Product Type", | |||
| @@ -505,6 +524,8 @@ | |||
| "Lot switch failed; pick line was not marked as checked.": "Lot switch failed; pick line was not marked as checked.", | |||
| "Lot confirmation failed. Please try again.": "Lot confirmation failed. Please try again.", | |||
| "Powder Mixture": "Powder Mixture", | |||
| "No inventory lot lines for inventoryLotId": "This lot is not yet putaway", | |||
| "No inventory lot for stockInLineId": "This lot is not yet putaway", | |||
| "This lot is not yet putaway": "This lot is not yet putaway", | |||
| "Cannot resolve new inventory lot line": "Cannot resolve new inventory lot line", | |||
| "Pick order line item is null": "Pick order line item is null", | |||
| @@ -11,6 +11,7 @@ | |||
| "Estimated Arrival To": "預計送貨日期至", | |||
| "Status": "來貨狀態", | |||
| "Etra": "加單", | |||
| "Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)", | |||
| "Loading": "正在加載...", | |||
| "No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection.": "沒有選擇送貨訂單進行批量放單。取消勾選您想排除的訂單,或重新搜索以重置選擇。", | |||
| "No Records": "沒有找到記錄", | |||
| @@ -26,6 +27,7 @@ | |||
| "Select Remark": "選擇備註", | |||
| "Confirm Assignment": "確認分配", | |||
| "Submit Qty": "提交數量", | |||
| "No inventory lot lines for inventoryLotId": "此批次尚未上架", | |||
| "Required Date": "所需日期", | |||
| "Submit Miss Item": "提交缺貨品", | |||
| "Submit Quantity": "提交數量", | |||
| @@ -95,6 +97,24 @@ | |||
| "Delivery": "送貨訂單", | |||
| "Replenishment page title": "送貨單補貨", | |||
| "Replenishment demo banner": "示範模式:候選送貨單與補貨提交均使用假資料,後端 API 完成後會切換為真實資料。", | |||
| "Replenishment m18 max rule": "規則:同一 m18Id 全系統最多 {{max}} 筆 delivery_order_line。", | |||
| "Replenishment demo scenario": "示範情境", | |||
| "Replenishment lookback days": "回溯天數", | |||
| "Replenishment source section": "來源行(completed DO,手選複製 m18Id)", | |||
| "Replenishment source hint": "搜尋後顯示可選來源行;同一 m18Id 全系統不是僅 1 筆的行會隱藏。", | |||
| "Replenishment source count": "共 {{count}} 筆可選來源行", | |||
| "Replenishment source do": "來源 DO", | |||
| "Replenishment source m18Id": "m18Id", | |||
| "Replenishment m18 usage count": "m18Id 已用", | |||
| "Replenishment is replenishment": "補貨行", | |||
| "Replenishment m18 max usage message": "此 m18Id 已達全系統上限(最多 {{max}} 筆),無法再補貨。", | |||
| "Replenishment demo case1 title": "Case 1:多筆可選來源行", | |||
| "Replenishment demo case1 body": "來源顯示 m18Id 6、8、12、15(各僅 1 筆)。DO-OLD-COMPLETED 的 m18Id 10 因已有 2 筆而不顯示。選 DO-2 或 DO-4 補貨後,該 m18Id 會從來源列表消失。", | |||
| "Replenishment demo case2 title": "Case 2:m18Id 6、10 已各 2 筆", | |||
| "Replenishment demo case2 body": "m18Id 6(DO-1 + DO-2 補貨)與 m18Id 10(DO-2 + DO-3 補貨)不再顯示。仍可選 m18Id 8、12 補到 DO-4-PENDING。", | |||
| "Replenishment reset hint": "重設會還原目前示範情境的初始假資料,不會切換 Case。", | |||
| "Yes": "是", | |||
| "No": "否", | |||
| "Replenishment input section": "補貨資料", | |||
| "Replenishment item code": "貨品編號", | |||
| "Replenishment search candidates": "搜尋候選送貨單", | |||
| @@ -8,5 +8,25 @@ | |||
| "Is Drink": "飲料", | |||
| "Drink": "飲料", | |||
| "Powder_Mixture": "箱料粉", | |||
| "Base Score": "基礎得分" | |||
| "Base Score": "基礎得分", | |||
| "Process & Equipment": "工序與設備", | |||
| "Sequence": "順序", | |||
| "Output Quantity": "產出數量", | |||
| "Import BOM": "匯入BOM", | |||
| "Base Qty": "基本數量", | |||
| "Base UOM": "基本單位", | |||
| "Stock UOM": "庫存單位", | |||
| "Process Description": "工序描述", | |||
| "Process Name":"工序名稱", | |||
| "Process Code":"工序編號", | |||
| "Type":"類型", | |||
| "Prep Time (Minutes)":"準備時間(分鐘)", | |||
| "Post Prod Time (Minutes)":"收尾時間(分鐘)", | |||
| "Code":"Bom 編號", | |||
| "Name":"Bom 名稱", | |||
| "BOM Status": "BOM 狀態", | |||
| "BOM Status Active": "啟用", | |||
| "BOM Status Inactive": "停用", | |||
| "Save Status": "儲存狀態", | |||
| "Saving...": "儲存中…" | |||
| } | |||
| @@ -56,6 +56,8 @@ | |||
| "nav.chartReports": "圖表報告", | |||
| "nav.dashboard": "資訊展示面板", | |||
| "nav.deliveryOrder": "送貨訂單", | |||
| "nav.deliveryOrder.search": "搜尋送貨單", | |||
| "nav.deliveryOrder.replenish": "送貨單補貨", | |||
| "nav.jobOrder.bagUsage": "包裝袋使用記錄", | |||
| "nav.jobOrder.pickExecution": "工單提料", | |||
| "nav.jobOrder.productionProcess": "工單生產流程", | |||
| @@ -144,13 +144,38 @@ | |||
| "isExtra order": "加單", | |||
| "Etra": "加單", | |||
| "Exit Etra view": "離開加單檢視", | |||
| "Etra Pick Order Detail": "加單", | |||
| "Etra Pick Order Detail": "加單", | |||
| "No inventory lot lines for inventoryLotId": "此批次尚未上架", | |||
| "No inventory lot for stockInLineId": "此批次尚未上架", | |||
| "Etra incomplete badge tooltip": "當日未完成加單票:{{count}} 張(待處理/已發佈,不含已結案)", | |||
| "Etra incomplete badge tooltip none": "目前無未完成加單票", | |||
| "Back to normal assign tab": "返回一般指派分頁", | |||
| "Enter isExtra workbench view?": "進入加單檢視?", | |||
| "Etra view groups all add-on tickets by shop and lane for the selected date.": "加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。", | |||
| "Etra Ticket Notice": "目前是加單票,顯示與操作已切換為加單模式。", | |||
| "Merge Etra ticket": "合併加單送貨訂單", | |||
| "Merge Etra ticket dialog title": "合併加單送貨訂單和批次送貨訂單", | |||
| "Merge Etra ticket lane hint": "僅可合併同一店鋪、同一樓層(2/F、4/F 或車線-X)、同一車線與發車時間的未指派送貨訂單。", | |||
| "Batch/Single ticket": "批次/單張 送貨訂單", | |||
| "Etra ticket": "加單送貨訂單", | |||
| "No mergeable batch tickets": "暫無可合併的批次/單張 送貨訂單", | |||
| "No isExtra on same lane": "同車線暫無可合併的加單送貨訂單", | |||
| "Select batch ticket first for isExtra": "請先選擇左側批次/單張 送貨訂單", | |||
| "No delivery orders on ticket": "此票尚無送貨單", | |||
| "Merge Etra ticket confirm": "確認合併", | |||
| "Merge Etra ticket confirm title": "確認合併批次/加單送貨訂單?", | |||
| "Merge Etra ticket confirm hint": "將產生新 TI-M 合併票,原批次票與加單票將歸檔。", | |||
| "Merge Etra ticket success": "合併成功", | |||
| "Merge Etra ticket failed": "合併失敗", | |||
| "Next": "下一頁", | |||
| "Shop search": "店鋪搜尋", | |||
| "Confirm Search": "確認搜索", | |||
| "Merge Etra ticket search prompt": "請輸入店鋪(選購)與日期,點選「確認搜尋」載入可合併送貨訂單。", | |||
| "Merge Etra ticket search failed": "搜尋可合併送貨訂單失敗", | |||
| "Truck X": "車線-X", | |||
| "batch": "批量", | |||
| "single": "單張", | |||
| "isExtra": "加單", | |||
| "Pick Order": "提料單", | |||
| "Type": "類型", | |||
| "Product Type": "貨品類型", | |||
| @@ -58,6 +58,13 @@ export function isWorkbenchZeroCompleteLot( | |||
| return isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot); | |||
| } | |||
| /** Backend messages with dynamic ids — map prefix to i18n key (pickOrder namespace). */ | |||
| const WORKBENCH_REJECT_PREFIX_I18N: Array<[RegExp, string]> = [ | |||
| [/^No inventory lot lines for inventoryLotId=\d+/i, "No inventory lot lines for inventoryLotId"], | |||
| [/^No inventory lot for stockInLineId=\d+/i, "No inventory lot for stockInLineId"], | |||
| [/^This lot is not yet putaway\.?$/i, "This lot is not yet putaway"], | |||
| ]; | |||
| export function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { | |||
| const msg = raw.trim(); | |||
| if (!msg) return msg; | |||
| @@ -69,6 +76,10 @@ export function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): str | |||
| }); | |||
| } | |||
| for (const [pattern, i18nKey] of WORKBENCH_REJECT_PREFIX_I18N) { | |||
| if (pattern.test(msg)) return t(i18nKey); | |||
| } | |||
| return t(msg); | |||
| } | |||
| @@ -98,8 +109,11 @@ export function inferUnpickableScanAvailability( | |||
| m.includes("unavailable") || | |||
| m.includes("not available") || | |||
| m.includes("not yet putaway") || | |||
| m.includes("no inventory lot lines") || | |||
| m.includes("no inventory lot for stockinlineid") || | |||
| m.includes("不可用") || | |||
| m.includes("未上架") | |||
| m.includes("未上架") || | |||
| m.includes("尚未上架") | |||
| ) { | |||
| return "status_unavailable"; | |||
| } | |||
| @@ -9,6 +9,7 @@ export function isWorkbenchExtraTicket( | |||
| rt === "isextrabatch" || | |||
| rt === "isextrasingle" || | |||
| rt === "isextra" || | |||
| tn.startsWith("TI-E-") | |||
| tn.startsWith("TI-E-") || | |||
| tn.startsWith("TI-M-") | |||
| ); | |||
| } | |||