Parcourir la source

job order bom status

and do merge
production
CANCERYS\kw093 il y a 1 semaine
Parent
révision
59c464ae3f
21 fichiers modifiés avec 1052 ajouts et 118 suppressions
  1. +9
    -2
      src/app/api/bom/client.ts
  2. +5
    -0
      src/app/api/bom/index.ts
  3. +57
    -2
      src/app/api/doworkbench/actions.ts
  4. +25
    -3
      src/components/DoSearch/DoSearch.tsx
  5. +18
    -4
      src/components/DoSearchWorkbench/DoSearchWorkbench.tsx
  6. +13
    -17
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  7. +453
    -0
      src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx
  8. +19
    -0
      src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
  9. +1
    -1
      src/components/DoWorkbench/workbenchLanePanelPrefs.ts
  10. +80
    -9
      src/components/ImportBom/ImportBomDetailTab.tsx
  11. +246
    -75
      src/components/PickOrderSearch/WorkbenchPickExecution.tsx
  12. +1
    -0
      src/i18n/en/do.json
  13. +16
    -1
      src/i18n/en/importBom.json
  14. +2
    -0
      src/i18n/en/navigation.json
  15. +21
    -0
      src/i18n/en/pickOrder.json
  16. +20
    -0
      src/i18n/zh/do.json
  17. +21
    -1
      src/i18n/zh/importBom.json
  18. +2
    -0
      src/i18n/zh/navigation.json
  19. +26
    -1
      src/i18n/zh/pickOrder.json
  20. +15
    -1
      src/utils/workbenchPickLotUtils.ts
  21. +2
    -1
      src/utils/workbenchReleaseType.ts

+ 9
- 2
src/app/api/bom/client.ts Voir le fichier

@@ -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;
}


+ 5
- 0
src/app/api/bom/index.ts Voir le fichier

@@ -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[];


+ 57
- 2
src/app/api/doworkbench/actions.ts Voir le fichier

@@ -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;


+ 25
- 3
src/components/DoSearch/DoSearch.tsx Voir le fichier

@@ -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 });


+ 18
- 4
src/components/DoSearchWorkbench/DoSearchWorkbench.tsx Voir le fichier

@@ -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;


+ 13
- 17
src/components/DoWorkbench/DoWorkbenchTabs.tsx Voir le fichier

@@ -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;


+ 453
- 0
src/components/DoWorkbench/WorkbenchEtraMergeDialog.tsx Voir le fichier

@@ -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;

+ 19
- 0
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx Voir le fichier

@@ -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
- 1
src/components/DoWorkbench/workbenchLanePanelPrefs.ts Voir le fichier

@@ -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;


+ 80
- 9
src/components/ImportBom/ImportBomDetailTab.tsx Voir le fichier

@@ -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>


+ 246
- 75
src/components/PickOrderSearch/WorkbenchPickExecution.tsx Voir le fichier

@@ -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) => {


+ 1
- 0
src/i18n/en/do.json Voir le fichier

@@ -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.",


+ 16
- 1
src/i18n/en/importBom.json Voir le fichier

@@ -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"
}

+ 2
- 0
src/i18n/en/navigation.json Voir le fichier

@@ -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",


+ 21
- 0
src/i18n/en/pickOrder.json Voir le fichier

@@ -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",


+ 20
- 0
src/i18n/zh/do.json Voir le fichier

@@ -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": "搜尋候選送貨單",


+ 21
- 1
src/i18n/zh/importBom.json Voir le fichier

@@ -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...": "儲存中…"
}

+ 2
- 0
src/i18n/zh/navigation.json Voir le fichier

@@ -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": "工單生產流程",


+ 26
- 1
src/i18n/zh/pickOrder.json Voir le fichier

@@ -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": "貨品類型",


+ 15
- 1
src/utils/workbenchPickLotUtils.ts Voir le fichier

@@ -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";
}


+ 2
- 1
src/utils/workbenchReleaseType.ts Voir le fichier

@@ -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-")
);
}

Chargement…
Annuler
Enregistrer