Browse Source

new supplier

isEtra
new do chart
do saerch batch release button put down
not lot requied qty show 0 fix
production
CANCERYS\kw093 1 month ago
parent
commit
1bbaa24c00
24 changed files with 1543 additions and 545 deletions
  1. +82
    -5
      src/app/(main)/chart/delivery/page.tsx
  2. +2
    -2
      src/app/(main)/m18Syn/page.tsx
  3. +21
    -0
      src/app/(main)/settings/deliveryOrderFloor/page.tsx
  4. +8
    -1
      src/app/api/chart/client.ts
  5. +6
    -6
      src/app/api/do/actions.tsx
  6. +29
    -2
      src/app/api/doworkbench/actions.ts
  7. +4
    -0
      src/app/api/pickOrder/actions.ts
  8. +75
    -0
      src/app/api/settings/deliveryOrderFloor/client.ts
  9. +5
    -0
      src/app/api/settings/deliveryOrderFloor/constants.ts
  10. +482
    -0
      src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx
  11. +216
    -233
      src/components/DoSearch/DoSearch.tsx
  12. +24
    -2
      src/components/DoWorkbench/DoWorkbenchPickShell.tsx
  13. +142
    -24
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  14. +24
    -2
      src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
  15. +238
    -31
      src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
  16. +67
    -116
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  17. +40
    -6
      src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx
  18. +1
    -112
      src/components/JoWorkbench/newJobPickExecution.tsx
  19. +6
    -0
      src/components/NavigationContent/NavigationContent.tsx
  20. +1
    -0
      src/i18n/en/common.json
  21. +33
    -0
      src/i18n/en/deliveryOrderFloor.json
  22. +1
    -0
      src/i18n/zh/common.json
  23. +33
    -0
      src/i18n/zh/deliveryOrderFloor.json
  24. +3
    -3
      src/i18n/zh/pickOrder.json

+ 82
- 5
src/app/(main)/chart/delivery/page.tsx View File

@@ -21,6 +21,7 @@ import {
fetchTopDeliveryItemsItemOptions,
fetchStaffDeliveryPerformance,
fetchStaffDeliveryPerformanceHandlers,
type StaffDeliveryPerformanceStoreFilter,
type StaffOption,
type TopDeliveryItemOption,
} from "@/app/api/chart/client";
@@ -31,16 +32,38 @@ import SafeApexCharts from "@/components/charts/SafeApexCharts";

const PAGE_TITLE = "發貨與配送";

const STAFF_PERF_STORE_FILTER_OPTIONS: {
value: StaffDeliveryPerformanceStoreFilter;
label: string;
}[] = [
{ value: "all", label: "全部" },
{ value: "2/F", label: "2/F" },
{ value: "4/F", label: "4/F" },
{ value: "null_only", label: "車線-X" },
];

type Criteria = {
delivery: { rangeDays: number };
topItems: { rangeDays: number; limit: number };
staffPerf: { rangeDays: number };
staffPerf: {
rangeDays: number;
startDate: string;
endDate: string;
storeFilter: StaffDeliveryPerformanceStoreFilter;
};
};

const defaultStaffPerfDateRange = toDateRange(DEFAULT_RANGE_DAYS);

const defaultCriteria: Criteria = {
delivery: { rangeDays: DEFAULT_RANGE_DAYS },
topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 },
staffPerf: { rangeDays: DEFAULT_RANGE_DAYS },
staffPerf: {
rangeDays: DEFAULT_RANGE_DAYS,
startDate: defaultStaffPerfDateRange.startDate,
endDate: defaultStaffPerfDateRange.endDate,
storeFilter: "all",
},
};

export default function DeliveryChartPage() {
@@ -101,10 +124,20 @@ export default function DeliveryChartPage() {
}, [criteria.topItems, topItemsSelected, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays);
const s = criteria.staffPerf.startDate;
const e = criteria.staffPerf.endDate;
if (!s || !e) {
setChartData((prev) => ({ ...prev, staffPerf: [] }));
return;
}
if (s > e) {
setError("員工發貨績效的起始日期不能晚於結束日期");
setChartData((prev) => ({ ...prev, staffPerf: [] }));
return;
}
const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined;
setChartLoading("staffPerf", true);
fetchStaffDeliveryPerformance(s, e, staffNos)
fetchStaffDeliveryPerformance(s, e, staffNos, criteria.staffPerf.storeFilter)
.then((data) =>
setChartData((prev) => ({
...prev,
@@ -270,8 +303,52 @@ export default function DeliveryChartPage() {
<>
<DateRangeSelect
value={criteria.staffPerf.rangeDays}
onChange={(v) => updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))}
onChange={(v) =>
updateCriteria("staffPerf", (c) => {
const { startDate, endDate } = toDateRange(v);
return { ...c, rangeDays: v, startDate, endDate };
})
}
/>
<TextField
size="small"
label="開始日期"
type="date"
value={criteria.staffPerf.startDate}
onChange={(e) =>
updateCriteria("staffPerf", (c) => ({ ...c, startDate: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
<TextField
size="small"
label="結束日期"
type="date"
value={criteria.staffPerf.endDate}
onChange={(e) =>
updateCriteria("staffPerf", (c) => ({ ...c, endDate: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>倉別</InputLabel>
<Select
label="倉別"
value={criteria.staffPerf.storeFilter}
onChange={(e) =>
updateCriteria("staffPerf", (c) => ({
...c,
storeFilter: e.target.value as StaffDeliveryPerformanceStoreFilter,
}))
}
>
{STAFF_PERF_STORE_FILTER_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
multiple
size="small"


+ 2
- 2
src/app/(main)/m18Syn/page.tsx View File

@@ -163,7 +163,7 @@ export default function M18SynPage() {
}
};

/** DO(加單):手動按 code 同步,並寫入本地 isEtra=true(可輸入多個 code,用逗號或換行分隔) */
/** DO(加單):手動按 code 同步,並寫入本地 isExtra=true(可輸入多個 code,用逗號或換行分隔) */
const handleSyncM18DoExtraByCode = async () => {
if (m18DoExtraInFlightRef.current) return;
const raw = m18DoExtraCode.trim();
@@ -339,7 +339,7 @@ export default function M18SynPage() {
</TabPanel>

<TabPanel value={tabValue} index={2}>
<Section title="M18 送貨訂單 — 加單 (isEtra)">
<Section title="M18 送貨訂單 — 加單 (isExtra)">
<Stack spacing={2} sx={{ mb: 2 }}>
<TextField
label="DO / Shop PO Code(加單)"


+ 21
- 0
src/app/(main)/settings/deliveryOrderFloor/page.tsx View File

@@ -0,0 +1,21 @@
import DeliveryOrderFloorSettings from "@/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings";
import { getServerI18n, I18nProvider } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Delivery order floor",
};

export default async function DeliveryOrderFloorPage() {
const { t } = await getServerI18n("deliveryOrderFloor", "common");

return (
<I18nProvider namespaces={["deliveryOrderFloor", "common"]}>
<Stack spacing={2}>
<Typography variant="h4">{t("title")}</Typography>
<DeliveryOrderFloorSettings />
</Stack>
</I18nProvider>
);
}

+ 8
- 1
src/app/api/chart/client.ts View File

@@ -574,15 +574,22 @@ export async function fetchPlannedOutputByDateAndItem(
}));
}

/** Warehouse / lane filter for staff delivery performance chart (delivery_order_pick_order.store_id). */
export type StaffDeliveryPerformanceStoreFilter = "all" | "2/F" | "4/F" | "null_only";

export async function fetchStaffDeliveryPerformance(
startDate?: string,
endDate?: string,
staffNos?: string[]
staffNos?: string[],
storeFilter: StaffDeliveryPerformanceStoreFilter = "all"
): Promise<StaffDeliveryPerformanceRow[]> {
const p = new URLSearchParams();
if (startDate) p.set("startDate", startDate);
if (endDate) p.set("endDate", endDate);
(staffNos ?? []).forEach((no) => p.append("staffNo", no));
if (storeFilter === "null_only") p.set("storeIdNull", "true");
else if (storeFilter === "2/F") p.set("storeId", "2/F");
else if (storeFilter === "4/F") p.set("storeId", "4/F");
const q = p.toString();
const res = await clientAuthFetch(
q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance`


+ 6
- 6
src/app/api/do/actions.tsx View File

@@ -26,7 +26,7 @@ export interface DoDetail {
completeDate: string;
status: string;
/** 加單 DO */
isEtra?: boolean;
isExtra?: boolean;
deliveryOrderLines: DoDetailLine[];
}

@@ -51,7 +51,7 @@ export interface DoSearchAll {
supplierName: string;
shopName: string;
shopAddress?: string;
isEtra?: boolean;
isExtra?: boolean;
}
export interface DoSearchLiteResponse {
records: DoSearchAll[];
@@ -380,7 +380,7 @@ export async function fetchDoSearch(
/** 後端:All/null 為全部;2F/4F 依供應商白名單篩選 */
floor?: string | null,
/** null:不篩;true/false:只顯示加單或非加單 DO */
isEtra?: boolean | null,
isExtra?: boolean | null,
): Promise<DoSearchLiteResponse> {
// 构建请求体
const requestBody: any = {
@@ -392,7 +392,7 @@ export async function fetchDoSearch(
pageNum: pageNum || 1,
pageSize: pageSize || 10,
floor: floor && floor !== "All" ? floor : null,
...(isEtra !== undefined && isEtra !== null ? { isEtra } : {}),
...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}),
};

// 如果日期不为空,转换为 LocalDateTime 格式
@@ -632,7 +632,7 @@ export async function fetchAllDoSearch(
estArrStartDate: string,
truckLanceCode?: string,
floor?: string | null,
isEtra?: boolean | null,
isExtra?: boolean | null,
): Promise<DoSearchAll[]> {
// 使用一个很大的 pageSize 来获取所有匹配的记录
const requestBody: any = {
@@ -644,7 +644,7 @@ export async function fetchAllDoSearch(
pageNum: 1,
pageSize: 10000, // 使用一个很大的值来获取所有记录
floor: floor && floor !== "All" ? floor : null,
...(isEtra !== undefined && isEtra !== null ? { isEtra } : {}),
...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}),
};

if (estArrStartDate) {


+ 29
- 2
src/app/api/doworkbench/actions.ts View File

@@ -4,6 +4,7 @@ import { revalidateTag } from "next/cache";
import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import type {
LaneBtn,
PostPickOrderResponse,
ReleasedDoPickOrderListItem,
StoreLaneSummary,
@@ -215,16 +216,38 @@ export async function fetchWorkbenchStoreLaneSummary(
});
}

/** All Etra tickets (`releaseType=isExtra`) for a calendar day, grouped by shop → lanes. */
export type WorkbenchEtraShopLaneGroup = {
shopCode: string | null;
shopName: string | null;
lanes: LaneBtn[];
};

export async function fetchWorkbenchEtraLaneSummary(
requiredDate?: string
): Promise<WorkbenchEtraShopLaneGroup[]> {
const dateToUse = requiredDate || dayjs().format("YYYY-MM-DD");
const url = `${BASE_API_URL}/doPickOrder/workbench/summary-is-etra?requiredDate=${encodeURIComponent(dateToUse)}`;
const data = await serverFetchJson<WorkbenchEtraShopLaneGroup[]>(url, {
method: "GET",
cache: "no-store",
next: { revalidate: 0 },
});
return Array.isArray(data) ? data : [];
}

/** Past-date `delivery_order_pick_order` tickets (same shape as `/doPickOrder/released`). */
export async function fetchWorkbenchReleasedDoPickOrdersForSelection(
shopName?: string,
storeId?: string,
truck?: string
truck?: string,
releaseType?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
if (releaseType?.trim()) params.append("releaseType", releaseType.trim());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
@@ -236,13 +259,15 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
shopName?: string,
storeId?: string,
truck?: string,
requiredDeliveryDate?: string
requiredDeliveryDate?: string,
releaseType?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim());
if (releaseType?.trim()) params.append("releaseType", releaseType.trim());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
@@ -258,6 +283,8 @@ export async function assignWorkbenchByLane(data: {
truckDepartureTime?: string;
loadingSequence?: number | null;
requiredDate?: string;
/** Backend normalizes to isExtra / isExtra filter on `delivery_order_pick_order.releaseType` */
releaseType?: string;
}): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-lane`,


+ 4
- 0
src/app/api/pickOrder/actions.ts View File

@@ -464,6 +464,10 @@ export interface LaneBtn {
unassigned: number;
total: number;
handlerName?: string | null;
/** Workbench Etra: dop storeId for assign scope */
storeId?: string | null;
/** ISO local time string for workbench assign-by-lane (Etra summary) */
truckDepartureTime?: string | null;
}

export interface QrPickBatchSubmitRequest {


+ 75
- 0
src/app/api/settings/deliveryOrderFloor/client.ts View File

@@ -0,0 +1,75 @@
"use client";

import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import {
SETTING_DO_FLOOR_SUPPLIERS_2F,
SETTING_DO_FLOOR_SUPPLIERS_4F,
} from "./constants";

const base = NEXT_PUBLIC_API_URL;

export type ShopComboRow = {
id: number;
code: string;
name: string;
value: number;
label: string;
};

export type SettingsRow = {
id: number;
name: string;
value: string;
category?: string | null;
type?: string | null;
};

async function parseJson<T>(res: Response): Promise<T> {
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(t || `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}

/** 供應商列表:`GET /shop/combo/supplier` */
export async function fetchSupplierComboClient(): Promise<ShopComboRow[]> {
const res = await clientAuthFetch(`${base}/shop/combo/supplier`, { method: "GET" });
return parseJson<ShopComboRow[]>(res);
}

/** 店鋪列表:`GET /shop/combo/shop` */
export async function fetchShopComboClient(): Promise<ShopComboRow[]> {
const res = await clientAuthFetch(`${base}/shop/combo/shop`, { method: "GET" });
return parseJson<ShopComboRow[]>(res);
}

export async function fetchAllSettingsClient(): Promise<SettingsRow[]> {
const res = await clientAuthFetch(`${base}/settings`, { method: "GET" });
return parseJson<SettingsRow[]>(res);
}

export async function fetchDoFloorSettingsClient(): Promise<{
suppliers2F: string;
suppliers4F: string;
}> {
const all = await fetchAllSettingsClient();
const get = (name: string) => all.find((s) => s.name === name)?.value ?? "";
return {
suppliers2F: get(SETTING_DO_FLOOR_SUPPLIERS_2F),
suppliers4F: get(SETTING_DO_FLOOR_SUPPLIERS_4F),
};
}

export async function postSettingClient(name: string, value: string): Promise<void> {
const res = await clientAuthFetch(`${base}/settings/${encodeURIComponent(name)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(t || `Failed to save setting: ${res.status}`);
}
}

+ 5
- 0
src/app/api/settings/deliveryOrderFloor/constants.ts View File

@@ -0,0 +1,5 @@
/** `settings.name`:逗號分隔 supplier **code**(須與後端讀取邏輯一致)。 */
export const SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F";
export const SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F";

export const SETTING_DO_FLOOR_CATEGORY = "DO_FLOOR";

+ 482
- 0
src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx View File

@@ -0,0 +1,482 @@
"use client";

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import EditOutlined from "@mui/icons-material/EditOutlined";
import DeleteOutline from "@mui/icons-material/DeleteOutline";
import Add from "@mui/icons-material/Add";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
type SelectChangeEvent,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import {
fetchDoFloorSettingsClient,
fetchSupplierComboClient,
postSettingClient,
type ShopComboRow,
} from "@/app/api/settings/deliveryOrderFloor/client";
import {
SETTING_DO_FLOOR_SUPPLIERS_2F,
SETTING_DO_FLOOR_SUPPLIERS_4F,
} from "@/app/api/settings/deliveryOrderFloor/constants";

function normalizeCodesCsv(raw: string): string {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.join(",");
}

/** 顯示為 `[XXX, YYY]`;無代碼時為 `[]` */
function formatBracketList(codesCsv: string): string {
const n = normalizeCodesCsv(codesCsv);
if (!n) return "[]";
return `[${n.split(",").join(", ")}]`;
}

type EditFloor = "2F" | "4F";

type FloorRow = { code: string; name: string };

function findSupplierRow(combo: ShopComboRow[], raw: string): ShopComboRow | undefined {
const t = raw.trim();
if (!t) return undefined;
const lower = t.toLowerCase();
const exact = combo.find((r) => r.code?.trim() === t);
if (exact) return exact;
return combo.find((r) => (r.code?.trim().toLowerCase() ?? "") === lower);
}

function csvToFloorRows(csv: string, combo: ShopComboRow[]): FloorRow[] {
const n = normalizeCodesCsv(csv);
if (!n) return [];
return n.split(",").map((code) => {
const hit = findSupplierRow(combo, code);
const canonical = hit?.code?.trim() || code;
return { code: canonical, name: hit?.name?.trim() || "" };
});
}

function floorRowsToCsv(rows: FloorRow[]): string {
return rows.map((r) => r.code.trim()).filter(Boolean).join(",");
}

const DeliveryOrderFloorSettings: React.FC = () => {
const { t } = useTranslation("deliveryOrderFloor");
const saveInFlightRef = useRef(false);
const addInFlightRef = useRef(false);

const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);

const [codes2F, setCodes2F] = useState("");
const [codes4F, setCodes4F] = useState("");

const [editOpen, setEditOpen] = useState(false);
const [editFloor, setEditFloor] = useState<EditFloor>("2F");
const [dialogSaving, setDialogSaving] = useState(false);
const [comboLoading, setComboLoading] = useState(false);
const [supplierCombo, setSupplierCombo] = useState<ShopComboRow[] | null>(null);
const [draftRows2F, setDraftRows2F] = useState<FloorRow[]>([]);
const [draftRows4F, setDraftRows4F] = useState<FloorRow[]>([]);

const [addOpen, setAddOpen] = useState(false);
const [addCodeInput, setAddCodeInput] = useState("");
const [addError, setAddError] = useState<string | null>(null);

const load = useCallback(async () => {
setLoading(true);
setError(null);
setSuccess(null);
try {
const floor = await fetchDoFloorSettingsClient();
setCodes2F(floor.suppliers2F);
setCodes4F(floor.suppliers4F);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void load();
}, [load]);

const display2F = useMemo(() => formatBracketList(codes2F), [codes2F]);
const display4F = useMemo(() => formatBracketList(codes4F), [codes4F]);

const currentDraftRows = editFloor === "2F" ? draftRows2F : draftRows4F;
const setCurrentDraftRows = editFloor === "2F" ? setDraftRows2F : setDraftRows4F;

const openEdit = async (floor: EditFloor) => {
setEditFloor(floor);
setEditOpen(true);
setError(null);
setSuccess(null);
setAddOpen(false);
setAddCodeInput("");
setAddError(null);
setComboLoading(true);
try {
const combo = await fetchSupplierComboClient();
setSupplierCombo(combo);
setDraftRows2F(csvToFloorRows(codes2F, combo));
setDraftRows4F(csvToFloorRows(codes4F, combo));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setDraftRows2F([]);
setDraftRows4F([]);
setSupplierCombo(null);
} finally {
setComboLoading(false);
}
};

const closeEdit = () => {
if (dialogSaving) return;
setEditOpen(false);
setAddOpen(false);
setAddCodeInput("");
setAddError(null);
};

const saveCurrentFloor = async () => {
if (saveInFlightRef.current) return;
saveInFlightRef.current = true;
setDialogSaving(true);
setError(null);
setSuccess(null);
try {
const rows = editFloor === "2F" ? draftRows2F : draftRows4F;
const normalized = normalizeCodesCsv(floorRowsToCsv(rows));
const key =
editFloor === "2F" ? SETTING_DO_FLOOR_SUPPLIERS_2F : SETTING_DO_FLOOR_SUPPLIERS_4F;
await postSettingClient(key, normalized);
if (editFloor === "2F") setCodes2F(normalized);
else setCodes4F(normalized);
setSuccess(t("Saved"));
setEditOpen(false);
setAddOpen(false);
setAddCodeInput("");
setAddError(null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDialogSaving(false);
saveInFlightRef.current = false;
}
};

const onFloorSelectChange = (e: SelectChangeEvent<EditFloor>) => {
setEditFloor(e.target.value as EditFloor);
setAddOpen(false);
setAddCodeInput("");
setAddError(null);
};

const removeRow = (code: string) => {
const next = currentDraftRows.filter((r) => r.code !== code);
setCurrentDraftRows(next);
};

const openAddMapping = () => {
if (!supplierCombo?.length) {
setError(t("Supplier list unavailable"));
return;
}
setAddCodeInput("");
setAddError(null);
setAddOpen(true);
};

const closeAdd = () => {
if (addInFlightRef.current) return;
setAddOpen(false);
setAddCodeInput("");
setAddError(null);
};

const confirmAddMapping = () => {
if (addInFlightRef.current) return;
addInFlightRef.current = true;
setAddError(null);
try {
const raw = addCodeInput.trim();
if (!raw) {
setAddError(t("Enter supplier code"));
return;
}
const hit = findSupplierRow(supplierCombo ?? [], raw);
if (!hit?.code) {
setAddError(t("Supplier code not found"));
return;
}
const canonical = hit.code.trim();
const otherRows = editFloor === "2F" ? draftRows4F : draftRows2F;
if (currentDraftRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) {
setAddError(t("Duplicate in floor"));
return;
}
if (otherRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) {
setAddError(t("Duplicate in other floor"));
return;
}
const name = hit.name?.trim() || "";
setCurrentDraftRows([...currentDraftRows, { code: canonical, name }]);
setAddOpen(false);
setAddCodeInput("");
} finally {
addInFlightRef.current = false;
}
};

if (loading) {
return (
<Stack alignItems="center" py={4}>
<CircularProgress />
</Stack>
);
}

return (
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
{t("Intro")}
</Typography>

{error ? <Alert severity="error">{error}</Alert> : null}
{success ? <Alert severity="success">{success}</Alert> : null}

<Stack spacing={2}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
py: 1.5,
px: 0,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
{t("2F supplier")}
</Typography>
<Typography
component="span"
variant="body1"
sx={{ fontFamily: "monospace", flex: 1, minWidth: 0, wordBreak: "break-all" }}
>
{display2F}
</Typography>
<IconButton
aria-label={t("Edit 2F")}
color="primary"
edge="end"
onClick={() => void openEdit("2F")}
size="small"
>
<EditOutlined />
</IconButton>
</Box>

<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
py: 1.5,
px: 0,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
{t("4F supplier")}
</Typography>
<Typography
component="span"
variant="body1"
sx={{ fontFamily: "monospace", flex: 1, minWidth: 0, wordBreak: "break-all" }}
>
{display4F}
</Typography>
<IconButton
aria-label={t("Edit 4F")}
color="primary"
edge="end"
onClick={() => void openEdit("4F")}
size="small"
>
<EditOutlined />
</IconButton>
</Box>
</Stack>

<Dialog open={editOpen} onClose={closeEdit} fullWidth maxWidth="md">
<DialogTitle>{t("Edit dialog title")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
alignItems={{ xs: "stretch", sm: "center" }}
flexWrap="wrap"
>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="do-floor-edit-floor-label">{t("Floor label")}</InputLabel>
<Select
labelId="do-floor-edit-floor-label"
label={t("Floor label")}
value={editFloor}
onChange={onFloorSelectChange}
disabled={dialogSaving || comboLoading}
>
<MenuItem value="2F">2F</MenuItem>
<MenuItem value="4F">4F</MenuItem>
</Select>
</FormControl>
<Button
variant="outlined"
color="primary"
onClick={() => void saveCurrentFloor()}
disabled={dialogSaving || comboLoading}
sx={{ alignSelf: { xs: "stretch", sm: "center" } }}
>
{dialogSaving ? <CircularProgress size={22} /> : t("Save")}
</Button>
<Box sx={{ flex: 1 }} />
<Button
variant="contained"
color="primary"
startIcon={<Add />}
onClick={openAddMapping}
disabled={dialogSaving || comboLoading || !supplierCombo?.length}
sx={{ alignSelf: { xs: "stretch", sm: "center" } }}
>
{t("Add mapping")}
</Button>
</Stack>

{comboLoading ? (
<Stack alignItems="center" py={4}>
<CircularProgress size={32} />
</Stack>
) : (
<TableContainer sx={{ maxHeight: 360, border: 1, borderColor: "divider", borderRadius: 1 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 700 }}>{t("Col code")}</TableCell>
<TableCell sx={{ fontWeight: 700 }}>{t("Col name")}</TableCell>
<TableCell sx={{ fontWeight: 700, width: 100 }}>{t("Col type")}</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, width: 88 }}>
{t("Col actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{currentDraftRows.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>
<Typography variant="body2" color="text.secondary">
{t("Empty floor list")}
</Typography>
</TableCell>
</TableRow>
) : (
currentDraftRows.map((row) => (
<TableRow key={row.code} hover>
<TableCell sx={{ fontFamily: "monospace" }}>{row.code}</TableCell>
<TableCell>
{row.name || (
<Typography component="span" color="text.secondary">
{t("Unknown supplier name")}
</Typography>
)}
</TableCell>
<TableCell>{editFloor}</TableCell>
<TableCell align="right">
<IconButton
aria-label={t("Delete row")}
color="error"
size="small"
onClick={() => removeRow(row.code)}
disabled={dialogSaving}
>
<DeleteOutline fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={closeEdit} disabled={dialogSaving}>
{t("Cancel")}
</Button>
</DialogActions>
</Dialog>

<Dialog open={addOpen} onClose={closeAdd} fullWidth maxWidth="xs">
<DialogTitle>{t("Add mapping title")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
<TextField
autoFocus
fullWidth
size="small"
label={t("Col code")}
value={addCodeInput}
onChange={(e) => {
setAddCodeInput(e.target.value);
setAddError(null);
}}
placeholder={t("Add code placeholder")}
/>
{addError ? <Alert severity="error">{addError}</Alert> : null}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeAdd}>{t("Cancel")}</Button>
<Button variant="contained" onClick={confirmAddMapping}>
{t("Add confirm")}
</Button>
</DialogActions>
</Dialog>
</Stack>
);
};

export default DeliveryOrderFloorSettings;

+ 216
- 233
src/components/DoSearch/DoSearch.tsx View File

@@ -31,7 +31,7 @@ import {
SubmitHandler,
useForm,
} from "react-hook-form";
import { Box, Button, Paper, Stack, Typography, TablePagination } from "@mui/material";
import { Box, Button, Paper, Stack, Tab, Tabs, TablePagination, Typography } from "@mui/material";
import StyledDataGrid from "../StyledDataGrid";
import { GridRowSelectionModel } from "@mui/x-data-grid";
import Swal from "sweetalert2";
@@ -43,8 +43,10 @@ type Props = {
searchQuery?: Record<string, any>;
onDeliveryOrderSearch?: () => void;
};
type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "floor" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo" | "floorTo", string>;
type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>;
type SearchParamNames = keyof SearchBoxInputs;
type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA";
type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string };

// put all this into a new component
// ConsoDoForm
@@ -83,6 +85,8 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isWorkbench, setIsWorkbench] = useState(false);
const [activeTab, setActiveTab] = useState<DoSearchTab>("2F");
const [searchBoxResetKey, setSearchBoxResetKey] = useState(0);
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
@@ -96,8 +100,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
supplierName: "",
shopName: "",
deliveryOrderLines: "",
truckLanceCode: "", // 添加这个字段
floor: "All",
truckLanceCode: "",
codeTo: "",
statusTo: "",
estimatedArrivalDateTo: "",
@@ -106,8 +109,28 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
shopNameTo: "",
deliveryOrderLinesTo: "",
truckLanceCodeTo: "",
floorTo: "",
});
const createClearedSearchParams = useCallback(
(source: SearchBoxInputs): SearchBoxInputs => ({
code: "",
status: "",
estimatedArrivalDate: source.estimatedArrivalDate || "",
orderDate: "",
supplierName: "",
shopName: "",
deliveryOrderLines: "",
truckLanceCode: "",
codeTo: "",
statusTo: "",
estimatedArrivalDateTo: source.estimatedArrivalDateTo || "",
orderDateTo: "",
supplierNameTo: "",
shopNameTo: "",
deliveryOrderLinesTo: "",
truckLanceCodeTo: "",
}),
[],
);

const [hasSearched, setHasSearched] = useState(false);
const [hasResults, setHasResults] = useState(false);
@@ -155,7 +178,6 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
currentSearchParams.status,
currentSearchParams.estimatedArrivalDate,
currentSearchParams.truckLanceCode,
currentSearchParams.floor,
]);


@@ -164,19 +186,11 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Shop Name"), paramName: "shopName", type: "text" },
{ label: t("Truck Lance Code"), paramName: "truckLanceCode", type: "text" },
{
label: t("Floor"),
paramName: "floor",
type: "select-labelled",
options: [
{ label: "2F", value: "2F" },
{ label: "4F", value: "4F" },
],
},
{
label: t("Estimated Arrival"),
paramName: "estimatedArrivalDate",
type: "date",
preFilledValue: currentSearchParams.estimatedArrivalDate || "",
},
{
label: t("Status"),
@@ -189,7 +203,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
]
}
],
[t],
[t, currentSearchParams.estimatedArrivalDate],
);

const onReset = useCallback(async () => {
@@ -316,67 +330,103 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
[],
);

//SEARCH FUNCTION
const handleSearch = useCallback(async (query: SearchBoxInputs) => {
try {
if (isTruckLaneSearchMissingEta(query.truckLanceCode ?? "", query.estimatedArrivalDate ?? "")) {
await Swal.fire({
icon: "warning",
title: t("Truck lane search requires date title"),
text: t("Truck lane search requires date message"),
confirmButtonText: t("Confirm"),
});
return;
const resolveTabFilter = useCallback((tab: DoSearchTab): TabFilter => {
switch (tab) {
case "2F":
return { floor: "2F", isExtra: false };
case "4F":
return { floor: "4F", isExtra: false };
case "TRUCK_X":
return { floor: null, isExtra: false, forceTruckKeyword: "x" };
case "ETRA":
default:
return { floor: null, isExtra: true };
}
}, []);

setCurrentSearchParams(query);
const performSearch = useCallback(
async (
query: SearchBoxInputs,
pageNum: number,
pageSize: number,
options?: { resetExcludedRows?: boolean; markSearched?: boolean; tabOverride?: DoSearchTab },
) => {
const effectiveTab = options?.tabOverride ?? activeTab;
const tabFilter = resolveTabFilter(effectiveTab);
const tabTruckKeyword = tabFilter.forceTruckKeyword ?? "";
const effectiveTruckLanceCode = tabTruckKeyword || query.truckLanceCode || "";
const shouldValidateTruckLane = effectiveTab !== "TRUCK_X";

let estArrStartDate = query.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = query.estimatedArrivalDate + time;
}
let status = "";
if(query.status == "All"){
status = "";
}
else{
status = query.status;
}
if (
shouldValidateTruckLane &&
isTruckLaneSearchMissingEta(effectiveTruckLanceCode, query.estimatedArrivalDate ?? "")
) {
await Swal.fire({
icon: "warning",
title: t("Truck lane search requires date title"),
text: t("Truck lane search requires date message"),
confirmButtonText: t("Confirm"),
});
return false;
}

const floorParam = query.floor === "All" || !query.floor ? null : query.floor;
// 调用新的 API,传入分页参数和 truckLanceCode
const response = await fetchDoSearch(
query.code || "",
query.shopName || "",
status,
"", // orderStartDate - 不再使用
"", // orderEndDate - 不再使用
estArrStartDate,
"", // estArrEndDate - 不再使用
pagingController.pageNum, // 传入当前页码
pagingController.pageSize, // 传入每页大小
query.truckLanceCode || "",
);

setSearchAllDos(response.records);
setTotalCount(response.total); // 设置总记录数
setHasSearched(true);
setHasResults(response.records.length > 0);
setExcludedRowIds([]);

} catch (error) {
console.error("Error: ", error);
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(true);
setHasResults(false);
setExcludedRowIds([]);
}
}, [pagingController, t]);
let estArrStartDate = query.estimatedArrivalDate;
const time = "T00:00:00";
if (estArrStartDate !== "") {
estArrStartDate = `${query.estimatedArrivalDate}${time}`;
}

const status = query.status === "All" ? "" : query.status;

const response = await fetchDoSearch(
query.code || "",
query.shopName || "",
status,
"",
"",
estArrStartDate,
"",
pageNum,
pageSize,
effectiveTruckLanceCode,
tabFilter.floor,
tabFilter.isExtra,
);

setSearchAllDos(response.records);
setTotalCount(response.total);
if (options?.markSearched ?? false) {
setHasSearched(true);
setHasResults(response.records.length > 0);
}
if (options?.resetExcludedRows ?? false) {
setExcludedRowIds([]);
}
return true;
},
[activeTab, resolveTabFilter, t],
);

//SEARCH FUNCTION
const handleSearch = useCallback(
async (query: SearchBoxInputs) => {
try {
setCurrentSearchParams(query);
await performSearch(query, pagingController.pageNum, pagingController.pageSize, {
resetExcludedRows: true,
markSearched: true,
});
} catch (error) {
console.error("Error: ", error);
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(true);
setHasResults(false);
setExcludedRowIds([]);
}
},
[pagingController.pageNum, pagingController.pageSize, performSearch],
);

useEffect(() => {
if (typeof window !== 'undefined') {
@@ -425,147 +475,53 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
}, [handleSearch, searchTimeout]);

// 分页变化时重新搜索
const handlePageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1,
};
setPagingController(newPagingController);
// 如果已经搜索过,重新搜索
if (hasSearched && currentSearchParams) {
// 使用新的分页参数重新搜索
const searchWithNewPage = async () => {
try {
if (
isTruckLaneSearchMissingEta(
currentSearchParams.truckLanceCode ?? "",
currentSearchParams.estimatedArrivalDate ?? "",
)
) {
await Swal.fire({
icon: "warning",
title: t("Truck lane search requires date title"),
text: t("Truck lane search requires date message"),
confirmButtonText: t("Confirm"),
});
return;
}
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}

const floorParam =
currentSearchParams.floor === "All" || !currentSearchParams.floor
? null
: currentSearchParams.floor;
const response = await fetchDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
"",
"",
estArrStartDate,
"",
newPagingController.pageNum,
newPagingController.pageSize,
currentSearchParams.truckLanceCode || "",
);
setSearchAllDos(response.records);
setTotalCount(response.total);
} catch (error) {
console.error("Error: ", error);
}
const handlePageChange = useCallback(
(event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1,
};
searchWithNewPage();
}
}, [pagingController, hasSearched, currentSearchParams, t]);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1, // 改变每页大小时重置到第一页
pageSize: newPageSize,
};
setPagingController(newPagingController);
// 如果已经搜索过,重新搜索
if (hasSearched && currentSearchParams) {
const searchWithNewPageSize = async () => {
try {
if (
isTruckLaneSearchMissingEta(
currentSearchParams.truckLanceCode ?? "",
currentSearchParams.estimatedArrivalDate ?? "",
)
) {
await Swal.fire({
icon: "warning",
title: t("Truck lane search requires date title"),
text: t("Truck lane search requires date message"),
confirmButtonText: t("Confirm"),
});
return;
}
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}

const floorParam =
currentSearchParams.floor === "All" || !currentSearchParams.floor
? null
: currentSearchParams.floor;
const response = await fetchDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
"",
"",
estArrStartDate,
"",
1, // 重置到第一页
newPageSize,
currentSearchParams.truckLanceCode || "",
);
setSearchAllDos(response.records);
setTotalCount(response.total);
} catch (error) {
setPagingController(newPagingController);
if (hasSearched && currentSearchParams) {
void performSearch(
currentSearchParams,
newPagingController.pageNum,
newPagingController.pageSize,
).catch((error) => {
console.error("Error: ", error);
}
});
}
},
[pagingController, hasSearched, currentSearchParams, performSearch],
);

const handlePageSizeChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1,
pageSize: newPageSize,
};
searchWithNewPageSize();
}
}, [hasSearched, currentSearchParams, t]);
setPagingController(newPagingController);
if (hasSearched && currentSearchParams) {
void performSearch(currentSearchParams, 1, newPageSize).catch((error) => {
console.error("Error: ", error);
});
}
},
[hasSearched, currentSearchParams, performSearch],
);

const handleBatchRelease = useCallback(async (isWorkbench: boolean) => {
try {
const tabFilter = resolveTabFilter(activeTab);
const tabTruckKeyword = tabFilter.forceTruckKeyword ?? "";
const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || "";
const shouldValidateTruckLane = activeTab !== "TRUCK_X";
if (
shouldValidateTruckLane &&
isTruckLaneSearchMissingEta(
currentSearchParams.truckLanceCode ?? "",
effectiveTruckLanceCode,
currentSearchParams.estimatedArrivalDate ?? "",
)
) {
@@ -593,11 +549,6 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
status = currentSearchParams.status;
}

const floorParam =
currentSearchParams.floor === "All" || !currentSearchParams.floor
? null
: currentSearchParams.floor;
// 显示加载提示
const loadingSwal = Swal.fire({
title: t("Loading"),
@@ -616,7 +567,9 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
currentSearchParams.shopName || "",
status,
estArrStartDate,
currentSearchParams.truckLanceCode || "",
effectiveTruckLanceCode,
tabFilter.floor,
tabFilter.isExtra,
);
Swal.close();
@@ -752,7 +705,29 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
confirmButtonText: t("OK")
});
}
}, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]);
}, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet, activeTab, resolveTabFilter]);

const handleTabChange = useCallback(
(_: React.SyntheticEvent, nextTab: DoSearchTab) => {
if (nextTab === activeTab) return;
const nextSearchParams = createClearedSearchParams(currentSearchParams);
setActiveTab(nextTab);
setCurrentSearchParams(nextSearchParams);
setSearchBoxResetKey((prev) => prev + 1);
setPagingController((prev) => ({ ...prev, pageNum: 1 }));
setExcludedRowIds([]);
// 切換 tab 僅重置搜尋條件與結果;由使用者再次按「搜尋」後才查詢。
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(false);
setHasResults(false);
},
[
activeTab,
currentSearchParams,
createClearedSearchParams,
],
);

return (
<>
@@ -762,28 +737,36 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
component="form"
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
{hasSearched && hasResults && (
<Stack direction="row" justifyContent="flex-end" spacing={2}sx={{ mb: 1 }}>
<Button
name="batch_release"
variant="contained"
onClick={() => handleBatchRelease(true)}
>
{t("Workbench Batch Release")}
</Button>
{/*
<Button
name="batch_release"
variant="contained"
onClick={() => handleBatchRelease(false)}
>
{t("Batch Release")}
</Button>
*/}
</Stack>
)}

<Paper variant="outlined" sx={{ px: 2, pt: 1 }}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="scrollable"
>
<Tab value="2F" label="2/F" />
<Tab value="4F" label="4/F" />
<Tab value="TRUCK_X" label={t("Truck X")} />
<Tab value="ETRA" label={t("Etra")} />
</Tabs>

{hasSearched && hasResults && (
<Button
name="batch_release"
variant="contained"
onClick={() => handleBatchRelease(true)}
>
{t("Workbench Batch Release")}
</Button>
)}
</Box>
</Paper>


<SearchBox
key={`tab-reset-${searchBoxResetKey}`}
criteria={searchCriteria}
onSearch={handleSearch}
onReset={onReset}


+ 24
- 2
src/components/DoWorkbench/DoWorkbenchPickShell.tsx View File

@@ -2,6 +2,7 @@

import { Box, CircularProgress } from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import {
@@ -10,15 +11,25 @@ import {
import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel";
import WorkbenchGoodPickExecutionDetail from "./WorkbenchGoodPickExecutionDetail";

export type DoWorkbenchPickShellLaneMode = "normal" | "etra";

type DoWorkbenchPickShellProps = {
/** Tab 0: normal 2F/4F lane grid; tab 1: Etra-only lane grid */
laneMode?: DoWorkbenchPickShellLaneMode;
};

/**
* FG workbench: 未指派顯示樓層/車線指派;已指派顯示揀貨明細(workbench API)。
*/
const DoWorkbenchPickShell: React.FC = () => {
const DoWorkbenchPickShell: React.FC<DoWorkbenchPickShellProps> = ({ laneMode = "normal" }) => {
const { data: session, status } = useSession() as {
data: SessionWithTokens | null;
status: "loading" | "authenticated" | "unauthenticated";
};
const currentUserId = session?.id ? parseInt(session.id, 10) : undefined;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [showDetail, setShowDetail] = useState(false);
const [viewLoading, setViewLoading] = useState(true);
const filterArgs = useMemo(() => ({}), []);
@@ -65,6 +76,13 @@ const DoWorkbenchPickShell: React.FC = () => {
void refreshWorkbenchView();
}, [refreshWorkbenchView]);

const goNormalAssignTab = useCallback(() => {
const p = new URLSearchParams(searchParams.toString());
p.set("tab", "0");
const qs = p.toString();
router.replace(qs ? `${pathname}?${qs}` : `${pathname}?tab=0`, { scroll: false });
}, [pathname, router, searchParams]);

if (status === "loading") {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 3, width: "100%" }}>
@@ -82,7 +100,11 @@ const DoWorkbenchPickShell: React.FC = () => {
<CircularProgress />
</Box>
) : !showDetail ? (
<WorkbenchFloorLanePanel onPickOrderAssigned={() => void refreshWorkbenchView()} />
<WorkbenchFloorLanePanel
onPickOrderAssigned={() => void refreshWorkbenchView()}
etraOnly={laneMode === "etra"}
onRequestNormalLaneTab={laneMode === "etra" ? goNormalAssignTab : undefined}
/>
) : (
<WorkbenchGoodPickExecutionDetail
filterArgs={filterArgs}


+ 142
- 24
src/components/DoWorkbench/DoWorkbenchTabs.tsx View File

@@ -1,6 +1,17 @@
"use client";

import { Autocomplete, Box, CircularProgress, Tab, Tabs, TextField, Typography } from "@mui/material";
import {
Autocomplete,
Badge,
Box,
Button,
CircularProgress,
Tab,
Tabs,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import React, { Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import DoWorkbenchPickShell from "./DoWorkbenchPickShell";
@@ -11,12 +22,26 @@ import WorkbenchTicketReleaseTableTab from "./WorkbenchTicketReleaseTable";
import { Stack } from "@mui/system";
import Swal from "sweetalert2";
import { printDNWorkbench } from "@/app/api/do/actions";
import { fetchWorkbenchReleasedDoPickOrdersForSelectionToday } from "@/app/api/doworkbench/actions";
import { Button } from "@mui/material";
import {
fetchWorkbenchEtraLaneSummary,
fetchWorkbenchReleasedDoPickOrdersForSelectionToday,
type WorkbenchEtraShopLaneGroup,
} from "@/app/api/doworkbench/actions";
import FinishedGoodCartonDashboardTab from "../FinishedGoodSearch/FinishedGoodCartonDashboardTab";
import TruckRoutingSummaryTabWorkbench from "./TruckRoutingSummaryTabWorkbench";

const ALLOWED_WORKBENCH_TABS = new Set([0, 1, 2, 3, 5, 6]);
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. */
function sumIncompleteEtraDopoTickets(groups: WorkbenchEtraShopLaneGroup[]): number {
let n = 0;
for (const g of groups) {
for (const lane of g.lanes) {
n += Number(lane.total) || 0;
}
}
return n;
}

type Props = {
defaultTabIndex?: 0 | 1;
@@ -45,6 +70,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
const [a4Printer, setA4Printer] = React.useState<PrinterCombo | null>(null);
const [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(null);
const [releasedOrderCount, setReleasedOrderCount] = React.useState(0);
const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0);
const { t } = useTranslation( );
const a4Printers = React.useMemo(
() => (printerCombo || []).filter((printer) => printer.type === "A4"),
@@ -55,19 +81,46 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
[printerCombo],
);

const fetchReleasedOrderCount = React.useCallback(async () => {
try {
const releasedOrders = await fetchWorkbenchReleasedDoPickOrdersForSelectionToday();
setReleasedOrderCount(releasedOrders.length);
} catch (error) {
console.error("Error fetching workbench released order count:", error);
const refreshWorkbenchCounts = React.useCallback(async () => {
const [releasedRes, etraRes] = await Promise.allSettled([
fetchWorkbenchReleasedDoPickOrdersForSelectionToday(),
fetchWorkbenchEtraLaneSummary(),
]);
if (releasedRes.status === "fulfilled") {
setReleasedOrderCount(releasedRes.value.length);
} else {
console.error("Error fetching workbench released order count:", releasedRes.reason);
setReleasedOrderCount(0);
}
if (etraRes.status === "fulfilled") {
setEtraIncompleteDopoCount(sumIncompleteEtraDopoTickets(etraRes.value));
} else {
console.error("Error fetching workbench Etra incomplete count:", etraRes.reason);
setEtraIncompleteDopoCount(0);
}
}, []);

React.useEffect(() => {
void fetchReleasedOrderCount();
}, [fetchReleasedOrderCount]);
void refreshWorkbenchCounts();
}, [refreshWorkbenchCounts]);

React.useEffect(() => {
const onAssigned = () => {
void refreshWorkbenchCounts();
};
window.addEventListener("pickOrderAssigned", onAssigned);
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) {
etraTabMountSkipRef.current = true;
return;
}
if (tab === 1) void refreshWorkbenchCounts();
}, [tab, refreshWorkbenchCounts]);

React.useEffect(() => {
if (urlTabStr == null || urlTabStr === "") return;
@@ -82,7 +135,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
setTab(newTab);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", String(newTab));
if (newTab !== 1) {
/* ticketNo deep-link only for "Finished Good Record" (mine) */
if (newTab !== 2) {
params.delete("ticketNo");
}
const qs = params.toString();
@@ -156,7 +210,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
showConfirmButton: false,
timer: 1500,
});
await fetchReleasedOrderCount();
await refreshWorkbenchCounts();
} catch (error) {
Swal.close();
console.error("Error in workbench handleAllDraft:", error);
@@ -165,7 +219,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
text: t("An error occurred during batch print"),
});
}
}, [a4Printer, t, fetchReleasedOrderCount]);
}, [a4Printer, t, refreshWorkbenchCounts]);

return (
<Box>
@@ -223,19 +277,85 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
{`${t("Print All Draft")} (${releasedOrderCount})`}
</Button>
</Stack>
<Tabs value={tab} onChange={handleTabChange} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={tab}
onChange={handleTabChange}
sx={{
borderBottom: 1,
borderColor: "divider",
"& .MuiTabs-flexContainer": {
columnGap: 2,
rowGap: 1,
},
/* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */
"& .MuiTab-root": {
overflow: "visible",
minWidth: "auto",
px: 2,
},
}}
>
<Tab label={t("Pick Order Detail")} value={0} />
<Tab label={t("Finished Good Record")} value={1} />
<Tab label={t("Finished Good Record (All)")} value={2} />
<Tab label={t("Ticket Release Table")} value={3} />
<Tab
value={1}
sx={{
overflow: "visible",
/* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */
pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2,
}}
label={
<Tooltip
title={
etraIncompleteDopoCount > 0
? t("Etra incomplete badge tooltip", { count: etraIncompleteDopoCount })
: t("Etra incomplete badge tooltip none")
}
>
<Box component="span" sx={{ display: "inline-flex", alignItems: "center" }}>
<Badge
color="error"
variant="standard"
badgeContent={etraIncompleteDopoCount > 99 ? "99+" : etraIncompleteDopoCount}
invisible={etraIncompleteDopoCount === 0}
sx={{
"& .MuiBadge-badge": {
fontWeight: 800,
fontSize: "0.7rem",
minWidth: 18,
height: 18,
lineHeight: "18px",
px: 0.5,
right: -8,
top: 2,
},
}}
>
<Typography
component="span"
variant="inherit"
sx={{ pr: etraIncompleteDopoCount > 0 ? 1 : 0 }}
>
{t("Etra Pick Order Detail")}
</Typography>
</Badge>
</Box>
</Tooltip>
}
/>
<Tab label={t("Finished Good Record")} value={2} />
<Tab label={t("Finished Good Record (All)")} value={3} />
<Tab label={t("Ticket Release Table")} value={4} />
<Tab label={t("成品出倉出箱數量")} value={5} />
<Tab label={t("送貨路線摘要")} value={6} />
</Tabs>

<TabPanel value={tab} index={0}>
<DoWorkbenchPickShell />
<DoWorkbenchPickShell laneMode="normal" />
</TabPanel>
<TabPanel value={tab} index={1}>
<DoWorkbenchPickShell laneMode="etra" />
</TabPanel>
<TabPanel value={tab} index={2}>
<GoodPickExecutionWorkbenchRecord
key={`workbench-record-mine-${urlTicketNo ?? ""}`}
printerCombo={printerCombo}
@@ -245,17 +365,15 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
initialTicketNo={urlTicketNo}
/>
</TabPanel>
<TabPanel value={tab} index={2}>
<TabPanel value={tab} index={3}>
<GoodPickExecutionWorkbenchRecord
//key={`workbench-record-all-${urlTicketNo ?? ""}`}
printerCombo={printerCombo}
listScope="all"
a4Printer={a4Printer}
labelPrinter={labelPrinter}
//initialTicketNo={urlTicketNo}
/>
</TabPanel>
<TabPanel value={tab} index={3}>
<TabPanel value={tab} index={4}>
<WorkbenchTicketReleaseTableTab />
</TabPanel>
<TabPanel value={tab} index={5}>


+ 24
- 2
src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx View File

@@ -633,7 +633,29 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6">{row.deliveryNoteCode || "-"}</Typography>
<Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap">
<Typography variant="h6">{row.deliveryNoteCode || "-"}</Typography>
{(row.ticketNo ?? "").trim().toUpperCase().startsWith("TI-E-") && (
<Chip
label={t("Etra")}
color="secondary"
size="small"
sx={{
"& .MuiChip-label": { fontSize: (theme) => theme.typography.h6.fontSize, fontWeight: 600 },
height: 30,
}}
/>
)}
<Chip
label={t("completed")}
color="success"
size="small"
sx={{
"& .MuiChip-label": { fontSize: (theme) => theme.typography.h6.fontSize, fontWeight: 600 },
height: 30,
}}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
{row.ticketNo || "-"}
</Typography>
@@ -648,7 +670,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
{t("Ticket No.")}: {row.ticketNo || "-"}
</Typography>
</Box>
<Chip label={t("completed")} color="success" size="small" />
{/*<Chip label={t("completed")} color="success" size="small" />*/}
</Stack>
</CardContent>
<CardActions>


+ 238
- 31
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx View File

@@ -9,9 +9,11 @@ import type { StoreLaneSummary, LaneRow, LaneBtn } from "@/app/api/pickOrder/act
import {
assignByDeliveryOrderPickOrderId,
assignWorkbenchByLane,
fetchWorkbenchEtraLaneSummary,
fetchWorkbenchReleasedDoPickOrdersForSelection,
fetchWorkbenchReleasedDoPickOrdersForSelectionToday,
fetchWorkbenchStoreLaneSummary,
type WorkbenchEtraShopLaneGroup,
} from "@/app/api/doworkbench/actions";
import Swal from "sweetalert2";
import dayjs from "dayjs";
@@ -21,12 +23,22 @@ interface Props {
onPickOrderAssigned?: () => void;
onSwitchToDetailTab?: () => void;
initialReleaseType?: string;
/** When true (workbench tab "Etra"), only the Etra shop×lane grid is shown; use top tab to return to normal. */
etraOnly?: boolean;
/** With [etraOnly], navigates to normal assign tab (tab 0). */
onRequestNormalLaneTab?: () => void;
}

type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn };
type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] };

const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitchToDetailTab, initialReleaseType = "batch" }) => {
const WorkbenchFloorLanePanel: React.FC<Props> = ({
onPickOrderAssigned,
onSwitchToDetailTab,
initialReleaseType = "batch",
etraOnly = false,
onRequestNormalLaneTab,
}) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
@@ -45,7 +57,16 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
const [selectedDate, setSelectedDate] = useState<string>("today");
const [releaseType, setReleaseType] = useState<string>(initialReleaseType);
const [ticketFloor, setTicketFloor] = useState<"2/F" | "4/F">("2/F");
const [isExtraView, setisExtraView] = useState(false);
const [etraGroups, setEtraGroups] = useState<WorkbenchEtraShopLaneGroup[]>([]);
const [isLoadingEtra, setIsLoadingEtra] = useState(false);
const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState<string | undefined>(undefined);
const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState<string | undefined>(undefined);
const [modalInitialShopSearch, setModalInitialShopSearch] = useState<string | undefined>(undefined);
const defaultTruckCount = summary4F?.defaultTruckCount ?? 0;
const etraEnterInFlightRef = useRef(false);

const inEtraUi = useMemo(() => etraOnly || isExtraView, [etraOnly, isExtraView]);

const selectedDeliveryDateYmd = useMemo(() => {
if (selectedDate === "today") return dayjs().format("YYYY-MM-DD");
@@ -60,14 +81,20 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc

const workbenchReleasedListBridge = useMemo(
() => ({
loadBeforeToday: fetchWorkbenchReleasedDoPickOrdersForSelection,
loadBeforeToday: (
shopName?: string,
storeId?: string,
truck?: string,
releaseType?: string
) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType),
loadToday: (
shopName?: string,
storeId?: string,
truck?: string,
requiredDeliveryDate?: string
requiredDeliveryDate?: string,
releaseType?: string
) =>
fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate),
fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType),
assignByListItemId: assignByDeliveryOrderPickOrderId,
}),
[],
@@ -118,12 +145,35 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
}
}, [selectedDate, releaseType]);

const loadEtraSummaries = useCallback(async () => {
setIsLoadingEtra(true);
pendingRef.current += 1;
startFullTimer();
try {
let dateParam: string | undefined;
if (selectedDate === "today") dateParam = dayjs().format("YYYY-MM-DD");
else if (selectedDate === "tomorrow") dateParam = dayjs().add(1, "day").format("YYYY-MM-DD");
else if (selectedDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD");
const data = await fetchWorkbenchEtraLaneSummary(dateParam);
setEtraGroups(data);
} catch (error) {
console.error("Error loading Etra summary:", error);
setEtraGroups([]);
} finally {
setIsLoadingEtra(false);
pendingRef.current -= 1;
tryEndFullTimer();
}
}, [selectedDate]);

useEffect(() => {
void loadSummaries();
}, [loadSummaries]);
if (inEtraUi) void loadEtraSummaries();
else void loadSummaries();
}, [inEtraUi, loadEtraSummaries, loadSummaries]);

useEffect(() => {
const loadCounts = async () => {
if (inEtraUi) return;
pendingRef.current += 1;
startFullTimer();
try {
@@ -153,10 +203,11 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
}
};
void loadCounts();
}, [loadSummaries]);
}, [inEtraUi, loadSummaries]);

useEffect(() => {
const loadBeforeTodayTruckX = async () => {
if (inEtraUi) return;
pendingRef.current += 1;
startFullTimer();
try {
@@ -170,6 +221,35 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
}
};
void loadBeforeTodayTruckX();
}, [inEtraUi]);

const clearModalEtraContext = useCallback(() => {
setModalReleaseTypeFilter(undefined);
setModalFilterRequiredDeliveryDate(undefined);
setModalInitialShopSearch(undefined);
}, []);

const openEnterEtraView = useCallback(async () => {
if (etraEnterInFlightRef.current) return;
etraEnterInFlightRef.current = true;
try {
/*
const r = await Swal.fire({
title: t("Enter isExtra workbench view?"),
text: t("Etra view groups all add-on tickets by shop and lane for the selected date."),
icon: "question",
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
});
if (r.isConfirmed) setisExtraView(true);
*/
setisExtraView(true);
} finally {
etraEnterInFlightRef.current = false;
}
}, []);

const handleAssignByLane = useCallback(
@@ -198,7 +278,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
console.log("assignByLane result:", res);
if (res.code === "SUCCESS") {
window.dispatchEvent(new CustomEvent("pickOrderAssigned"));
void loadSummaries();
void (inEtraUi ? loadEtraSummaries() : loadSummaries());
onPickOrderAssigned?.();
onSwitchToDetailTab?.();
}
@@ -208,7 +288,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
setIsAssigning(false);
}
},
[currentUserId, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t],
[currentUserId, inEtraUi, loadEtraSummaries, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t],
);

const handleLaneButtonClick = useCallback(
@@ -280,7 +360,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc

return (
<Box sx={{ mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "flex-start" }}>
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "flex-start", flexWrap: "wrap" }}>
<Box sx={{ maxWidth: 300 }}>
<FormControl fullWidth size="small">
<InputLabel id="date-select-label">{t("Select Date")}</InputLabel>
@@ -291,26 +371,60 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
</Select>
</FormControl>
</Box>
<Box sx={{ minWidth: 140, maxWidth: 300 }}>
<FormControl fullWidth size="small">
<InputLabel id="release-type-select-label">{t("Release Type")}</InputLabel>
<Select labelId="release-type-select-label" value={releaseType} label={t("Release Type")} onChange={(e) => setReleaseType(e.target.value)}>
<MenuItem value="batch">{t("Batch")}</MenuItem>
<MenuItem value="single">{t("Single")}</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ minWidth: 120, maxWidth: 200 }}>
<FormControl fullWidth size="small">
<InputLabel id="ticket-floor-select-label">{t("Floor ticket")}</InputLabel>
<Select labelId="ticket-floor-select-label" value={ticketFloor} label={t("Floor ticket")} onChange={(e) => setTicketFloor(e.target.value as "2/F" | "4/F")}>
<MenuItem value="2/F">{t("2F ticket")}</MenuItem>
<MenuItem value="4/F">{t("4F ticket")}</MenuItem>
</Select>
</FormControl>
</Box>
{!inEtraUi && (
<>
<Box sx={{ minWidth: 140, maxWidth: 300 }}>
<FormControl fullWidth size="small">
<InputLabel id="release-type-select-label">{t("Release Type")}</InputLabel>
<Select labelId="release-type-select-label" value={releaseType} label={t("Release Type")} onChange={(e) => setReleaseType(e.target.value)}>
<MenuItem value="batch">{t("Batch")}</MenuItem>
<MenuItem value="single">{t("Single")}</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ minWidth: 120, maxWidth: 200 }}>
<FormControl fullWidth size="small">
<InputLabel id="ticket-floor-select-label">{t("Floor ticket")}</InputLabel>
<Select labelId="ticket-floor-select-label" value={ticketFloor} label={t("Floor ticket")} onChange={(e) => setTicketFloor(e.target.value as "2/F" | "4/F")}>
<MenuItem value="2/F">{t("2F ticket")}</MenuItem>
<MenuItem value="4/F">{t("4F ticket")}</MenuItem>
</Select>
</FormControl>
</Box>
{/*
<Box sx={{ display: "flex", alignItems: "center", pt: 0.5 }}>
<Button variant="contained" color="secondary" onClick={() => void openEnterEtraView()}>
{t("isExtra order")}
</Button>
</Box>
*/}
</>
)}
{/*
{inEtraUi && !etraOnly && (
<Box sx={{ display: "flex", alignItems: "center", pt: 0.5 }}>
<Button
variant="outlined"
onClick={() => {
clearModalEtraContext();
setisExtraView(false);
}}
>
{t("Exit Etra view")}
</Button>
</Box>
)}
{etraOnly && onRequestNormalLaneTab && (
<Box sx={{ display: "flex", alignItems: "center", pt: 0.5 }}>
<Button variant="outlined" onClick={() => onRequestNormalLaneTab()}>
{t("Back to normal assign tab")}
</Button>
</Box>
)}
*/}
</Stack>

{!inEtraUi ? (
<Grid container spacing={2}>
{ticketFloor === "2/F" && (
<Grid item xs={12}>
@@ -388,6 +502,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
<Button
variant="outlined"
onClick={() => {
clearModalEtraContext();
setSelectedStore("");
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
@@ -426,6 +541,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
<Button
variant="outlined"
onClick={() => {
clearModalEtraContext();
setIsDefaultTruck(false);
setSelectedStore("2/F");
setSelectedTruck(truck);
@@ -456,6 +572,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
<Button
variant="outlined"
onClick={() => {
clearModalEtraContext();
setIsDefaultTruck(false);
setSelectedStore("4/F");
setSelectedTruck(truck);
@@ -489,6 +606,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
<Button
variant="outlined"
onClick={() => {
clearModalEtraContext();
setSelectedStore("4/F");
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
@@ -502,6 +620,89 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
</Box>
</Stack>
</Grid>
</Grid>
) : (
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 2, backgroundColor: "#fafafa" }}>
{isLoadingEtra ? (
<Typography variant="caption">{t("Loading...")}</Typography>
) : etraGroups.length === 0 ? (
renderNoEntry()
) : (
<Grid container spacing={2}>
{etraGroups.flatMap((group) =>
group.lanes.map((lane, li) => {
const sid = (lane.storeId ?? "").trim();
const dep = (lane.truckDepartureTime ?? "").trim();
const is4F = sid.replace(/\//g, "").toUpperCase() === "4F";
const labelCore =
is4F && lane.loadingSequence != null
? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})`
: `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`;
const handlerName = (lane.handlerName ?? "").trim();
const shopCode = (group.shopCode ?? "").trim();
const shopName = (group.shopName ?? "").trim();
const shopPrimary =
shopCode && shopName && shopCode !== shopName
? `${shopCode} · ${shopName}`
: shopName || shopCode || t("Shop");
const laneSecondary = `${labelCore}${handlerName ? ` · ${handlerName}` : ""}`;
const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${li}`;
return (
<Grid item xs={12} sm={6} md={4} lg={3} key={tileKey}>
<Button
fullWidth
variant="outlined"
disabled={lane.unassigned === 0 || !sid}
onClick={() => {
setModalReleaseTypeFilter("isExtra");
setModalFilterRequiredDeliveryDate(selectedDeliveryDateYmd);
setModalInitialShopSearch((group.shopName || group.shopCode || "").trim() || undefined);
setSelectedStore(sid);
setSelectedTruck(lane.truckLanceCode);
setIsDefaultTruck(false);
setDefaultDateScope("today");
setModalOpen(true);
}}
sx={{
height: "100%",
minHeight: 72,
py: 1.25,
px: 1.5,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
textAlign: "left",
textTransform: "none",
whiteSpace: "normal",
borderColor: "secondary.main",
}}
>
<Typography
variant="subtitle2"
component="span"
color="secondary"
sx={{ fontWeight: 700, lineHeight: 1.35, wordBreak: "break-word" }}
>
{shopPrimary}
</Typography>
<Typography
variant="caption"
component="span"
color="text.secondary"
sx={{ mt: 0.5, lineHeight: 1.35, wordBreak: "break-word" }}
>
{laneSecondary}
</Typography>
</Button>
</Grid>
);
}),
)}
</Grid>
)}
</Box>
)}

<ReleasedDoPickOrderSelectModal
open={modalOpen}
@@ -511,14 +712,20 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc
defaultDateScope={defaultDateScope}
defaultTruckRequiredDeliveryDate={selectedDeliveryDateYmd}
listBridge={workbenchReleasedListBridge}
onClose={() => setModalOpen(false)}
releaseTypeFilter={modalReleaseTypeFilter}
filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate}
initialShopSearch={modalInitialShopSearch}
onClose={() => {
setModalOpen(false);
clearModalEtraContext();
}}
onAssigned={() => {
void loadSummaries();
if (inEtraUi) void loadEtraSummaries();
else void loadSummaries();
onPickOrderAssigned?.();
onSwitchToDetailTab?.();
}}
/>
</Grid>
</Box>
);
};


+ 67
- 116
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx View File

@@ -534,6 +534,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]);

const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false);
const isExtraTicket = useMemo(() => {
const ticketNo = String(fgPickOrders?.[0]?.ticketNo ?? "").trim().toUpperCase();
return ticketNo.startsWith("TI-E-");
}, [fgPickOrders]);

const lotFloorPrefixFilter = useMemo(() => {
const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
@@ -605,21 +609,23 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
// TODO: Implement QR code functionality
};
const progress = useMemo(() => {
if (combinedLotData.length === 0) {
// 只给进度条统计可正常拣货的 lot(排除 unavailable / expired)
const progressLots = combinedLotData.filter(
(lot) => !isLotAvailabilityExpired(lot) && !isInventoryLotLineUnavailable(lot)
);
if (progressLots.length === 0) {
return { completed: 0, total: 0 };
}

// 與 allItemsReady 一致:noLot / 過期 / unavailable 的 pending 也算「已面對該行」可收尾
const nonPendingCount = combinedLotData.filter((lot) => {
const status = lot.stockOutLineStatus?.toLowerCase();
if (status !== "pending") return true;
if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) return true;
return false;
const completedCount = progressLots.filter((lot) => {
const status = String(lot.stockOutLineStatus || "").toLowerCase();
return status !== "pending";
}).length;
return {
completed: nonPendingCount,
total: combinedLotData.length,
completed: completedCount,
total: progressLots.length,
};
}, [combinedLotData]);

@@ -744,7 +750,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
) {
workbenchFinishNavigateDoneRef.current = true;
router.replace(
`${pathname}?tab=1&ticketNo=${encodeURIComponent(ticketForRedirect)}`,
`${pathname}?tab=2&ticketNo=${encodeURIComponent(ticketForRedirect)}`,
{ scroll: false },
);
}
@@ -832,8 +838,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
pickOrderLinesForDisplay.forEach((line: any) => {
// 用来记录这一行已经通过 lots 出现过的 lotId
const lotIdSet = new Set<number>();
/** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */
let lotsAllocatedSumForLine = 0;

// ✅ lots:按 lotId 去重并合并 requiredQty
if (line.lots && line.lots.length > 0) {
@@ -851,7 +855,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
});
lotMap.forEach((lot: any) => {
lotsAllocatedSumForLine += Number(lot.requiredQty) || 0;
if (lot.id != null) {
lotIdSet.add(lot.id);
}
@@ -915,6 +918,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
return;
}
const stockoutRequiredQty = Number(
stockout?.requiredQty ?? stockout?.suggestedPickLotQty,
);
const effectiveStockoutRequiredQty = Number.isFinite(stockoutRequiredQty)
? stockoutRequiredQty
: Number(line.requiredQty) || 0;
const fallbackRouteFromLine =
line?.lots?.[0]?.router?.route ?? line?.lots?.[0]?.location ?? null;

// 只渲染:
// - noLot === true 的 Null stock 行
// - 或者 lotId 在 lots 中不存在的特殊情况
@@ -942,13 +954,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
location: stockout.location || null,
stockUnit: line.item.uomDesc,
availableQty: stockout.availableQty || 0,
// 無批次列對應 suggested_pick_lot 的缺口量(如 11),勿用整行 POL 需求(100)以免顯示成 89 / 100
requiredQty: stockout.noLot
? Math.max(
0,
(Number(line.requiredQty) || 0) - lotsAllocatedSumForLine
)
: Number(line.requiredQty) || 0,
// Workbench stockout row required qty should come from backend stockout payload
// (linked by stockOutLineId to suggested_pick_lot), not inferred from line gap.
requiredQty: effectiveStockoutRequiredQty,
actualPickQty: stockout.qty || 0,
inQty: 0,
outQty: 0,
@@ -956,7 +964,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
lotStatus: stockout.noLot ? "unavailable" : "available",
lotAvailability: stockout.noLot ? "insufficient_stock" : "available",
processingStatus: stockout.status || "pending",
suggestedPickLotId: null,
suggestedPickLotId: stockout.suggestedPickLotId ?? null,
stockOutLineId: stockout.id || null,
stockOutLineStatus: stockout.status || null,
stockOutLineQty: stockout.qty || 0,
@@ -964,8 +972,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
routerId: null,
routerIndex: stockout.noLot ? 999999 : null,
routerRoute: null,
routerArea: null,
routerRoute: fallbackRouteFromLine,
routerArea: fallbackRouteFromLine,
noLot: !!stockout.noLot,
});
});
@@ -1166,7 +1174,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const event = new CustomEvent('pickOrderCompletionStatus', {
detail: {
allLotsCompleted,
tabIndex: 1 // 明确指定这是来自标签页 1 的事件
tabIndex: 2 // DO workbench「Finished Good Record (mine)」分頁索引
}
});
window.dispatchEvent(event);
@@ -2537,59 +2545,7 @@ useEffect(() => {
console.log("Pick execution form opened for lot ID:", lot.lotId);
}, []);

const handlePickExecutionFormSubmit = useCallback(async (data: any) => {
try {
console.log("Pick execution form submitted:", data);
const issueData = {
...data,
type: "Do", // Delivery Order Record 类型
pickerName: session?.user?.name || '',
};
const result = await recordPickExecutionIssue(issueData);
console.log("Pick execution issue recorded:", result);
if (result && result.code === "SUCCESS") {
console.log(" Pick execution issue recorded successfully");
// 关键:issue form 只记录问题,不会更新 SOL.qty
// 但 batch submit 需要知道“实际拣到多少”,否则会按 requiredQty 补拣到满
const solId = Number(issueData.stockOutLineId || issueData.stockOutLineId === 0 ? issueData.stockOutLineId : data?.stockOutLineId);
if (solId > 0) {
const picked = Number(issueData.actualPickQty || 0);
setIssuePickedQtyBySolId((prev) => {
const next = { ...prev, [solId]: picked };
const doId = fgPickOrders[0]?.doPickOrderId;
if (doId) saveIssuePickedMap(doId, next);
return next;
});
setCombinedLotData(prev => prev.map(lot => {
if (Number(lot.stockOutLineId) === solId) {
return { ...lot, actualPickQty: picked, stockOutLineQty: picked };
}
return lot;
}));
}
} else {
console.error(" Failed to record pick execution issue:", result);
}
setPickExecutionFormOpen(false);
setSelectedLotForExecutionForm(null);
setQrScanError(false);
setQrScanSuccess(false);
setQrScanInput('');
// ✅ Keep scanner active after form submission - don't stop scanning
// Only clear processed QR codes for the specific lot, not all
// setIsManualScanning(false); // Removed - keep scanner active
// stopScan(); // Removed - keep scanner active
// resetScan(); // Removed - keep scanner active
// Don't clear all processed codes - only clear for this specific lot if needed
await fetchAllCombinedLotData();
} catch (error) {
console.error("Error submitting pick execution form:", error);
}
}, [fetchAllCombinedLotData, session, fgPickOrders]);

// Calculate remaining required quantity
const calculateRemainingRequiredQty = useCallback((lot: any) => {
const requiredQty = lot.requiredQty || 0;
@@ -2710,12 +2666,14 @@ useEffect(() => {
>();
combinedLotData.forEach((lot: any, originalIndex: number) => {
const routeKey = String(lot?.routerRoute ?? "").trim();
const pickOrderLineKey =
lot?.pickOrderLineId != null ? `pol:${String(lot.pickOrderLineId)}` : "pol:unknown";
const itemKey =
lot?.itemId != null
? `itemId:${String(lot.itemId)}`
: `itemCode:${String(lot?.itemCode ?? "").trim()}`;
// Group only within same route to avoid collapsing different routes visually.
const key = `${routeKey}__${itemKey}`;
// Group by pickOrderLine first so no-lot row stays with its lot rows even when route is empty.
const key = `${pickOrderLineKey}__${itemKey}__${routeKey}`;
const g = groups.get(key);
if (!g) {
groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] });
@@ -3487,7 +3445,7 @@ const handleSubmitAllScanned = useCallback(async () => {
variant="outlined"
startIcon={<QrCodeIcon />}
onClick={handleStopScan}
color="secondary"
color={isExtraTicket ? "secondary" : "primary"}
sx={{ minWidth: '120px' }}
>
{t("Stop QR Scan")}
@@ -3497,7 +3455,7 @@ const handleSubmitAllScanned = useCallback(async () => {
variant="contained"
startIcon={<QrCodeIcon />}
onClick={handleStartScan}
color="primary"
color={isExtraTicket ? "secondary" : "primary"}
sx={{ minWidth: '120px' }}
>
{t("Start QR Scan")}
@@ -3507,7 +3465,7 @@ const handleSubmitAllScanned = useCallback(async () => {
{/* 保留:Submit All Scanned Button */}
<Button
variant="contained"
color="success"
color={isExtraTicket ? "secondary" : "success"}
onClick={handleSubmitAllScanned}
disabled={
scannedItemsCount === 0
@@ -3528,10 +3486,31 @@ const handleSubmitAllScanned = useCallback(async () => {

{fgPickOrders.length > 0 && (
<Paper sx={{ p: 2, mb: 2 }}>
<Paper
sx={{
p: 2,
mb: 2,
borderLeft: isExtraTicket ? "6px solid" : "none",
borderColor: isExtraTicket ? "secondary.main" : "transparent",
backgroundColor: isExtraTicket ? "#f8f0ff" : "background.paper",
}}
>
<Stack spacing={2}>
{isExtraTicket && (
<Alert severity="info" sx={{ mb: 0.5, borderColor: "secondary.main", color: "secondary.dark" }}>
{t("Etra Ticket Notice")}
</Alert>
)}
{/* 基本信息 */}
<Stack direction="row" spacing={4} useFlexGap flexWrap="wrap">
{isExtraTicket && (
<Chip
label={t("isExtra order")}
color="secondary"
variant="filled"
sx={{ fontWeight: 700 }}
/>
)}
<Typography variant="subtitle1">
<strong>{t("Shop Name")}:</strong> {fgPickOrders[0].shopName || '-'}
</Typography>
@@ -3539,7 +3518,7 @@ const handleSubmitAllScanned = useCallback(async () => {
<strong>{t("Store ID")}:</strong> {fgPickOrders[0].storeId || '-'}
</Typography>
<Typography variant="subtitle1">
<strong>{t("Ticket No.")}:</strong> {fgPickOrders[0].ticketNo || '-'}
<strong>{t("Ticket No.")}:</strong> {fgPickOrders[0].ticketNo || '-'}{isExtraTicket ? ` (${t("isExtra order")})` : ""}
</Typography>
<Typography variant="subtitle1">
<strong>{t("Departure Time")}:</strong> {fgPickOrders[0].DepartureTime || '-'}
@@ -4010,7 +3989,7 @@ paginatedData.map((row, index) => {
(Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
}
sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }}
>
{t("Just Completed")}
@@ -4057,35 +4036,7 @@ paginatedData.map((row, index) => {
isLoading={false}
/>
{/* 保留:Good Pick Execution Form Modal */}
{pickExecutionFormOpen && selectedLotForExecutionForm && (
<GoodPickExecutionForm
open={pickExecutionFormOpen}
onClose={() => {
setPickExecutionFormOpen(false);
setSelectedLotForExecutionForm(null);
}}
onSubmit={handlePickExecutionFormSubmit}
selectedLot={selectedLotForExecutionForm}
selectedPickOrderLine={{
id: selectedLotForExecutionForm.pickOrderLineId,
itemId: selectedLotForExecutionForm.itemId,
itemCode: selectedLotForExecutionForm.itemCode,
itemName: selectedLotForExecutionForm.itemName,
pickOrderCode: selectedLotForExecutionForm.pickOrderCode,
availableQty: selectedLotForExecutionForm.availableQty || 0,
requiredQty: selectedLotForExecutionForm.requiredQty || 0,
// uomCode: selectedLotForExecutionForm.uomCode || '',
uomDesc: selectedLotForExecutionForm.uomDesc || '',
pickedQty: selectedLotForExecutionForm.actualPickQty || 0,
uomShortDesc: selectedLotForExecutionForm.uomShortDesc || '',
suggestedList: [],
noLotLines: [],
}}
pickOrderId={selectedLotForExecutionForm.pickOrderId}
pickOrderCreateDate={new Date()}
/>
)}

<WorkbenchLotLabelPrintModal
open={workbenchLotLabelModalOpen}


+ 40
- 6
src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx View File

@@ -35,14 +35,16 @@ export type ReleasedDoPickListBridge = {
loadBeforeToday: (
shopName?: string,
storeId?: string,
truck?: string
truck?: string,
releaseType?: string
) => Promise<ReleasedDoPickOrderListItem[]>;
/** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */
loadToday: (
shopName?: string,
storeId?: string,
truck?: string,
requiredDeliveryDate?: string
requiredDeliveryDate?: string,
releaseType?: string
) => Promise<ReleasedDoPickOrderListItem[]>;
assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>;
};
@@ -59,6 +61,15 @@ interface Props {
listBridge?: ReleasedDoPickListBridge;
/** Workbench: `delivery_order_pick_order.requiredDeliveryDate` for Truck X (select day); used when [defaultDateScope] is today. */
defaultTruckRequiredDeliveryDate?: string;
/** Workbench: filter list to `releaseType` (e.g. `isExtra`). */
releaseTypeFilter?: string;
/** Prefill shop search when opening (e.g. Etra lane picker). */
initialShopSearch?: string;
/**
* When set with a workbench listBridge, non–Truck-X list uses released-today with this
* requiredDate instead of historical released (delivery date before calendar today).
*/
filterRequiredDeliveryDate?: string;
}

const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
@@ -71,6 +82,9 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
defaultDateScope: defaultDateScopeProp = "today",
listBridge,
defaultTruckRequiredDeliveryDate,
releaseTypeFilter,
initialShopSearch,
filterRequiredDeliveryDate,
}) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -94,16 +108,31 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
undefined,
undefined,
"車線-X",
defaultTruckRequiredDeliveryDate?.trim() || undefined
defaultTruckRequiredDeliveryDate?.trim() || undefined,
releaseTypeFilter?.trim() || undefined
);
} else {
data = await loadReleased(undefined, undefined, "車線-X");
data = await loadReleased(
undefined,
undefined,
"車線-X",
releaseTypeFilter?.trim() || undefined
);
}
} else if (filterRequiredDeliveryDate?.trim() && listBridge?.loadToday) {
data = await listBridge.loadToday(
shopSearch.trim() || undefined,
storeId,
truck?.trim() || undefined,
filterRequiredDeliveryDate.trim(),
releaseTypeFilter?.trim() || undefined
);
} else {
data = await loadReleased(
shopSearch.trim() || undefined,
storeId,
truck?.trim() || undefined
truck?.trim() || undefined,
releaseTypeFilter?.trim() || undefined
);
}
@@ -114,12 +143,17 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
} finally {
setLoading(false);
}
}, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate]);
}, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate]);

useEffect(() => {
loadList();
}, [loadList]);

useEffect(() => {
if (!open) return;
setShopSearch(initialShopSearch?.trim() ?? "");
}, [open, initialShopSearch]);

const handleSelectRow = useCallback(
async (item: ReleasedDoPickOrderListItem) => {
if (!currentUserId) return;


+ 1
- 112
src/components/JoWorkbench/newJobPickExecution.tsx View File

@@ -34,7 +34,6 @@ import { useRouter } from "next/navigation";
import {
updateStockOutLineStatus,
createStockOutLine,
recordPickExecutionIssue,
//applyPickExecutionHoldAndChecked,
fetchFGPickOrdersByUserIdWorkbench,
FGPickOrderResponse,
@@ -3563,89 +3562,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
console.log("Pick execution form opened for lot ID:", lot.lotId);
}, []);

const handlePickExecutionFormSubmit = useCallback(
async (data: any) => {
const lotSnap = selectedLotForExecutionForm;
const pickOrderIdEarly = filterArgs?.pickOrderId
? Number(filterArgs.pickOrderId)
: Number(lotSnap?.pickOrderId || 0) || undefined;

try {
if (currentUserId && lotSnap?.pickOrderId && lotSnap?.itemId) {
try {
await updateHandledBy(lotSnap.pickOrderId, lotSnap.itemId);
console.log(
`✅ [ISSUE FORM] Handler updated for itemId ${lotSnap.itemId}`,
);
} catch (error) {
console.error(
`❌ [ISSUE FORM] Error updating handler (non-critical):`,
error,
);
}
}

console.log("Pick execution form submitted:", data);
const issueData = {
...data,
type: "Jo",
pickerName: session?.user?.name || undefined,
handledBy: currentUserId || undefined,
};

const missN = Number(issueData.missQty ?? 0) || 0;
const badN = Number(issueData.badItemQty ?? 0) || 0;
const badPkgN = Number(issueData.badPackageQty ?? 0) || 0;
const useHoldOnlyApi = missN === 0 && badN === 0 && badPkgN === 0;

const result = useHoldOnlyApi
? await applyPickExecutionHoldAndChecked(issueData)
: await recordPickExecutionIssue(issueData);

console.log(
useHoldOnlyApi
? "Pick hold/checked applied:"
: "Pick execution issue recorded:",
result,
);

if (!result || result.code !== "SUCCESS") {
console.error("❌ Pick execution submit failed:", result);
throw new Error(result?.message || "Submit failed");
}

const solId = Number(issueData.stockOutLineId || data?.stockOutLineId);
const picked = Number(issueData.actualPickQty ?? 0);

if (solId > 0) {
setIssuePickedQtyBySolId((prev) => {
const next = { ...prev, [solId]: picked };
const pid = filterArgs?.pickOrderId
? Number(filterArgs.pickOrderId)
: undefined;
if (pid) saveIssuePickedMapJo(pid, next);
return next;
});
}

setPickExecutionFormOpen(false);
setSelectedLotForExecutionForm(null);

await fetchJobOrderData(pickOrderIdEarly);
} catch (error) {
console.error("Error submitting pick execution form:", error);
throw error;
}
},
[
fetchJobOrderData,
currentUserId,
selectedLotForExecutionForm,
updateHandledBy,
filterArgs,
session?.user?.name,
],
);
// Calculate remaining required quantity
const calculateRemainingRequiredQty = useCallback((lot: any) => {
const requiredQty = lot.requiredQty || 0;
@@ -4748,35 +4665,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
scannedLot={scannedLotData}
isLoading={isConfirmingLot}
/>
{/* Pick Execution Form Modal */}
{pickExecutionFormOpen && selectedLotForExecutionForm && (
<GoodPickExecutionForm
open={pickExecutionFormOpen}
onClose={() => {
setPickExecutionFormOpen(false);
setSelectedLotForExecutionForm(null);
}}
onSubmit={handlePickExecutionFormSubmit}
selectedLot={selectedLotForExecutionForm}
selectedPickOrderLine={{
id: selectedLotForExecutionForm.pickOrderLineId,
itemId: selectedLotForExecutionForm.itemId,
itemCode: selectedLotForExecutionForm.itemCode,
itemName: selectedLotForExecutionForm.itemName,
pickOrderCode: selectedLotForExecutionForm.pickOrderCode,
// Add missing required properties from GetPickOrderLineInfo interface
availableQty: selectedLotForExecutionForm.availableQty || 0,
requiredQty: selectedLotForExecutionForm.requiredQty || 0,
uomDesc: selectedLotForExecutionForm.uomDesc || "",
uomShortDesc: selectedLotForExecutionForm.uomShortDesc || "",
pickedQty: selectedLotForExecutionForm.actualPickQty || 0,
suggestedList: [],
noLotLines: [],
}}
pickOrderId={selectedLotForExecutionForm.pickOrderId}
pickOrderCreateDate={new Date()}
/>
)}
</FormProvider>
</TestQrCodeProvider>
);


+ 6
- 0
src/components/NavigationContent/NavigationContent.tsx View File

@@ -38,6 +38,7 @@ import Checklist from "@mui/icons-material/Checklist";
import Science from "@mui/icons-material/Science";
import UploadFile from "@mui/icons-material/UploadFile";
import Sync from "@mui/icons-material/Sync";
import Layers from "@mui/icons-material/Layers";
import { useTranslation } from "react-i18next";
import { usePathname } from "next/navigation";
import Link from "next/link";
@@ -333,6 +334,11 @@ const NavigationContent: React.FC = () => {
label: "ShopAndTruck",
path: "/settings/shop",
},
{
icon: <Layers />,
label: "DO floor (supplier)",
path: "/settings/deliveryOrderFloor",
},
{
icon: <TrendingUp />,
label: "Demand Forecast Setting",


+ 1
- 0
src/i18n/en/common.json View File

@@ -19,6 +19,7 @@
"Equipment Name": "Equipment Name",
"Equipment Code": "Equipment Code",
"ShopAndTruck": "ShopAndTruck",
"DO floor (supplier)": "DO floor (supplier)",
"TruckLance Code is required": "TruckLance Code is required",
"Truck shop details updated successfully": "Truck shop details updated successfully",
"Failed to save truck shop details": "Failed to save truck shop details",


+ 33
- 0
src/i18n/en/deliveryOrderFloor.json View File

@@ -0,0 +1,33 @@
{
"title": "Delivery order / workbench floor (supplier codes)",
"Intro": "Comma-separated supplier codes in system settings. Use edit to change; DO behavior depends on backend reading these keys.",
"2F supplier": "2F supplier",
"4F supplier": "4F supplier",
"Edit 2F": "Edit 2F",
"Edit 4F": "Edit 4F",
"Edit 2F title": "Edit 2F supplier codes",
"Edit 4F title": "Edit 4F supplier codes",
"Edit dialog title": "Edit floor–supplier mapping",
"Floor label": "Floor",
"Add mapping": "Add mapping",
"Add mapping title": "Add supplier mapping",
"Add confirm": "Add",
"Add code placeholder": "Supplier code",
"Col code": "Supplier code",
"Col name": "Supplier name",
"Col type": "Floor",
"Col actions": "Actions",
"Empty floor list": "No suppliers for this floor yet. Use “Add mapping”.",
"Unknown supplier name": "(Not in master data or empty name)",
"Delete row": "Delete row",
"Supplier list unavailable": "Could not load supplier list. Try again later.",
"Enter supplier code": "Enter a supplier code.",
"Supplier code not found": "This supplier code does not exist in the system.",
"Duplicate in floor": "This code is already in the list for the current floor.",
"Duplicate in other floor": "This code is already on the other floor; remove it there first.",
"Codes input label": "Supplier codes",
"Comma separated hint": "Separate codes with commas, no spaces",
"Save": "Save",
"Saved": "Saved",
"Cancel": "Cancel"
}

+ 1
- 0
src/i18n/zh/common.json View File

@@ -449,6 +449,7 @@
"Batch Count": "批數",
"Shop": "店鋪",
"ShopAndTruck": "店鋪路線管理",
"DO floor (supplier)": "送貨單樓層(供應商)",
"Shop Information": "店鋪資訊",
"Shop Name": "店鋪名稱",
"Shop Branch": "店鋪分店",


+ 33
- 0
src/i18n/zh/deliveryOrderFloor.json View File

@@ -0,0 +1,33 @@
{
"title": "送貨單樓層設定(供應商代碼)",
"Intro": "以下為系統設定中的供應商代碼(逗號分隔)。點編輯可修改;是否影響 DO 行為取決於後端是否讀取對應 settings。",
"2F supplier": "2F 供應商",
"4F supplier": "4F 供應商",
"Edit 2F": "編輯 2F",
"Edit 4F": "編輯 4F",
"Edit 2F title": "編輯 2F 供應商代碼",
"Edit 4F title": "編輯 4F 供應商代碼",
"Edit dialog title": "編輯樓層供應商映射",
"Floor label": "樓層",
"Add mapping": "新增映射",
"Add mapping title": "新增供應商映射",
"Add confirm": "新增",
"Add code placeholder": "輸入供應商代碼",
"Col code": "供應商代碼",
"Col name": "供應商名稱",
"Col type": "樓層",
"Col actions": "操作",
"Empty floor list": "此樓層尚無供應商,請按「新增映射」加入。",
"Unknown supplier name": "(主檔無此代碼或名稱為空)",
"Delete row": "刪除此列",
"Supplier list unavailable": "無法載入供應商清單,請稍後再試。",
"Enter supplier code": "請輸入供應商代碼。",
"Supplier code not found": "此供應商代碼不存在於系統主檔。",
"Duplicate in floor": "此代碼已在目前樓層清單中。",
"Duplicate in other floor": "此代碼已在另一樓層清單中,請先移除後再加入。",
"Codes input label": "供應商代碼",
"Comma separated hint": "多個代碼請以英文逗號分隔,勿加空白",
"Save": "儲存",
"Saved": "已儲存",
"Cancel": "取消"
}

+ 3
- 3
src/i18n/zh/pickOrder.json View File

@@ -144,15 +144,15 @@
"Batch": "批量",
"Single": "單量",
"Release Type": "放單類型",
"isEtra order": "加單",
"isExtra order": "加單",
"Etra": "加單",
"Exit Etra view": "離開加單檢視",
"Etra Pick Order Detail": "加單",
"Etra incomplete badge tooltip": "當日未完成加單票:{{count}} 張(待處理/已發佈,不含已結案)",
"Etra incomplete badge tooltip none": "目前無未完成加單票",
"Back to normal assign tab": "返回一般指派分頁",
"Enter isEtra workbench view?": "進入加單檢視?",
"Etra view groups all add-on tickets by shop and lane for the selected date.": "加單檢視會依選定日期,將 isEtra 票依店鋪與車線顯示。",
"Enter isExtra workbench view?": "進入加單檢視?",
"Etra view groups all add-on tickets by shop and lane for the selected date.": "加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。",
"Etra Ticket Notice": "目前是加單票,顯示與操作已切換為加單模式。",
"Pick Order": "提料單",


Loading…
Cancel
Save