| @@ -75,9 +75,16 @@ export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => { | |||||
| return response.data; | return response.data; | ||||
| }; | }; | ||||
| export async function fetchBomComboClient(): Promise<BomCombo[]> { | |||||
| export async function fetchBomComboClient(options?: { | |||||
| includeInactive?: boolean; | |||||
| }): Promise<BomCombo[]> { | |||||
| const response = await axiosInstance.get<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; | return response.data; | ||||
| } | } | ||||
| @@ -2,6 +2,8 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| export type BomStatus = "active" | "inactive"; | |||||
| export interface BomCombo { | export interface BomCombo { | ||||
| id: number; | id: number; | ||||
| value: number; | value: number; | ||||
| @@ -9,6 +11,7 @@ export interface BomCombo { | |||||
| outputQty: number; | outputQty: number; | ||||
| outputQtyUom: string; | outputQtyUom: string; | ||||
| description: string; | description: string; | ||||
| status?: BomStatus; | |||||
| } | } | ||||
| export type BomComboIssueCode = | export type BomComboIssueCode = | ||||
| @@ -118,6 +121,7 @@ export interface BomDetailResponse { | |||||
| description?: string; | description?: string; | ||||
| outputQty?: number; | outputQty?: number; | ||||
| outputQtyUom?: string; | outputQtyUom?: string; | ||||
| status?: BomStatus; | |||||
| materials: BomMaterialDto[]; | materials: BomMaterialDto[]; | ||||
| processes: BomProcessDto[]; | processes: BomProcessDto[]; | ||||
| } | } | ||||
| @@ -139,6 +143,7 @@ export interface EditBomRequest { | |||||
| complexity?: number; | complexity?: number; | ||||
| isDrink?: boolean; | isDrink?: boolean; | ||||
| isPowderMixture?: boolean; | isPowderMixture?: boolean; | ||||
| status?: BomStatus; | |||||
| materials?: EditBomMaterialRequest[]; | materials?: EditBomMaterialRequest[]; | ||||
| processes?: EditBomProcessRequest[]; | processes?: EditBomProcessRequest[]; | ||||
| @@ -41,13 +41,14 @@ export async function startWorkbenchBatchReleaseAsync(data: { | |||||
| export async function startWorkbenchBatchReleaseAsyncV2(data: { | export async function startWorkbenchBatchReleaseAsyncV2(data: { | ||||
| ids: number[]; | ids: number[]; | ||||
| userId: number; | userId: number; | ||||
| mergeExtraIntoLaneTicket?: boolean; | |||||
| }): Promise<WorkbenchMessageResponse> { | }): Promise<WorkbenchMessageResponse> { | ||||
| const { ids, userId } = data; | |||||
| const { ids, userId, mergeExtraIntoLaneTicket = true } = data; | |||||
| return serverFetchJson<WorkbenchMessageResponse>( | return serverFetchJson<WorkbenchMessageResponse>( | ||||
| `${BASE_API_URL}/doPickOrder/workbench/batch-release/async-v2?userId=${userId}`, | `${BASE_API_URL}/doPickOrder/workbench/batch-release/async-v2?userId=${userId}`, | ||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(ids), | |||||
| body: JSON.stringify({ ids, mergeExtraIntoLaneTicket }), | |||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| } | } | ||||
| ); | ); | ||||
| @@ -275,6 +276,60 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( | |||||
| return Array.isArray(response) ? response : []; | 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`. */ | /** Same body as `/doPickOrder/assign-by-lane` but resolves `delivery_order_pick_order`. */ | ||||
| export async function assignWorkbenchByLane(data: { | export async function assignWorkbenchByLane(data: { | ||||
| userId: number; | userId: number; | ||||
| @@ -572,12 +572,15 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| return; | return; | ||||
| } | } | ||||
| const showMergeExtraOption = isWorkbench && activeTab === "ETRA"; | |||||
| const mergeCheckboxDefault = false; | |||||
| // 显示确认对话框 | // 显示确认对话框 | ||||
| const result = await Swal.fire({ | const result = await Swal.fire({ | ||||
| icon: "question", | icon: "question", | ||||
| title: t("Batch Release"), | title: t("Batch Release"), | ||||
| html: ` | html: ` | ||||
| <div> | |||||
| <div style="text-align: left;"> | |||||
| <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | ||||
| <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | <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} ` : ""} | ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} | ||||
| ${status ? `${t("Status")}: ${t(status)} ` : ""} | ${status ? `${t("Status")}: ${t(status)} ` : ""} | ||||
| </p> | </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> | </div> | ||||
| `, | `, | ||||
| showCancelButton: true, | showCancelButton: true, | ||||
| confirmButtonText: t("Confirm"), | confirmButtonText: t("Confirm"), | ||||
| cancelButtonText: t("Cancel"), | cancelButtonText: t("Cancel"), | ||||
| confirmButtonColor: "#8dba00", | 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) { | if (result.isConfirmed) { | ||||
| try { | try { | ||||
| let startRes ; | let startRes ; | ||||
| const mergeExtraIntoLaneTicket = | |||||
| (result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? true; | |||||
| if(isWorkbench){ | if(isWorkbench){ | ||||
| startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, userId: currentUserId ?? 1 }); | |||||
| startRes = await startWorkbenchBatchReleaseAsyncV2({ | |||||
| ids: idsToRelease, | |||||
| userId: currentUserId ?? 1, | |||||
| mergeExtraIntoLaneTicket, | |||||
| }); | |||||
| } | } | ||||
| else{ | else{ | ||||
| startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | ||||
| @@ -625,27 +625,41 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| icon: "question", | icon: "question", | ||||
| title: t("Batch Release"), | title: t("Batch Release"), | ||||
| html: ` | html: ` | ||||
| <div> | |||||
| <div style="text-align: left;"> | |||||
| <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | ||||
| <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | ||||
| ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | ||||
| ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""} | ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""} | ||||
| ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} | ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} | ||||
| ${status ? `${t("Status")}: ${status} ` : ""} | |||||
| ${status ? `${t("Status")}: ${t(status)} ` : ""} | |||||
| </p> | </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> | </div> | ||||
| `, | `, | ||||
| showCancelButton: true, | showCancelButton: true, | ||||
| confirmButtonText: t("Confirm"), | confirmButtonText: t("Confirm"), | ||||
| cancelButtonText: t("Cancel"), | cancelButtonText: t("Cancel"), | ||||
| confirmButtonColor: "#8dba00", | confirmButtonColor: "#8dba00", | ||||
| cancelButtonColor: "#F04438" | |||||
| cancelButtonColor: "#F04438", | |||||
| preConfirm: () => { | |||||
| const el = document.getElementById("mergeExtraIntoLaneTicket") as HTMLInputElement | null; | |||||
| return { mergeExtraIntoLaneTicket: el?.checked ?? false }; | |||||
| }, | |||||
| }); | }); | ||||
| if (result.isConfirmed) { | if (result.isConfirmed) { | ||||
| try { | 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 startEntity = startRes?.entity as { jobId?: string } | undefined; | ||||
| const jobId = startEntity?.jobId; | const jobId = startEntity?.jobId; | ||||
| @@ -33,10 +33,12 @@ import { | |||||
| DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | ||||
| type WorkbenchLanePanelPrefs, | type WorkbenchLanePanelPrefs, | ||||
| } from "./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 { | function sumIncompleteEtraDopoTickets(groups: WorkbenchEtraShopLaneGroup[]): number { | ||||
| let n = 0; | let n = 0; | ||||
| for (const g of groups) { | 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 [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(null); | ||||
| const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); | const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); | ||||
| const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0); | const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0); | ||||
| const { t } = useTranslation( ); | |||||
| const { t } = useTranslation(); | |||||
| const a4Printers = React.useMemo( | const a4Printers = React.useMemo( | ||||
| () => (printerCombo || []).filter((printer) => printer.type === "A4"), | () => (printerCombo || []).filter((printer) => printer.type === "A4"), | ||||
| [printerCombo], | [printerCombo], | ||||
| @@ -124,7 +127,6 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| return () => window.removeEventListener("pickOrderAssigned", onAssigned); | return () => window.removeEventListener("pickOrderAssigned", onAssigned); | ||||
| }, [refreshWorkbenchCounts]); | }, [refreshWorkbenchCounts]); | ||||
| /** Opening Etra tab refreshes badge (completion does not always dispatch `pickOrderAssigned`). */ | |||||
| const etraTabMountSkipRef = React.useRef(false); | const etraTabMountSkipRef = React.useRef(false); | ||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| if (!etraTabMountSkipRef.current) { | if (!etraTabMountSkipRef.current) { | ||||
| @@ -137,8 +139,10 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| if (urlTabStr == null || urlTabStr === "") return; | if (urlTabStr == null || urlTabStr === "") return; | ||||
| const n = parseInt(urlTabStr, 10); | 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]); | }, [urlTabStr]); | ||||
| @@ -147,8 +151,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| setTab(newTab); | setTab(newTab); | ||||
| const params = new URLSearchParams(searchParams.toString()); | const params = new URLSearchParams(searchParams.toString()); | ||||
| params.set("tab", String(newTab)); | 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("ticketNo"); | ||||
| params.delete("targetDate"); | 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})`} | {`${t("Print All Draft")} (${releasedOrderCount})`} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| @@ -300,7 +300,6 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| columnGap: 2, | columnGap: 2, | ||||
| rowGap: 1, | rowGap: 1, | ||||
| }, | }, | ||||
| /* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */ | |||||
| "& .MuiTab-root": { | "& .MuiTab-root": { | ||||
| overflow: "visible", | overflow: "visible", | ||||
| minWidth: "auto", | minWidth: "auto", | ||||
| @@ -313,7 +312,6 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| value={1} | value={1} | ||||
| sx={{ | sx={{ | ||||
| overflow: "visible", | overflow: "visible", | ||||
| /* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */ | |||||
| pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, | pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, | ||||
| }} | }} | ||||
| label={ | label={ | ||||
| @@ -404,7 +402,6 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| <TabPanel value={tab} index={6}> | <TabPanel value={tab} index={6}> | ||||
| <TruckRoutingSummaryTabWorkbench /> | <TruckRoutingSummaryTabWorkbench /> | ||||
| </TabPanel> | </TabPanel> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -422,4 +419,3 @@ const DoWorkbenchTabs: React.FC<Props> = (props) => ( | |||||
| ); | ); | ||||
| export default DoWorkbenchTabs; | 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, | DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | ||||
| type WorkbenchLanePanelPrefs, | type WorkbenchLanePanelPrefs, | ||||
| } from "./workbenchLanePanelPrefs"; | } from "./workbenchLanePanelPrefs"; | ||||
| import WorkbenchEtraMergeDialog from "./WorkbenchEtraMergeDialog"; | |||||
| interface Props { | interface Props { | ||||
| onPickOrderAssigned?: () => void; | onPickOrderAssigned?: () => void; | ||||
| @@ -86,6 +87,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| const [modalInitialShopSearch, setModalInitialShopSearch] = useState<string | undefined>(undefined); | const [modalInitialShopSearch, setModalInitialShopSearch] = useState<string | undefined>(undefined); | ||||
| const defaultTruckCount = summary4F?.defaultTruckCount ?? 0; | const defaultTruckCount = summary4F?.defaultTruckCount ?? 0; | ||||
| const etraEnterInFlightRef = useRef(false); | const etraEnterInFlightRef = useRef(false); | ||||
| const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false); | |||||
| const inEtraUi = useMemo(() => etraOnly || isExtraView, [etraOnly, isExtraView]); | const inEtraUi = useMemo(() => etraOnly || isExtraView, [etraOnly, isExtraView]); | ||||
| @@ -393,6 +395,13 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| </Box> | </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 && ( | {!inEtraUi && ( | ||||
| <> | <> | ||||
| <Box sx={{ minWidth: 140, maxWidth: 300 }}> | <Box sx={{ minWidth: 140, maxWidth: 300 }}> | ||||
| @@ -748,6 +757,16 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| onSwitchToDetailTab?.(); | onSwitchToDetailTab?.(); | ||||
| }} | }} | ||||
| /> | /> | ||||
| <WorkbenchEtraMergeDialog | |||||
| open={etraMergeDialogOpen} | |||||
| onClose={() => setEtraMergeDialogOpen(false)} | |||||
| initialDateYmd={selectedDeliveryDateYmd} | |||||
| onMerged={() => { | |||||
| void loadEtraSummaries(); | |||||
| onPickOrderAssigned?.(); | |||||
| window.dispatchEvent(new Event("pickOrderAssigned")); | |||||
| }} | |||||
| /> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -1,7 +1,7 @@ | |||||
| export type WorkbenchLaneDateKey = "today" | "tomorrow" | "dayAfterTomorrow"; | export type WorkbenchLaneDateKey = "today" | "tomorrow" | "dayAfterTomorrow"; | ||||
| export type WorkbenchLaneFloor = "2/F" | "4/F"; | 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 = { | export type WorkbenchLanePanelPrefs = { | ||||
| selectedDate: WorkbenchLaneDateKey; | selectedDate: WorkbenchLaneDateKey; | ||||
| ticketFloor: WorkbenchLaneFloor; | ticketFloor: WorkbenchLaneFloor; | ||||
| @@ -22,7 +22,7 @@ import { | |||||
| FormControlLabel, | FormControlLabel, | ||||
| IconButton, | IconButton, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import type { BomCombo, BomDetailResponse } from "@/app/api/bom"; | |||||
| import type { BomCombo, BomDetailResponse, BomStatus } from "@/app/api/bom"; | |||||
| import { | import { | ||||
| editBomClient, | editBomClient, | ||||
| fetchBomComboClient, | fetchBomComboClient, | ||||
| @@ -60,6 +60,9 @@ function resolveEquipmentCode( | |||||
| return byPair?.code ?? null; | 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 ImportBomDetailTab: React.FC = () => { | ||||
| const { t } = useTranslation(["importBom", "common"]); | const { t } = useTranslation(["importBom", "common"]); | ||||
| const [bomList, setBomList] = useState<BomCombo[]>([]); | const [bomList, setBomList] = useState<BomCombo[]>([]); | ||||
| @@ -130,6 +133,9 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| ProcessMasterRow[] | ProcessMasterRow[] | ||||
| >([]); | >([]); | ||||
| const [editMasterLoading, setEditMasterLoading] = useState(false); | 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). | // Process add form (uses dropdown selections from master tables). | ||||
| const [processAddForm, setProcessAddForm] = useState<{ | const [processAddForm, setProcessAddForm] = useState<{ | ||||
| @@ -178,7 +184,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| const loadList = async () => { | const loadList = async () => { | ||||
| setLoadingList(true); | setLoadingList(true); | ||||
| try { | try { | ||||
| const list = await fetchBomComboClient(); | |||||
| const list = await fetchBomComboClient({ includeInactive: true }); | |||||
| setBomList(list); | setBomList(list); | ||||
| } finally { | } finally { | ||||
| setLoadingList(false); | setLoadingList(false); | ||||
| @@ -209,6 +215,8 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| try { | try { | ||||
| const d = await fetchBomDetailClient(id); | const d = await fetchBomDetailClient(id); | ||||
| setDetail(d); | setDetail(d); | ||||
| setStatusDraft(d.status ?? "active"); | |||||
| setStatusError(null); | |||||
| } finally { | } finally { | ||||
| setLoadingDetail(false); | setLoadingDetail(false); | ||||
| loadDetailInFlightRef.current = false; | loadDetailInFlightRef.current = false; | ||||
| @@ -273,6 +281,38 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| if (v === "WIP") return "半成品"; | if (v === "WIP") return "半成品"; | ||||
| 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) => { | const renderComplexity = (v?: number) => { | ||||
| if (v === 10) return "簡單"; | if (v === 10) return "簡單"; | ||||
| @@ -576,7 +616,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| disabled={loadingDetail} | disabled={loadingDetail} | ||||
| onClick={() => void loadBomDetail(b.id)} | 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> | </Button> | ||||
| ))} | ))} | ||||
| </Stack> | </Stack> | ||||
| @@ -606,7 +646,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| {t("Basic Info")} | {t("Basic Info")} | ||||
| </Typography> | </Typography> | ||||
| {!isEditing ? ( | |||||
| {SHOW_BOM_FULL_EDIT && !isEditing ? ( | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| startIcon={ | startIcon={ | ||||
| @@ -622,7 +662,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| > | > | ||||
| {editMasterLoading ? t("Loading...") : t("Edit")} | {editMasterLoading ? t("Loading...") : t("Edit")} | ||||
| </Button> | </Button> | ||||
| ) : ( | |||||
| ) : SHOW_BOM_FULL_EDIT ? ( | |||||
| <Stack direction="row" spacing={1}> | <Stack direction="row" spacing={1}> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -643,7 +683,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| {t("Cancel")} | {t("Cancel")} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| )} | |||||
| ) : null} | |||||
| </Stack> | </Stack> | ||||
| {editError && ( | {editError && ( | ||||
| @@ -653,12 +693,43 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| )} | )} | ||||
| {!isEditing && ( | {!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"> | <Typography variant="body2"> | ||||
| {t("Output Quantity")}: {detail.outputQty} {detail.outputQtyUom} | {t("Output Quantity")}: {detail.outputQty} {detail.outputQtyUom} | ||||
| {" "} | {" "} | ||||
| {t("Type")}: {detail.description ?? "-"} | {t("Type")}: {detail.description ?? "-"} | ||||
| {" "} | |||||
| {t("BOM Status")}: {renderBomStatus(detail.status)} | |||||
| </Typography> | </Typography> | ||||
| {/* 第二行:各種指標,排成一行 key:value, key:value */} | {/* 第二行:各種指標,排成一行 key:value, key:value */} | ||||
| @@ -1050,7 +1121,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| <TableCell> {t("Sequence")}</TableCell> | <TableCell> {t("Sequence")}</TableCell> | ||||
| <TableCell> {t("Process Name")}</TableCell> | <TableCell> {t("Process Name")}</TableCell> | ||||
| <TableCell> {t("Process Description")}</TableCell> | <TableCell> {t("Process Description")}</TableCell> | ||||
| <TableCell> {t("Process Code")}</TableCell> | |||||
| {/*<TableCell> {t("Process Code")}</TableCell>*/} | |||||
| <TableCell>設備(說明/名稱)</TableCell> | <TableCell>設備(說明/名稱)</TableCell> | ||||
| <TableCell align="right"> {t("Duration (Minutes)")}</TableCell> | <TableCell align="right"> {t("Duration (Minutes)")}</TableCell> | ||||
| <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | ||||
| @@ -1226,7 +1297,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| <TableCell>{p.seqNo}</TableCell> | <TableCell>{p.seqNo}</TableCell> | ||||
| <TableCell>{p.processName}</TableCell> | <TableCell>{p.processName}</TableCell> | ||||
| <TableCell>{p.processDescription}</TableCell> | <TableCell>{p.processDescription}</TableCell> | ||||
| <TableCell>{p.processCode ?? "-"}</TableCell> | |||||
| {/*<TableCell>{p.processCode ?? "-"}</TableCell>*/} | |||||
| <TableCell>{p.equipmentCode ?? p.equipmentName}</TableCell> | <TableCell>{p.equipmentCode ?? p.equipmentName}</TableCell> | ||||
| <TableCell align="right">{p.durationInMinute}</TableCell> | <TableCell align="right">{p.durationInMinute}</TableCell> | ||||
| <TableCell align="right">{p.prepTimeInMinute}</TableCell> | <TableCell align="right">{p.prepTimeInMinute}</TableCell> | ||||
| @@ -41,6 +41,18 @@ import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLa | |||||
| import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; | import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; | ||||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| import ScanStatusAlert from "@/components/common/ScanStatusAlert"; | import ScanStatusAlert from "@/components/common/ScanStatusAlert"; | ||||
| import { | |||||
| buildUnpickableScanRowPatch, | |||||
| getWorkbenchSourceLotStatusSummary, | |||||
| inferUnpickableScanAvailability, | |||||
| isExpiredWorkbenchReminderMessage, | |||||
| isInventoryLotLineUnavailable, | |||||
| isLotAvailabilityExpired, | |||||
| isWorkbenchSourceLotExpired, | |||||
| isWorkbenchZeroCompleteLot, | |||||
| translateWorkbenchRejectMessage, | |||||
| type UnpickableScanAvailability, | |||||
| } from "@/utils/workbenchPickLotUtils"; | |||||
| dayjs.extend(arraySupport); | dayjs.extend(arraySupport); | ||||
| @@ -201,20 +213,6 @@ const isCheckedStatus = (status: string | undefined): boolean => | |||||
| const isRejectedStatus = (status: string | undefined): boolean => | const isRejectedStatus = (status: string | undefined): boolean => | ||||
| String(status || "").toLowerCase() === "rejected"; | String(status || "").toLowerCase() === "rejected"; | ||||
| const 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 isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => { | ||||
| const c = String(code || "").toUpperCase(); | const c = String(code || "").toUpperCase(); | ||||
| const m = String(message || ""); | const m = String(message || ""); | ||||
| @@ -324,6 +322,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = useState<LotRow | null>(null); | const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = useState<LotRow | null>(null); | ||||
| const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = | const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = | ||||
| useState<{ itemId: number; stockInLineId: number } | null>(null); | useState<{ itemId: number; stockInLineId: number } | null>(null); | ||||
| const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] = useState<string | null>(null); | |||||
| const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false); | const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false); | ||||
| const [lotConfirmationError, setLotConfirmationError] = useState<string | null>(null); | const [lotConfirmationError, setLotConfirmationError] = useState<string | null>(null); | ||||
| const [expectedLotData, setExpectedLotData] = useState<ConfirmLotState | null>(null); | const [expectedLotData, setExpectedLotData] = useState<ConfirmLotState | null>(null); | ||||
| @@ -360,10 +359,16 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| for (const row of lotRows) { | for (const row of lotRows) { | ||||
| const itemId = Number(row.itemId); | const itemId = Number(row.itemId); | ||||
| const stockInLineId = Number(row.stockInLineId); | 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 = | const isActive = | ||||
| row.stockOutLineId > 0 && | row.stockOutLineId > 0 && | ||||
| !isCompletedStatus(row.status) && | !isCompletedStatus(row.status) && | ||||
| !isCheckedStatus(row.status); | |||||
| !isCheckedStatus(row.status) && | |||||
| !isRejected && | |||||
| !isUnavailable && | |||||
| !isExpired; | |||||
| if (Number.isFinite(itemId) && itemId > 0) { | if (Number.isFinite(itemId) && itemId > 0) { | ||||
| if (!byItemId.has(itemId)) byItemId.set(itemId, []); | if (!byItemId.has(itemId)) byItemId.set(itemId, []); | ||||
| @@ -649,6 +654,29 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| [hasQtyOverrideBySolId, resolveSingleSubmitQty], | [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( | const handleJustComplete = useCallback( | ||||
| async (row: LotRow) => { | async (row: LotRow) => { | ||||
| if (!row.stockOutLineId) { | if (!row.stockOutLineId) { | ||||
| @@ -657,36 +685,20 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| const lotNo = String(row.lotNo || "").trim(); | const lotNo = String(row.lotNo || "").trim(); | ||||
| const isUnavailable = isInventoryLotLineUnavailable(row); | |||||
| const isExpired = isLotExpired(row); | |||||
| const isZeroComplete = isWorkbenchZeroCompleteLot(row); | |||||
| const hasExplicitOverride = hasQtyOverrideBySolId(row.stockOutLineId); | const hasExplicitOverride = hasQtyOverrideBySolId(row.stockOutLineId); | ||||
| const explicitQty = hasExplicitOverride ? Number(qtyBySolId[row.stockOutLineId]) : NaN; | const explicitQty = hasExplicitOverride ? Number(qtyBySolId[row.stockOutLineId]) : NaN; | ||||
| const qtyPayload = workbenchScanPickQtyFromLot(row); | const qtyPayload = workbenchScanPickQtyFromLot(row); | ||||
| const wbJustQty = qtyPayload.qty; | const wbJustQty = qtyPayload.qty; | ||||
| const canPostScanPick = | const canPostScanPick = | ||||
| isUnavailable || | |||||
| isZeroComplete || | |||||
| (lotNo !== "" && | (lotNo !== "" && | ||||
| ((hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) || | ((hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) || | ||||
| (wbJustQty != null && wbJustQty > 0))); | (wbJustQty != null && wbJustQty > 0))); | ||||
| if (!canPostScanPick) { | 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); | setError(msg); | ||||
| startTransition(() => { | startTransition(() => { | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| @@ -697,7 +709,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| const qtyToSend = | const qtyToSend = | ||||
| isUnavailable || (hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) | |||||
| isZeroComplete || (hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) | |||||
| ? 0 | ? 0 | ||||
| : Number(wbJustQty); | : Number(wbJustQty); | ||||
| @@ -743,18 +755,82 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| [loadLineDetailV2, selectedPickOrderLineId], | [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( | const handleWorkbenchLotLabelScanPick = useCallback( | ||||
| async ({ inventoryLotLineId, lotNo, qty }: { inventoryLotLineId: number; lotNo: string; qty?: number }) => { | async ({ inventoryLotLineId, lotNo, qty }: { inventoryLotLineId: number; lotNo: string; qty?: number }) => { | ||||
| if (!userId) throw new Error(t("User not found")); | 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")); | throw new Error(t("No stock out line for this lot")); | ||||
| } | } | ||||
| const fallbackQty = Number( | const fallbackQty = Number( | ||||
| resolveSingleSubmitQty(workbenchLotLabelContextLot), | |||||
| resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot), | |||||
| ); | ); | ||||
| const res = await workbenchScanPick({ | const res = await workbenchScanPick({ | ||||
| stockOutLineId: workbenchLotLabelContextLot.stockOutLineId, | stockOutLineId: workbenchLotLabelContextLot.stockOutLineId, | ||||
| @@ -782,12 +858,13 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); | await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); | ||||
| } | } | ||||
| setWorkbenchLotLabelModalOpen(false); | setWorkbenchLotLabelModalOpen(false); | ||||
| setWorkbenchLotLabelReminderText(null); | |||||
| setWorkbenchLotLabelContextLot(null); | setWorkbenchLotLabelContextLot(null); | ||||
| setWorkbenchLotLabelInitialPayload(null); | setWorkbenchLotLabelInitialPayload(null); | ||||
| }, | }, | ||||
| [ | [ | ||||
| loadLineDetailV2, | loadLineDetailV2, | ||||
| qtyBySolId, | |||||
| resolveLockedSubmitQtyDisplay, | |||||
| selectedPickOrderId, | selectedPickOrderId, | ||||
| selectedPickOrderLineId, | selectedPickOrderLineId, | ||||
| selectedTopMeta, | selectedTopMeta, | ||||
| @@ -1172,23 +1249,26 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| }); | }); | ||||
| setMessage(t("This lot is unavailable, please scan another lot.")); | 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; | return; | ||||
| } | } | ||||
| if (scannedRowInItem && isLotExpired(scannedRowInItem)) { | |||||
| const expiredMsg = t("Lot is expired"); | |||||
| setError(expiredMsg); | |||||
| if (scannedRowInItem && isWorkbenchSourceLotExpired(scannedRowInItem)) { | |||||
| startTransition(() => { | startTransition(() => { | ||||
| setQrScanError(true); | |||||
| setQrScanError(false); | |||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg( | |||||
| scannedRowInItem.expiryDate | |||||
| ? `${expiredMsg} (expiry=${scannedRowInItem.expiryDate})` | |||||
| : expiredMsg, | |||||
| ); | |||||
| }); | }); | ||||
| openWorkbenchLotLabelModalForLot(scannedRowInItem); | |||||
| openUnpickableScanLotLabelModal( | |||||
| scannedRowInItem, | |||||
| scannedRowInItem, | |||||
| `Lot is expired (expiry=${scannedRowInItem.expiryDate || "-"})`, | |||||
| latest, | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -1252,6 +1332,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| lotConfirmationOpen, | lotConfirmationOpen, | ||||
| pickExpectedRowForSubstitution, | pickExpectedRowForSubstitution, | ||||
| lotRowIndexes, | lotRowIndexes, | ||||
| openUnpickableScanLotLabelModal, | |||||
| resetScan, | resetScan, | ||||
| submitRow, | submitRow, | ||||
| t, | t, | ||||
| @@ -1301,6 +1382,8 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchLotLabelModalOpen && latest === lastProcessedQrRef.current) return; | |||||
| if (latest === lastProcessedQrRef.current || processedQrCodesRef.current.has(latest)) return; | if (latest === lastProcessedQrRef.current || processedQrCodesRef.current.has(latest)) return; | ||||
| lastProcessedQrRef.current = latest; | lastProcessedQrRef.current = latest; | ||||
| processedQrCodesRef.current.add(latest); | processedQrCodesRef.current.add(latest); | ||||
| @@ -1343,6 +1426,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| processOutsideQrCode, | processOutsideQrCode, | ||||
| qrValues, | qrValues, | ||||
| resetScan, | resetScan, | ||||
| workbenchLotLabelModalOpen, | |||||
| ]); | ]); | ||||
| return ( | return ( | ||||
| @@ -1469,7 +1553,20 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <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}> | <TableRow key={r.key}> | ||||
| <TableCell>{idx === 0 ? lotPagingController.pageNum * lotPagingController.pageSize + 1 : ""}</TableCell> | <TableCell>{idx === 0 ? lotPagingController.pageNum * lotPagingController.pageSize + 1 : ""}</TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -1485,8 +1582,35 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{r.location || "-"}</TableCell> | <TableCell>{r.location || "-"}</TableCell> | ||||
| <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 ? ( | {r.stockOutLineId > 0 ? ( | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -1506,24 +1630,66 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| ).toLocaleString()}(${r.uomDesc || ""})`} | ).toLocaleString()}(${r.uomDesc || ""})`} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center"> | <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> | ||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| <Stack direction="row" spacing={1} justifyContent="center" alignItems="center"> | <Stack direction="row" spacing={1} justifyContent="center" alignItems="center"> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={qtyBySolId[r.stockOutLineId] ?? Number(r.requiredQty)} | |||||
| value={ | |||||
| qtyEditableBySolId[r.stockOutLineId] === true && hasQtyOverride | |||||
| ? String(qtyBySolId[r.stockOutLineId]) | |||||
| : String(submitQtyDisplay) | |||||
| } | |||||
| onKeyDown={(e) => { | onKeyDown={(e) => { | ||||
| const editable = qtyEditableBySolId[r.stockOutLineId] === true; | const editable = qtyEditableBySolId[r.stockOutLineId] === true; | ||||
| if (!editable) return; | if (!editable) return; | ||||
| @@ -1587,7 +1753,8 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ))} | |||||
| ); | |||||
| })} | |||||
| {lotRows.length === 0 ? ( | {lotRows.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={9} align="center" sx={{ textAlign: "center" }}> | <TableCell colSpan={9} align="center" sx={{ textAlign: "center" }}> | ||||
| @@ -1620,17 +1787,21 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| open={workbenchLotLabelModalOpen} | open={workbenchLotLabelModalOpen} | ||||
| onClose={() => { | onClose={() => { | ||||
| setWorkbenchLotLabelModalOpen(false); | setWorkbenchLotLabelModalOpen(false); | ||||
| setWorkbenchLotLabelReminderText(null); | |||||
| setWorkbenchLotLabelContextLot(null); | setWorkbenchLotLabelContextLot(null); | ||||
| setWorkbenchLotLabelInitialPayload(null); | setWorkbenchLotLabelInitialPayload(null); | ||||
| }} | }} | ||||
| initialPayload={workbenchLotLabelInitialPayload} | initialPayload={workbenchLotLabelInitialPayload} | ||||
| initialItemId={workbenchLotLabelContextLot?.itemId ?? null} | initialItemId={workbenchLotLabelContextLot?.itemId ?? null} | ||||
| hideScanSection={workbenchLotLabelInitialPayload != null || workbenchLotLabelContextLot != null} | hideScanSection={workbenchLotLabelInitialPayload != null || workbenchLotLabelContextLot != null} | ||||
| reminderText={workbenchLotLabelReminderText ?? undefined} | |||||
| statusTitleText={workbenchLotLabelStatusBanner.text} | |||||
| statusTitleSeverity={workbenchLotLabelStatusBanner.severity} | |||||
| triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} | triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} | ||||
| triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} | triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} | ||||
| submitQty={ | submitQty={ | ||||
| workbenchLotLabelContextLot?.stockOutLineId | workbenchLotLabelContextLot?.stockOutLineId | ||||
| ? Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) | |||||
| ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) | |||||
| : null | : null | ||||
| } | } | ||||
| onSubmitQtyChange={(qty) => { | onSubmitQtyChange={(qty) => { | ||||
| @@ -31,6 +31,7 @@ | |||||
| "Estimated Arrival From": "Estimated Arrival From", | "Estimated Arrival From": "Estimated Arrival From", | ||||
| "Estimated Arrival To": "Estimated Arrival To", | "Estimated Arrival To": "Estimated Arrival To", | ||||
| "Etra": "Etra", | "Etra": "Etra", | ||||
| "Merge extra orders into lane batch ticket": "Merge into lane merge ticket (isExtrabatch, TI-M- prefix)", | |||||
| "Expiry Date": "Expiry Date", | "Expiry Date": "Expiry Date", | ||||
| "Failed to assign pick orders. Please try again later.": "Failed to assign pick orders. Please try again later.", | "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.", | "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", | "Is Drink": "Is Drink", | ||||
| "Drink": "Drink", | "Drink": "Drink", | ||||
| "Powder_Mixture": "Powder Mixture", | "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.stockRecord": "Stock Record", | ||||
| "nav.store.doWorkbench": "DO Workbench", | "nav.store.doWorkbench": "DO Workbench", | ||||
| "nav.deliveryOrder": "Delivery Order", | "nav.deliveryOrder": "Delivery Order", | ||||
| "nav.deliveryOrder.search": "Search Delivery Order", | |||||
| "nav.deliveryOrder.replenish": "DO Replenishment", | |||||
| "nav.scheduling": "Scheduling", | "nav.scheduling": "Scheduling", | ||||
| "nav.jobOrderManagement": "Management Job Order", | "nav.jobOrderManagement": "Management Job Order", | ||||
| "nav.jobOrder.searchCreate": "Search Job Order/ Create Job Order", | "nav.jobOrder.searchCreate": "Search Job Order/ Create Job Order", | ||||
| @@ -151,6 +151,25 @@ | |||||
| "Enter isExtra workbench view?": "Enter isExtra workbench view?", | "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 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", | "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", | "Pick Order": "Pick Order", | ||||
| "Type": "Type", | "Type": "Type", | ||||
| "Product Type": "Product 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 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.", | "Lot confirmation failed. Please try again.": "Lot confirmation failed. Please try again.", | ||||
| "Powder Mixture": "Powder Mixture", | "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", | "This lot is not yet putaway": "This lot is not yet putaway", | ||||
| "Cannot resolve new inventory lot line": "Cannot resolve new inventory lot line", | "Cannot resolve new inventory lot line": "Cannot resolve new inventory lot line", | ||||
| "Pick order line item is null": "Pick order line item is null", | "Pick order line item is null": "Pick order line item is null", | ||||
| @@ -11,6 +11,7 @@ | |||||
| "Estimated Arrival To": "預計送貨日期至", | "Estimated Arrival To": "預計送貨日期至", | ||||
| "Status": "來貨狀態", | "Status": "來貨狀態", | ||||
| "Etra": "加單", | "Etra": "加單", | ||||
| "Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)", | |||||
| "Loading": "正在加載...", | "Loading": "正在加載...", | ||||
| "No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection.": "沒有選擇送貨訂單進行批量放單。取消勾選您想排除的訂單,或重新搜索以重置選擇。", | "No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection.": "沒有選擇送貨訂單進行批量放單。取消勾選您想排除的訂單,或重新搜索以重置選擇。", | ||||
| "No Records": "沒有找到記錄", | "No Records": "沒有找到記錄", | ||||
| @@ -26,6 +27,7 @@ | |||||
| "Select Remark": "選擇備註", | "Select Remark": "選擇備註", | ||||
| "Confirm Assignment": "確認分配", | "Confirm Assignment": "確認分配", | ||||
| "Submit Qty": "提交數量", | "Submit Qty": "提交數量", | ||||
| "No inventory lot lines for inventoryLotId": "此批次尚未上架", | |||||
| "Required Date": "所需日期", | "Required Date": "所需日期", | ||||
| "Submit Miss Item": "提交缺貨品", | "Submit Miss Item": "提交缺貨品", | ||||
| "Submit Quantity": "提交數量", | "Submit Quantity": "提交數量", | ||||
| @@ -95,6 +97,24 @@ | |||||
| "Delivery": "送貨訂單", | "Delivery": "送貨訂單", | ||||
| "Replenishment page title": "送貨單補貨", | "Replenishment page title": "送貨單補貨", | ||||
| "Replenishment demo banner": "示範模式:候選送貨單與補貨提交均使用假資料,後端 API 完成後會切換為真實資料。", | "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 input section": "補貨資料", | ||||
| "Replenishment item code": "貨品編號", | "Replenishment item code": "貨品編號", | ||||
| "Replenishment search candidates": "搜尋候選送貨單", | "Replenishment search candidates": "搜尋候選送貨單", | ||||
| @@ -8,5 +8,25 @@ | |||||
| "Is Drink": "飲料", | "Is Drink": "飲料", | ||||
| "Drink": "飲料", | "Drink": "飲料", | ||||
| "Powder_Mixture": "箱料粉", | "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.chartReports": "圖表報告", | ||||
| "nav.dashboard": "資訊展示面板", | "nav.dashboard": "資訊展示面板", | ||||
| "nav.deliveryOrder": "送貨訂單", | "nav.deliveryOrder": "送貨訂單", | ||||
| "nav.deliveryOrder.search": "搜尋送貨單", | |||||
| "nav.deliveryOrder.replenish": "送貨單補貨", | |||||
| "nav.jobOrder.bagUsage": "包裝袋使用記錄", | "nav.jobOrder.bagUsage": "包裝袋使用記錄", | ||||
| "nav.jobOrder.pickExecution": "工單提料", | "nav.jobOrder.pickExecution": "工單提料", | ||||
| "nav.jobOrder.productionProcess": "工單生產流程", | "nav.jobOrder.productionProcess": "工單生產流程", | ||||
| @@ -144,13 +144,38 @@ | |||||
| "isExtra order": "加單", | "isExtra order": "加單", | ||||
| "Etra": "加單", | "Etra": "加單", | ||||
| "Exit Etra view": "離開加單檢視", | "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": "當日未完成加單票:{{count}} 張(待處理/已發佈,不含已結案)", | ||||
| "Etra incomplete badge tooltip none": "目前無未完成加單票", | "Etra incomplete badge tooltip none": "目前無未完成加單票", | ||||
| "Back to normal assign tab": "返回一般指派分頁", | "Back to normal assign tab": "返回一般指派分頁", | ||||
| "Enter isExtra workbench view?": "進入加單檢視?", | "Enter isExtra workbench view?": "進入加單檢視?", | ||||
| "Etra view groups all add-on tickets by shop and lane for the selected date.": "加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。", | "Etra view groups all add-on tickets by shop and lane for the selected date.": "加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。", | ||||
| "Etra Ticket Notice": "目前是加單票,顯示與操作已切換為加單模式。", | "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": "提料單", | "Pick Order": "提料單", | ||||
| "Type": "類型", | "Type": "類型", | ||||
| "Product Type": "貨品類型", | "Product Type": "貨品類型", | ||||
| @@ -58,6 +58,13 @@ export function isWorkbenchZeroCompleteLot( | |||||
| return isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot); | 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 { | export function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { | ||||
| const msg = raw.trim(); | const msg = raw.trim(); | ||||
| if (!msg) return msg; | 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); | return t(msg); | ||||
| } | } | ||||
| @@ -98,8 +109,11 @@ export function inferUnpickableScanAvailability( | |||||
| m.includes("unavailable") || | m.includes("unavailable") || | ||||
| m.includes("not available") || | m.includes("not available") || | ||||
| m.includes("not yet putaway") || | 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("未上架") || | |||||
| m.includes("尚未上架") | |||||
| ) { | ) { | ||||
| return "status_unavailable"; | return "status_unavailable"; | ||||
| } | } | ||||
| @@ -9,6 +9,7 @@ export function isWorkbenchExtraTicket( | |||||
| rt === "isextrabatch" || | rt === "isextrabatch" || | ||||
| rt === "isextrasingle" || | rt === "isextrasingle" || | ||||
| rt === "isextra" || | rt === "isextra" || | ||||
| tn.startsWith("TI-E-") | |||||
| tn.startsWith("TI-E-") || | |||||
| tn.startsWith("TI-M-") | |||||
| ); | ); | ||||
| } | } | ||||