isEtra new do chart do saerch batch release button put down not lot requied qty show 0 fixproduction
| @@ -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" | |||
| @@ -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(加單)" | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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` | |||
| @@ -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) { | |||
| @@ -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`, | |||
| @@ -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 { | |||
| @@ -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}`); | |||
| } | |||
| } | |||
| @@ -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"; | |||
| @@ -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; | |||
| @@ -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} | |||
| @@ -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} | |||
| @@ -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}> | |||
| @@ -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> | |||
| @@ -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> | |||
| ); | |||
| }; | |||
| @@ -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} | |||
| @@ -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; | |||
| @@ -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> | |||
| ); | |||
| @@ -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", | |||
| @@ -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", | |||
| @@ -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" | |||
| } | |||
| @@ -449,6 +449,7 @@ | |||
| "Batch Count": "批數", | |||
| "Shop": "店鋪", | |||
| "ShopAndTruck": "店鋪路線管理", | |||
| "DO floor (supplier)": "送貨單樓層(供應商)", | |||
| "Shop Information": "店鋪資訊", | |||
| "Shop Name": "店鋪名稱", | |||
| "Shop Branch": "店鋪分店", | |||
| @@ -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": "取消" | |||
| } | |||
| @@ -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": "提料單", | |||