isEtra new do chart do saerch batch release button put down not lot requied qty show 0 fixproduction
| @@ -21,6 +21,7 @@ import { | |||||
| fetchTopDeliveryItemsItemOptions, | fetchTopDeliveryItemsItemOptions, | ||||
| fetchStaffDeliveryPerformance, | fetchStaffDeliveryPerformance, | ||||
| fetchStaffDeliveryPerformanceHandlers, | fetchStaffDeliveryPerformanceHandlers, | ||||
| type StaffDeliveryPerformanceStoreFilter, | |||||
| type StaffOption, | type StaffOption, | ||||
| type TopDeliveryItemOption, | type TopDeliveryItemOption, | ||||
| } from "@/app/api/chart/client"; | } from "@/app/api/chart/client"; | ||||
| @@ -31,16 +32,38 @@ import SafeApexCharts from "@/components/charts/SafeApexCharts"; | |||||
| const PAGE_TITLE = "發貨與配送"; | 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 = { | type Criteria = { | ||||
| delivery: { rangeDays: number }; | delivery: { rangeDays: number }; | ||||
| topItems: { rangeDays: number; limit: 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 = { | const defaultCriteria: Criteria = { | ||||
| delivery: { rangeDays: DEFAULT_RANGE_DAYS }, | delivery: { rangeDays: DEFAULT_RANGE_DAYS }, | ||||
| topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 }, | 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() { | export default function DeliveryChartPage() { | ||||
| @@ -101,10 +124,20 @@ export default function DeliveryChartPage() { | |||||
| }, [criteria.topItems, topItemsSelected, setChartLoading]); | }, [criteria.topItems, topItemsSelected, setChartLoading]); | ||||
| React.useEffect(() => { | 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; | const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined; | ||||
| setChartLoading("staffPerf", true); | setChartLoading("staffPerf", true); | ||||
| fetchStaffDeliveryPerformance(s, e, staffNos) | |||||
| fetchStaffDeliveryPerformance(s, e, staffNos, criteria.staffPerf.storeFilter) | |||||
| .then((data) => | .then((data) => | ||||
| setChartData((prev) => ({ | setChartData((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| @@ -270,8 +303,52 @@ export default function DeliveryChartPage() { | |||||
| <> | <> | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.staffPerf.rangeDays} | 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 | <Autocomplete | ||||
| multiple | multiple | ||||
| size="small" | size="small" | ||||
| @@ -163,7 +163,7 @@ export default function M18SynPage() { | |||||
| } | } | ||||
| }; | }; | ||||
| /** DO(加單):手動按 code 同步,並寫入本地 isEtra=true(可輸入多個 code,用逗號或換行分隔) */ | |||||
| /** DO(加單):手動按 code 同步,並寫入本地 isExtra=true(可輸入多個 code,用逗號或換行分隔) */ | |||||
| const handleSyncM18DoExtraByCode = async () => { | const handleSyncM18DoExtraByCode = async () => { | ||||
| if (m18DoExtraInFlightRef.current) return; | if (m18DoExtraInFlightRef.current) return; | ||||
| const raw = m18DoExtraCode.trim(); | const raw = m18DoExtraCode.trim(); | ||||
| @@ -339,7 +339,7 @@ export default function M18SynPage() { | |||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tabValue} index={2}> | <TabPanel value={tabValue} index={2}> | ||||
| <Section title="M18 送貨訂單 — 加單 (isEtra)"> | |||||
| <Section title="M18 送貨訂單 — 加單 (isExtra)"> | |||||
| <Stack spacing={2} sx={{ mb: 2 }}> | <Stack spacing={2} sx={{ mb: 2 }}> | ||||
| <TextField | <TextField | ||||
| label="DO / Shop PO Code(加單)" | 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( | export async function fetchStaffDeliveryPerformance( | ||||
| startDate?: string, | startDate?: string, | ||||
| endDate?: string, | endDate?: string, | ||||
| staffNos?: string[] | |||||
| staffNos?: string[], | |||||
| storeFilter: StaffDeliveryPerformanceStoreFilter = "all" | |||||
| ): Promise<StaffDeliveryPerformanceRow[]> { | ): Promise<StaffDeliveryPerformanceRow[]> { | ||||
| const p = new URLSearchParams(); | const p = new URLSearchParams(); | ||||
| if (startDate) p.set("startDate", startDate); | if (startDate) p.set("startDate", startDate); | ||||
| if (endDate) p.set("endDate", endDate); | if (endDate) p.set("endDate", endDate); | ||||
| (staffNos ?? []).forEach((no) => p.append("staffNo", no)); | (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 q = p.toString(); | ||||
| const res = await clientAuthFetch( | const res = await clientAuthFetch( | ||||
| q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance` | q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance` | ||||
| @@ -26,7 +26,7 @@ export interface DoDetail { | |||||
| completeDate: string; | completeDate: string; | ||||
| status: string; | status: string; | ||||
| /** 加單 DO */ | /** 加單 DO */ | ||||
| isEtra?: boolean; | |||||
| isExtra?: boolean; | |||||
| deliveryOrderLines: DoDetailLine[]; | deliveryOrderLines: DoDetailLine[]; | ||||
| } | } | ||||
| @@ -51,7 +51,7 @@ export interface DoSearchAll { | |||||
| supplierName: string; | supplierName: string; | ||||
| shopName: string; | shopName: string; | ||||
| shopAddress?: string; | shopAddress?: string; | ||||
| isEtra?: boolean; | |||||
| isExtra?: boolean; | |||||
| } | } | ||||
| export interface DoSearchLiteResponse { | export interface DoSearchLiteResponse { | ||||
| records: DoSearchAll[]; | records: DoSearchAll[]; | ||||
| @@ -380,7 +380,7 @@ export async function fetchDoSearch( | |||||
| /** 後端:All/null 為全部;2F/4F 依供應商白名單篩選 */ | /** 後端:All/null 為全部;2F/4F 依供應商白名單篩選 */ | ||||
| floor?: string | null, | floor?: string | null, | ||||
| /** null:不篩;true/false:只顯示加單或非加單 DO */ | /** null:不篩;true/false:只顯示加單或非加單 DO */ | ||||
| isEtra?: boolean | null, | |||||
| isExtra?: boolean | null, | |||||
| ): Promise<DoSearchLiteResponse> { | ): Promise<DoSearchLiteResponse> { | ||||
| // 构建请求体 | // 构建请求体 | ||||
| const requestBody: any = { | const requestBody: any = { | ||||
| @@ -392,7 +392,7 @@ export async function fetchDoSearch( | |||||
| pageNum: pageNum || 1, | pageNum: pageNum || 1, | ||||
| pageSize: pageSize || 10, | pageSize: pageSize || 10, | ||||
| floor: floor && floor !== "All" ? floor : null, | floor: floor && floor !== "All" ? floor : null, | ||||
| ...(isEtra !== undefined && isEtra !== null ? { isEtra } : {}), | |||||
| ...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}), | |||||
| }; | }; | ||||
| // 如果日期不为空,转换为 LocalDateTime 格式 | // 如果日期不为空,转换为 LocalDateTime 格式 | ||||
| @@ -632,7 +632,7 @@ export async function fetchAllDoSearch( | |||||
| estArrStartDate: string, | estArrStartDate: string, | ||||
| truckLanceCode?: string, | truckLanceCode?: string, | ||||
| floor?: string | null, | floor?: string | null, | ||||
| isEtra?: boolean | null, | |||||
| isExtra?: boolean | null, | |||||
| ): Promise<DoSearchAll[]> { | ): Promise<DoSearchAll[]> { | ||||
| // 使用一个很大的 pageSize 来获取所有匹配的记录 | // 使用一个很大的 pageSize 来获取所有匹配的记录 | ||||
| const requestBody: any = { | const requestBody: any = { | ||||
| @@ -644,7 +644,7 @@ export async function fetchAllDoSearch( | |||||
| pageNum: 1, | pageNum: 1, | ||||
| pageSize: 10000, // 使用一个很大的值来获取所有记录 | pageSize: 10000, // 使用一个很大的值来获取所有记录 | ||||
| floor: floor && floor !== "All" ? floor : null, | floor: floor && floor !== "All" ? floor : null, | ||||
| ...(isEtra !== undefined && isEtra !== null ? { isEtra } : {}), | |||||
| ...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}), | |||||
| }; | }; | ||||
| if (estArrStartDate) { | if (estArrStartDate) { | ||||
| @@ -4,6 +4,7 @@ import { revalidateTag } from "next/cache"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | import { serverFetchJson } from "@/app/utils/fetchUtil"; | ||||
| import type { | import type { | ||||
| LaneBtn, | |||||
| PostPickOrderResponse, | PostPickOrderResponse, | ||||
| ReleasedDoPickOrderListItem, | ReleasedDoPickOrderListItem, | ||||
| StoreLaneSummary, | 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`). */ | /** Past-date `delivery_order_pick_order` tickets (same shape as `/doPickOrder/released`). */ | ||||
| export async function fetchWorkbenchReleasedDoPickOrdersForSelection( | export async function fetchWorkbenchReleasedDoPickOrdersForSelection( | ||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string | |||||
| truck?: string, | |||||
| releaseType?: string | |||||
| ): Promise<ReleasedDoPickOrderListItem[]> { | ): Promise<ReleasedDoPickOrderListItem[]> { | ||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | if (shopName?.trim()) params.append("shopName", shopName.trim()); | ||||
| if (storeId?.trim()) params.append("storeId", storeId.trim()); | if (storeId?.trim()) params.append("storeId", storeId.trim()); | ||||
| if (truck?.trim()) params.append("truck", truck.trim()); | if (truck?.trim()) params.append("truck", truck.trim()); | ||||
| if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | |||||
| const query = params.toString(); | const query = params.toString(); | ||||
| const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; | const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; | ||||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | ||||
| @@ -236,13 +259,15 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( | |||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| requiredDeliveryDate?: string | |||||
| requiredDeliveryDate?: string, | |||||
| releaseType?: string | |||||
| ): Promise<ReleasedDoPickOrderListItem[]> { | ): Promise<ReleasedDoPickOrderListItem[]> { | ||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | if (shopName?.trim()) params.append("shopName", shopName.trim()); | ||||
| if (storeId?.trim()) params.append("storeId", storeId.trim()); | if (storeId?.trim()) params.append("storeId", storeId.trim()); | ||||
| if (truck?.trim()) params.append("truck", truck.trim()); | if (truck?.trim()) params.append("truck", truck.trim()); | ||||
| if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim()); | if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim()); | ||||
| if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | |||||
| const query = params.toString(); | const query = params.toString(); | ||||
| const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; | const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; | ||||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | ||||
| @@ -258,6 +283,8 @@ export async function assignWorkbenchByLane(data: { | |||||
| truckDepartureTime?: string; | truckDepartureTime?: string; | ||||
| loadingSequence?: number | null; | loadingSequence?: number | null; | ||||
| requiredDate?: string; | requiredDate?: string; | ||||
| /** Backend normalizes to isExtra / isExtra filter on `delivery_order_pick_order.releaseType` */ | |||||
| releaseType?: string; | |||||
| }): Promise<PostPickOrderResponse> { | }): Promise<PostPickOrderResponse> { | ||||
| const res = await serverFetchJson<PostPickOrderResponse>( | const res = await serverFetchJson<PostPickOrderResponse>( | ||||
| `${BASE_API_URL}/doPickOrder/workbench/assign-by-lane`, | `${BASE_API_URL}/doPickOrder/workbench/assign-by-lane`, | ||||
| @@ -464,6 +464,10 @@ export interface LaneBtn { | |||||
| unassigned: number; | unassigned: number; | ||||
| total: number; | total: number; | ||||
| handlerName?: string | null; | 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 { | 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, | SubmitHandler, | ||||
| useForm, | useForm, | ||||
| } from "react-hook-form"; | } 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 StyledDataGrid from "../StyledDataGrid"; | ||||
| import { GridRowSelectionModel } from "@mui/x-data-grid"; | import { GridRowSelectionModel } from "@mui/x-data-grid"; | ||||
| import Swal from "sweetalert2"; | import Swal from "sweetalert2"; | ||||
| @@ -43,8 +43,10 @@ type Props = { | |||||
| searchQuery?: Record<string, any>; | searchQuery?: Record<string, any>; | ||||
| onDeliveryOrderSearch?: () => void; | 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 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 | // put all this into a new component | ||||
| // ConsoDoForm | // ConsoDoForm | ||||
| @@ -83,6 +85,8 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | ||||
| const [totalCount, setTotalCount] = useState(0); | const [totalCount, setTotalCount] = useState(0); | ||||
| const [isWorkbench, setIsWorkbench] = useState(false); | const [isWorkbench, setIsWorkbench] = useState(false); | ||||
| const [activeTab, setActiveTab] = useState<DoSearchTab>("2F"); | |||||
| const [searchBoxResetKey, setSearchBoxResetKey] = useState(0); | |||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| pageSize: 10, | pageSize: 10, | ||||
| @@ -96,8 +100,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| supplierName: "", | supplierName: "", | ||||
| shopName: "", | shopName: "", | ||||
| deliveryOrderLines: "", | deliveryOrderLines: "", | ||||
| truckLanceCode: "", // 添加这个字段 | |||||
| floor: "All", | |||||
| truckLanceCode: "", | |||||
| codeTo: "", | codeTo: "", | ||||
| statusTo: "", | statusTo: "", | ||||
| estimatedArrivalDateTo: "", | estimatedArrivalDateTo: "", | ||||
| @@ -106,8 +109,28 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| shopNameTo: "", | shopNameTo: "", | ||||
| deliveryOrderLinesTo: "", | deliveryOrderLinesTo: "", | ||||
| truckLanceCodeTo: "", | 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 [hasSearched, setHasSearched] = useState(false); | ||||
| const [hasResults, setHasResults] = useState(false); | const [hasResults, setHasResults] = useState(false); | ||||
| @@ -155,7 +178,6 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| currentSearchParams.status, | currentSearchParams.status, | ||||
| currentSearchParams.estimatedArrivalDate, | currentSearchParams.estimatedArrivalDate, | ||||
| currentSearchParams.truckLanceCode, | 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("Code"), paramName: "code", type: "text" }, | ||||
| { label: t("Shop Name"), paramName: "shopName", type: "text" }, | { label: t("Shop Name"), paramName: "shopName", type: "text" }, | ||||
| { label: t("Truck Lance Code"), paramName: "truckLanceCode", 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"), | label: t("Estimated Arrival"), | ||||
| paramName: "estimatedArrivalDate", | paramName: "estimatedArrivalDate", | ||||
| type: "date", | type: "date", | ||||
| preFilledValue: currentSearchParams.estimatedArrivalDate || "", | |||||
| }, | }, | ||||
| { | { | ||||
| label: t("Status"), | label: t("Status"), | ||||
| @@ -189,7 +203,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| ] | ] | ||||
| } | } | ||||
| ], | ], | ||||
| [t], | |||||
| [t, currentSearchParams.estimatedArrivalDate], | |||||
| ); | ); | ||||
| const onReset = useCallback(async () => { | 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(() => { | useEffect(() => { | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| @@ -425,147 +475,53 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| }, [handleSearch, searchTimeout]); | }, [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); | 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) => { | const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { | ||||
| try { | try { | ||||
| const tabFilter = resolveTabFilter(activeTab); | |||||
| const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; | |||||
| const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || ""; | |||||
| const shouldValidateTruckLane = activeTab !== "TRUCK_X"; | |||||
| if ( | if ( | ||||
| shouldValidateTruckLane && | |||||
| isTruckLaneSearchMissingEta( | isTruckLaneSearchMissingEta( | ||||
| currentSearchParams.truckLanceCode ?? "", | |||||
| effectiveTruckLanceCode, | |||||
| currentSearchParams.estimatedArrivalDate ?? "", | currentSearchParams.estimatedArrivalDate ?? "", | ||||
| ) | ) | ||||
| ) { | ) { | ||||
| @@ -593,11 +549,6 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| status = currentSearchParams.status; | status = currentSearchParams.status; | ||||
| } | } | ||||
| const floorParam = | |||||
| currentSearchParams.floor === "All" || !currentSearchParams.floor | |||||
| ? null | |||||
| : currentSearchParams.floor; | |||||
| // 显示加载提示 | // 显示加载提示 | ||||
| const loadingSwal = Swal.fire({ | const loadingSwal = Swal.fire({ | ||||
| title: t("Loading"), | title: t("Loading"), | ||||
| @@ -616,7 +567,9 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| currentSearchParams.shopName || "", | currentSearchParams.shopName || "", | ||||
| status, | status, | ||||
| estArrStartDate, | estArrStartDate, | ||||
| currentSearchParams.truckLanceCode || "", | |||||
| effectiveTruckLanceCode, | |||||
| tabFilter.floor, | |||||
| tabFilter.isExtra, | |||||
| ); | ); | ||||
| Swal.close(); | Swal.close(); | ||||
| @@ -752,7 +705,29 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| confirmButtonText: t("OK") | 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 ( | return ( | ||||
| <> | <> | ||||
| @@ -762,28 +737,36 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| component="form" | component="form" | ||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | 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 | <SearchBox | ||||
| key={`tab-reset-${searchBoxResetKey}`} | |||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={handleSearch} | onSearch={handleSearch} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { Box, CircularProgress } from "@mui/material"; | import { Box, CircularProgress } from "@mui/material"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { usePathname, useRouter, useSearchParams } from "next/navigation"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { | import { | ||||
| @@ -10,15 +11,25 @@ import { | |||||
| import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel"; | import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel"; | ||||
| import WorkbenchGoodPickExecutionDetail from "./WorkbenchGoodPickExecutionDetail"; | 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)。 | * FG workbench: 未指派顯示樓層/車線指派;已指派顯示揀貨明細(workbench API)。 | ||||
| */ | */ | ||||
| const DoWorkbenchPickShell: React.FC = () => { | |||||
| const DoWorkbenchPickShell: React.FC<DoWorkbenchPickShellProps> = ({ laneMode = "normal" }) => { | |||||
| const { data: session, status } = useSession() as { | const { data: session, status } = useSession() as { | ||||
| data: SessionWithTokens | null; | data: SessionWithTokens | null; | ||||
| status: "loading" | "authenticated" | "unauthenticated"; | status: "loading" | "authenticated" | "unauthenticated"; | ||||
| }; | }; | ||||
| const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; | const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; | ||||
| const router = useRouter(); | |||||
| const pathname = usePathname(); | |||||
| const searchParams = useSearchParams(); | |||||
| const [showDetail, setShowDetail] = useState(false); | const [showDetail, setShowDetail] = useState(false); | ||||
| const [viewLoading, setViewLoading] = useState(true); | const [viewLoading, setViewLoading] = useState(true); | ||||
| const filterArgs = useMemo(() => ({}), []); | const filterArgs = useMemo(() => ({}), []); | ||||
| @@ -65,6 +76,13 @@ const DoWorkbenchPickShell: React.FC = () => { | |||||
| void refreshWorkbenchView(); | void refreshWorkbenchView(); | ||||
| }, [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") { | if (status === "loading") { | ||||
| return ( | return ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3, width: "100%" }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3, width: "100%" }}> | ||||
| @@ -82,7 +100,11 @@ const DoWorkbenchPickShell: React.FC = () => { | |||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : !showDetail ? ( | ) : !showDetail ? ( | ||||
| <WorkbenchFloorLanePanel onPickOrderAssigned={() => void refreshWorkbenchView()} /> | |||||
| <WorkbenchFloorLanePanel | |||||
| onPickOrderAssigned={() => void refreshWorkbenchView()} | |||||
| etraOnly={laneMode === "etra"} | |||||
| onRequestNormalLaneTab={laneMode === "etra" ? goNormalAssignTab : undefined} | |||||
| /> | |||||
| ) : ( | ) : ( | ||||
| <WorkbenchGoodPickExecutionDetail | <WorkbenchGoodPickExecutionDetail | ||||
| filterArgs={filterArgs} | filterArgs={filterArgs} | ||||
| @@ -1,6 +1,17 @@ | |||||
| "use client"; | "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 React, { Suspense } from "react"; | ||||
| import { usePathname, useRouter, useSearchParams } from "next/navigation"; | import { usePathname, useRouter, useSearchParams } from "next/navigation"; | ||||
| import DoWorkbenchPickShell from "./DoWorkbenchPickShell"; | import DoWorkbenchPickShell from "./DoWorkbenchPickShell"; | ||||
| @@ -11,12 +22,26 @@ import WorkbenchTicketReleaseTableTab from "./WorkbenchTicketReleaseTable"; | |||||
| import { Stack } from "@mui/system"; | import { Stack } from "@mui/system"; | ||||
| import Swal from "sweetalert2"; | import Swal from "sweetalert2"; | ||||
| import { printDNWorkbench } from "@/app/api/do/actions"; | 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 FinishedGoodCartonDashboardTab from "../FinishedGoodSearch/FinishedGoodCartonDashboardTab"; | ||||
| import TruckRoutingSummaryTabWorkbench from "./TruckRoutingSummaryTabWorkbench"; | 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 = { | type Props = { | ||||
| defaultTabIndex?: 0 | 1; | defaultTabIndex?: 0 | 1; | ||||
| @@ -45,6 +70,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| const [a4Printer, setA4Printer] = React.useState<PrinterCombo | null>(null); | const [a4Printer, setA4Printer] = React.useState<PrinterCombo | null>(null); | ||||
| const [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(null); | const [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(null); | ||||
| const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); | const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); | ||||
| const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0); | |||||
| const { t } = useTranslation( ); | const { t } = useTranslation( ); | ||||
| const a4Printers = React.useMemo( | const a4Printers = React.useMemo( | ||||
| () => (printerCombo || []).filter((printer) => printer.type === "A4"), | () => (printerCombo || []).filter((printer) => printer.type === "A4"), | ||||
| @@ -55,19 +81,46 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| [printerCombo], | [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); | 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(() => { | 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(() => { | React.useEffect(() => { | ||||
| if (urlTabStr == null || urlTabStr === "") return; | if (urlTabStr == null || urlTabStr === "") return; | ||||
| @@ -82,7 +135,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| setTab(newTab); | setTab(newTab); | ||||
| const params = new URLSearchParams(searchParams.toString()); | const params = new URLSearchParams(searchParams.toString()); | ||||
| params.set("tab", String(newTab)); | params.set("tab", String(newTab)); | ||||
| if (newTab !== 1) { | |||||
| /* ticketNo deep-link only for "Finished Good Record" (mine) */ | |||||
| if (newTab !== 2) { | |||||
| params.delete("ticketNo"); | params.delete("ticketNo"); | ||||
| } | } | ||||
| const qs = params.toString(); | const qs = params.toString(); | ||||
| @@ -156,7 +210,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| showConfirmButton: false, | showConfirmButton: false, | ||||
| timer: 1500, | timer: 1500, | ||||
| }); | }); | ||||
| await fetchReleasedOrderCount(); | |||||
| await refreshWorkbenchCounts(); | |||||
| } catch (error) { | } catch (error) { | ||||
| Swal.close(); | Swal.close(); | ||||
| console.error("Error in workbench handleAllDraft:", error); | 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"), | text: t("An error occurred during batch print"), | ||||
| }); | }); | ||||
| } | } | ||||
| }, [a4Printer, t, fetchReleasedOrderCount]); | |||||
| }, [a4Printer, t, refreshWorkbenchCounts]); | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| @@ -223,19 +277,85 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| {`${t("Print All Draft")} (${releasedOrderCount})`} | {`${t("Print All Draft")} (${releasedOrderCount})`} | ||||
| </Button> | </Button> | ||||
| </Stack> | </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("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={5} /> | ||||
| <Tab label={t("送貨路線摘要")} value={6} /> | <Tab label={t("送貨路線摘要")} value={6} /> | ||||
| </Tabs> | </Tabs> | ||||
| <TabPanel value={tab} index={0}> | <TabPanel value={tab} index={0}> | ||||
| <DoWorkbenchPickShell /> | |||||
| <DoWorkbenchPickShell laneMode="normal" /> | |||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={1}> | <TabPanel value={tab} index={1}> | ||||
| <DoWorkbenchPickShell laneMode="etra" /> | |||||
| </TabPanel> | |||||
| <TabPanel value={tab} index={2}> | |||||
| <GoodPickExecutionWorkbenchRecord | <GoodPickExecutionWorkbenchRecord | ||||
| key={`workbench-record-mine-${urlTicketNo ?? ""}`} | key={`workbench-record-mine-${urlTicketNo ?? ""}`} | ||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| @@ -245,17 +365,15 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| initialTicketNo={urlTicketNo} | initialTicketNo={urlTicketNo} | ||||
| /> | /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={2}> | |||||
| <TabPanel value={tab} index={3}> | |||||
| <GoodPickExecutionWorkbenchRecord | <GoodPickExecutionWorkbenchRecord | ||||
| //key={`workbench-record-all-${urlTicketNo ?? ""}`} | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| listScope="all" | listScope="all" | ||||
| a4Printer={a4Printer} | a4Printer={a4Printer} | ||||
| labelPrinter={labelPrinter} | labelPrinter={labelPrinter} | ||||
| //initialTicketNo={urlTicketNo} | |||||
| /> | /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={3}> | |||||
| <TabPanel value={tab} index={4}> | |||||
| <WorkbenchTicketReleaseTableTab /> | <WorkbenchTicketReleaseTableTab /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={5}> | <TabPanel value={tab} index={5}> | ||||
| @@ -633,7 +633,29 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| <CardContent> | <CardContent> | ||||
| <Stack direction="row" justifyContent="space-between" alignItems="center"> | <Stack direction="row" justifyContent="space-between" alignItems="center"> | ||||
| <Box> | <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"> | <Typography variant="body2" color="text.secondary"> | ||||
| {row.ticketNo || "-"} | {row.ticketNo || "-"} | ||||
| </Typography> | </Typography> | ||||
| @@ -648,7 +670,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| {t("Ticket No.")}: {row.ticketNo || "-"} | {t("Ticket No.")}: {row.ticketNo || "-"} | ||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| <Chip label={t("completed")} color="success" size="small" /> | |||||
| {/*<Chip label={t("completed")} color="success" size="small" />*/} | |||||
| </Stack> | </Stack> | ||||
| </CardContent> | </CardContent> | ||||
| <CardActions> | <CardActions> | ||||
| @@ -9,9 +9,11 @@ import type { StoreLaneSummary, LaneRow, LaneBtn } from "@/app/api/pickOrder/act | |||||
| import { | import { | ||||
| assignByDeliveryOrderPickOrderId, | assignByDeliveryOrderPickOrderId, | ||||
| assignWorkbenchByLane, | assignWorkbenchByLane, | ||||
| fetchWorkbenchEtraLaneSummary, | |||||
| fetchWorkbenchReleasedDoPickOrdersForSelection, | fetchWorkbenchReleasedDoPickOrdersForSelection, | ||||
| fetchWorkbenchReleasedDoPickOrdersForSelectionToday, | fetchWorkbenchReleasedDoPickOrdersForSelectionToday, | ||||
| fetchWorkbenchStoreLaneSummary, | fetchWorkbenchStoreLaneSummary, | ||||
| type WorkbenchEtraShopLaneGroup, | |||||
| } from "@/app/api/doworkbench/actions"; | } from "@/app/api/doworkbench/actions"; | ||||
| import Swal from "sweetalert2"; | import Swal from "sweetalert2"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -21,12 +23,22 @@ interface Props { | |||||
| onPickOrderAssigned?: () => void; | onPickOrderAssigned?: () => void; | ||||
| onSwitchToDetailTab?: () => void; | onSwitchToDetailTab?: () => void; | ||||
| initialReleaseType?: string; | 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 LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn }; | ||||
| type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] }; | 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 { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | 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 [selectedDate, setSelectedDate] = useState<string>("today"); | ||||
| const [releaseType, setReleaseType] = useState<string>(initialReleaseType); | const [releaseType, setReleaseType] = useState<string>(initialReleaseType); | ||||
| const [ticketFloor, setTicketFloor] = useState<"2/F" | "4/F">("2/F"); | 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 defaultTruckCount = summary4F?.defaultTruckCount ?? 0; | ||||
| const etraEnterInFlightRef = useRef(false); | |||||
| const inEtraUi = useMemo(() => etraOnly || isExtraView, [etraOnly, isExtraView]); | |||||
| const selectedDeliveryDateYmd = useMemo(() => { | const selectedDeliveryDateYmd = useMemo(() => { | ||||
| if (selectedDate === "today") return dayjs().format("YYYY-MM-DD"); | if (selectedDate === "today") return dayjs().format("YYYY-MM-DD"); | ||||
| @@ -60,14 +81,20 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| const workbenchReleasedListBridge = useMemo( | const workbenchReleasedListBridge = useMemo( | ||||
| () => ({ | () => ({ | ||||
| loadBeforeToday: fetchWorkbenchReleasedDoPickOrdersForSelection, | |||||
| loadBeforeToday: ( | |||||
| shopName?: string, | |||||
| storeId?: string, | |||||
| truck?: string, | |||||
| releaseType?: string | |||||
| ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType), | |||||
| loadToday: ( | loadToday: ( | ||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| requiredDeliveryDate?: string | |||||
| requiredDeliveryDate?: string, | |||||
| releaseType?: string | |||||
| ) => | ) => | ||||
| fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate), | |||||
| fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType), | |||||
| assignByListItemId: assignByDeliveryOrderPickOrderId, | assignByListItemId: assignByDeliveryOrderPickOrderId, | ||||
| }), | }), | ||||
| [], | [], | ||||
| @@ -118,12 +145,35 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| } | } | ||||
| }, [selectedDate, releaseType]); | }, [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(() => { | useEffect(() => { | ||||
| void loadSummaries(); | |||||
| }, [loadSummaries]); | |||||
| if (inEtraUi) void loadEtraSummaries(); | |||||
| else void loadSummaries(); | |||||
| }, [inEtraUi, loadEtraSummaries, loadSummaries]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const loadCounts = async () => { | const loadCounts = async () => { | ||||
| if (inEtraUi) return; | |||||
| pendingRef.current += 1; | pendingRef.current += 1; | ||||
| startFullTimer(); | startFullTimer(); | ||||
| try { | try { | ||||
| @@ -153,10 +203,11 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| } | } | ||||
| }; | }; | ||||
| void loadCounts(); | void loadCounts(); | ||||
| }, [loadSummaries]); | |||||
| }, [inEtraUi, loadSummaries]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const loadBeforeTodayTruckX = async () => { | const loadBeforeTodayTruckX = async () => { | ||||
| if (inEtraUi) return; | |||||
| pendingRef.current += 1; | pendingRef.current += 1; | ||||
| startFullTimer(); | startFullTimer(); | ||||
| try { | try { | ||||
| @@ -170,6 +221,35 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| } | } | ||||
| }; | }; | ||||
| void loadBeforeTodayTruckX(); | 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( | const handleAssignByLane = useCallback( | ||||
| @@ -198,7 +278,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| console.log("assignByLane result:", res); | console.log("assignByLane result:", res); | ||||
| if (res.code === "SUCCESS") { | if (res.code === "SUCCESS") { | ||||
| window.dispatchEvent(new CustomEvent("pickOrderAssigned")); | window.dispatchEvent(new CustomEvent("pickOrderAssigned")); | ||||
| void loadSummaries(); | |||||
| void (inEtraUi ? loadEtraSummaries() : loadSummaries()); | |||||
| onPickOrderAssigned?.(); | onPickOrderAssigned?.(); | ||||
| onSwitchToDetailTab?.(); | onSwitchToDetailTab?.(); | ||||
| } | } | ||||
| @@ -208,7 +288,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| setIsAssigning(false); | setIsAssigning(false); | ||||
| } | } | ||||
| }, | }, | ||||
| [currentUserId, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t], | |||||
| [currentUserId, inEtraUi, loadEtraSummaries, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t], | |||||
| ); | ); | ||||
| const handleLaneButtonClick = useCallback( | const handleLaneButtonClick = useCallback( | ||||
| @@ -280,7 +360,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| return ( | return ( | ||||
| <Box sx={{ mb: 2 }}> | <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 }}> | <Box sx={{ maxWidth: 300 }}> | ||||
| <FormControl fullWidth size="small"> | <FormControl fullWidth size="small"> | ||||
| <InputLabel id="date-select-label">{t("Select Date")}</InputLabel> | <InputLabel id="date-select-label">{t("Select Date")}</InputLabel> | ||||
| @@ -291,26 +371,60 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| </Box> | </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> | </Stack> | ||||
| {!inEtraUi ? ( | |||||
| <Grid container spacing={2}> | <Grid container spacing={2}> | ||||
| {ticketFloor === "2/F" && ( | {ticketFloor === "2/F" && ( | ||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| @@ -388,6 +502,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={() => { | onClick={() => { | ||||
| clearModalEtraContext(); | |||||
| setSelectedStore(""); | setSelectedStore(""); | ||||
| setSelectedTruck("車線-X"); | setSelectedTruck("車線-X"); | ||||
| setIsDefaultTruck(true); | setIsDefaultTruck(true); | ||||
| @@ -426,6 +541,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={() => { | onClick={() => { | ||||
| clearModalEtraContext(); | |||||
| setIsDefaultTruck(false); | setIsDefaultTruck(false); | ||||
| setSelectedStore("2/F"); | setSelectedStore("2/F"); | ||||
| setSelectedTruck(truck); | setSelectedTruck(truck); | ||||
| @@ -456,6 +572,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={() => { | onClick={() => { | ||||
| clearModalEtraContext(); | |||||
| setIsDefaultTruck(false); | setIsDefaultTruck(false); | ||||
| setSelectedStore("4/F"); | setSelectedStore("4/F"); | ||||
| setSelectedTruck(truck); | setSelectedTruck(truck); | ||||
| @@ -489,6 +606,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={() => { | onClick={() => { | ||||
| clearModalEtraContext(); | |||||
| setSelectedStore("4/F"); | setSelectedStore("4/F"); | ||||
| setSelectedTruck("車線-X"); | setSelectedTruck("車線-X"); | ||||
| setIsDefaultTruck(true); | setIsDefaultTruck(true); | ||||
| @@ -502,6 +620,89 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| </Grid> | </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 | <ReleasedDoPickOrderSelectModal | ||||
| open={modalOpen} | open={modalOpen} | ||||
| @@ -511,14 +712,20 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitc | |||||
| defaultDateScope={defaultDateScope} | defaultDateScope={defaultDateScope} | ||||
| defaultTruckRequiredDeliveryDate={selectedDeliveryDateYmd} | defaultTruckRequiredDeliveryDate={selectedDeliveryDateYmd} | ||||
| listBridge={workbenchReleasedListBridge} | listBridge={workbenchReleasedListBridge} | ||||
| onClose={() => setModalOpen(false)} | |||||
| releaseTypeFilter={modalReleaseTypeFilter} | |||||
| filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate} | |||||
| initialShopSearch={modalInitialShopSearch} | |||||
| onClose={() => { | |||||
| setModalOpen(false); | |||||
| clearModalEtraContext(); | |||||
| }} | |||||
| onAssigned={() => { | onAssigned={() => { | ||||
| void loadSummaries(); | |||||
| if (inEtraUi) void loadEtraSummaries(); | |||||
| else void loadSummaries(); | |||||
| onPickOrderAssigned?.(); | onPickOrderAssigned?.(); | ||||
| onSwitchToDetailTab?.(); | onSwitchToDetailTab?.(); | ||||
| }} | }} | ||||
| /> | /> | ||||
| </Grid> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -534,6 +534,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]); | const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]); | ||||
| const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); | 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 lotFloorPrefixFilter = useMemo(() => { | ||||
| const storeId = String(fgPickOrders?.[0]?.storeId ?? "") | const storeId = String(fgPickOrders?.[0]?.storeId ?? "") | ||||
| @@ -605,21 +609,23 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| // TODO: Implement QR code functionality | // TODO: Implement QR code functionality | ||||
| }; | }; | ||||
| const progress = useMemo(() => { | 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 }; | 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; | }).length; | ||||
| return { | return { | ||||
| completed: nonPendingCount, | |||||
| total: combinedLotData.length, | |||||
| completed: completedCount, | |||||
| total: progressLots.length, | |||||
| }; | }; | ||||
| }, [combinedLotData]); | }, [combinedLotData]); | ||||
| @@ -744,7 +750,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| ) { | ) { | ||||
| workbenchFinishNavigateDoneRef.current = true; | workbenchFinishNavigateDoneRef.current = true; | ||||
| router.replace( | router.replace( | ||||
| `${pathname}?tab=1&ticketNo=${encodeURIComponent(ticketForRedirect)}`, | |||||
| `${pathname}?tab=2&ticketNo=${encodeURIComponent(ticketForRedirect)}`, | |||||
| { scroll: false }, | { scroll: false }, | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -832,8 +838,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| pickOrderLinesForDisplay.forEach((line: any) => { | pickOrderLinesForDisplay.forEach((line: any) => { | ||||
| // 用来记录这一行已经通过 lots 出现过的 lotId | // 用来记录这一行已经通过 lots 出现过的 lotId | ||||
| const lotIdSet = new Set<number>(); | const lotIdSet = new Set<number>(); | ||||
| /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */ | |||||
| let lotsAllocatedSumForLine = 0; | |||||
| // ✅ lots:按 lotId 去重并合并 requiredQty | // ✅ lots:按 lotId 去重并合并 requiredQty | ||||
| if (line.lots && line.lots.length > 0) { | if (line.lots && line.lots.length > 0) { | ||||
| @@ -851,7 +855,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| }); | }); | ||||
| lotMap.forEach((lot: any) => { | lotMap.forEach((lot: any) => { | ||||
| lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; | |||||
| if (lot.id != null) { | if (lot.id != null) { | ||||
| lotIdSet.add(lot.id); | lotIdSet.add(lot.id); | ||||
| } | } | ||||
| @@ -915,6 +918,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| return; | 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 行 | // - noLot === true 的 Null stock 行 | ||||
| // - 或者 lotId 在 lots 中不存在的特殊情况 | // - 或者 lotId 在 lots 中不存在的特殊情况 | ||||
| @@ -942,13 +954,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| location: stockout.location || null, | location: stockout.location || null, | ||||
| stockUnit: line.item.uomDesc, | stockUnit: line.item.uomDesc, | ||||
| availableQty: stockout.availableQty || 0, | 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, | actualPickQty: stockout.qty || 0, | ||||
| inQty: 0, | inQty: 0, | ||||
| outQty: 0, | outQty: 0, | ||||
| @@ -956,7 +964,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| lotStatus: stockout.noLot ? "unavailable" : "available", | lotStatus: stockout.noLot ? "unavailable" : "available", | ||||
| lotAvailability: stockout.noLot ? "insufficient_stock" : "available", | lotAvailability: stockout.noLot ? "insufficient_stock" : "available", | ||||
| processingStatus: stockout.status || "pending", | processingStatus: stockout.status || "pending", | ||||
| suggestedPickLotId: null, | |||||
| suggestedPickLotId: stockout.suggestedPickLotId ?? null, | |||||
| stockOutLineId: stockout.id || null, | stockOutLineId: stockout.id || null, | ||||
| stockOutLineStatus: stockout.status || null, | stockOutLineStatus: stockout.status || null, | ||||
| stockOutLineQty: stockout.qty || 0, | stockOutLineQty: stockout.qty || 0, | ||||
| @@ -964,8 +972,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| routerId: null, | routerId: null, | ||||
| routerIndex: stockout.noLot ? 999999 : null, | routerIndex: stockout.noLot ? 999999 : null, | ||||
| routerRoute: null, | |||||
| routerArea: null, | |||||
| routerRoute: fallbackRouteFromLine, | |||||
| routerArea: fallbackRouteFromLine, | |||||
| noLot: !!stockout.noLot, | noLot: !!stockout.noLot, | ||||
| }); | }); | ||||
| }); | }); | ||||
| @@ -1166,7 +1174,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const event = new CustomEvent('pickOrderCompletionStatus', { | const event = new CustomEvent('pickOrderCompletionStatus', { | ||||
| detail: { | detail: { | ||||
| allLotsCompleted, | allLotsCompleted, | ||||
| tabIndex: 1 // 明确指定这是来自标签页 1 的事件 | |||||
| tabIndex: 2 // DO workbench「Finished Good Record (mine)」分頁索引 | |||||
| } | } | ||||
| }); | }); | ||||
| window.dispatchEvent(event); | window.dispatchEvent(event); | ||||
| @@ -2537,59 +2545,7 @@ useEffect(() => { | |||||
| console.log("Pick execution form opened for lot ID:", lot.lotId); | 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 | // Calculate remaining required quantity | ||||
| const calculateRemainingRequiredQty = useCallback((lot: any) => { | const calculateRemainingRequiredQty = useCallback((lot: any) => { | ||||
| const requiredQty = lot.requiredQty || 0; | const requiredQty = lot.requiredQty || 0; | ||||
| @@ -2710,12 +2666,14 @@ useEffect(() => { | |||||
| >(); | >(); | ||||
| combinedLotData.forEach((lot: any, originalIndex: number) => { | combinedLotData.forEach((lot: any, originalIndex: number) => { | ||||
| const routeKey = String(lot?.routerRoute ?? "").trim(); | const routeKey = String(lot?.routerRoute ?? "").trim(); | ||||
| const pickOrderLineKey = | |||||
| lot?.pickOrderLineId != null ? `pol:${String(lot.pickOrderLineId)}` : "pol:unknown"; | |||||
| const itemKey = | const itemKey = | ||||
| lot?.itemId != null | lot?.itemId != null | ||||
| ? `itemId:${String(lot.itemId)}` | ? `itemId:${String(lot.itemId)}` | ||||
| : `itemCode:${String(lot?.itemCode ?? "").trim()}`; | : `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); | const g = groups.get(key); | ||||
| if (!g) { | if (!g) { | ||||
| groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] }); | groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] }); | ||||
| @@ -3487,7 +3445,7 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| variant="outlined" | variant="outlined" | ||||
| startIcon={<QrCodeIcon />} | startIcon={<QrCodeIcon />} | ||||
| onClick={handleStopScan} | onClick={handleStopScan} | ||||
| color="secondary" | |||||
| color={isExtraTicket ? "secondary" : "primary"} | |||||
| sx={{ minWidth: '120px' }} | sx={{ minWidth: '120px' }} | ||||
| > | > | ||||
| {t("Stop QR Scan")} | {t("Stop QR Scan")} | ||||
| @@ -3497,7 +3455,7 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| variant="contained" | variant="contained" | ||||
| startIcon={<QrCodeIcon />} | startIcon={<QrCodeIcon />} | ||||
| onClick={handleStartScan} | onClick={handleStartScan} | ||||
| color="primary" | |||||
| color={isExtraTicket ? "secondary" : "primary"} | |||||
| sx={{ minWidth: '120px' }} | sx={{ minWidth: '120px' }} | ||||
| > | > | ||||
| {t("Start QR Scan")} | {t("Start QR Scan")} | ||||
| @@ -3507,7 +3465,7 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| {/* 保留:Submit All Scanned Button */} | {/* 保留:Submit All Scanned Button */} | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="success" | |||||
| color={isExtraTicket ? "secondary" : "success"} | |||||
| onClick={handleSubmitAllScanned} | onClick={handleSubmitAllScanned} | ||||
| disabled={ | disabled={ | ||||
| scannedItemsCount === 0 | scannedItemsCount === 0 | ||||
| @@ -3528,10 +3486,31 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| {fgPickOrders.length > 0 && ( | {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}> | <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"> | <Stack direction="row" spacing={4} useFlexGap flexWrap="wrap"> | ||||
| {isExtraTicket && ( | |||||
| <Chip | |||||
| label={t("isExtra order")} | |||||
| color="secondary" | |||||
| variant="filled" | |||||
| sx={{ fontWeight: 700 }} | |||||
| /> | |||||
| )} | |||||
| <Typography variant="subtitle1"> | <Typography variant="subtitle1"> | ||||
| <strong>{t("Shop Name")}:</strong> {fgPickOrders[0].shopName || '-'} | <strong>{t("Shop Name")}:</strong> {fgPickOrders[0].shopName || '-'} | ||||
| </Typography> | </Typography> | ||||
| @@ -3539,7 +3518,7 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| <strong>{t("Store ID")}:</strong> {fgPickOrders[0].storeId || '-'} | <strong>{t("Store ID")}:</strong> {fgPickOrders[0].storeId || '-'} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="subtitle1"> | <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> | ||||
| <Typography variant="subtitle1"> | <Typography variant="subtitle1"> | ||||
| <strong>{t("Departure Time")}:</strong> {fgPickOrders[0].DepartureTime || '-'} | <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) | (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | ||||
| } | } | ||||
| sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }} | sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }} | ||||
| > | > | ||||
| {t("Just Completed")} | {t("Just Completed")} | ||||
| @@ -4057,35 +4036,7 @@ paginatedData.map((row, index) => { | |||||
| isLoading={false} | 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 | <WorkbenchLotLabelPrintModal | ||||
| open={workbenchLotLabelModalOpen} | open={workbenchLotLabelModalOpen} | ||||
| @@ -35,14 +35,16 @@ export type ReleasedDoPickListBridge = { | |||||
| loadBeforeToday: ( | loadBeforeToday: ( | ||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string | |||||
| truck?: string, | |||||
| releaseType?: string | |||||
| ) => Promise<ReleasedDoPickOrderListItem[]>; | ) => Promise<ReleasedDoPickOrderListItem[]>; | ||||
| /** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */ | /** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */ | ||||
| loadToday: ( | loadToday: ( | ||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| requiredDeliveryDate?: string | |||||
| requiredDeliveryDate?: string, | |||||
| releaseType?: string | |||||
| ) => Promise<ReleasedDoPickOrderListItem[]>; | ) => Promise<ReleasedDoPickOrderListItem[]>; | ||||
| assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>; | assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>; | ||||
| }; | }; | ||||
| @@ -59,6 +61,15 @@ interface Props { | |||||
| listBridge?: ReleasedDoPickListBridge; | listBridge?: ReleasedDoPickListBridge; | ||||
| /** Workbench: `delivery_order_pick_order.requiredDeliveryDate` for Truck X (select day); used when [defaultDateScope] is today. */ | /** Workbench: `delivery_order_pick_order.requiredDeliveryDate` for Truck X (select day); used when [defaultDateScope] is today. */ | ||||
| defaultTruckRequiredDeliveryDate?: string; | 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> = ({ | const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | ||||
| @@ -71,6 +82,9 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||||
| defaultDateScope: defaultDateScopeProp = "today", | defaultDateScope: defaultDateScopeProp = "today", | ||||
| listBridge, | listBridge, | ||||
| defaultTruckRequiredDeliveryDate, | defaultTruckRequiredDeliveryDate, | ||||
| releaseTypeFilter, | |||||
| initialShopSearch, | |||||
| filterRequiredDeliveryDate, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -94,16 +108,31 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||||
| undefined, | undefined, | ||||
| undefined, | undefined, | ||||
| "車線-X", | "車線-X", | ||||
| defaultTruckRequiredDeliveryDate?.trim() || undefined | |||||
| defaultTruckRequiredDeliveryDate?.trim() || undefined, | |||||
| releaseTypeFilter?.trim() || undefined | |||||
| ); | ); | ||||
| } else { | } 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 { | } else { | ||||
| data = await loadReleased( | data = await loadReleased( | ||||
| shopSearch.trim() || undefined, | shopSearch.trim() || undefined, | ||||
| storeId, | storeId, | ||||
| truck?.trim() || undefined | |||||
| truck?.trim() || undefined, | |||||
| releaseTypeFilter?.trim() || undefined | |||||
| ); | ); | ||||
| } | } | ||||
| @@ -114,12 +143,17 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate]); | |||||
| }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadList(); | loadList(); | ||||
| }, [loadList]); | }, [loadList]); | ||||
| useEffect(() => { | |||||
| if (!open) return; | |||||
| setShopSearch(initialShopSearch?.trim() ?? ""); | |||||
| }, [open, initialShopSearch]); | |||||
| const handleSelectRow = useCallback( | const handleSelectRow = useCallback( | ||||
| async (item: ReleasedDoPickOrderListItem) => { | async (item: ReleasedDoPickOrderListItem) => { | ||||
| if (!currentUserId) return; | if (!currentUserId) return; | ||||
| @@ -34,7 +34,6 @@ import { useRouter } from "next/navigation"; | |||||
| import { | import { | ||||
| updateStockOutLineStatus, | updateStockOutLineStatus, | ||||
| createStockOutLine, | createStockOutLine, | ||||
| recordPickExecutionIssue, | |||||
| //applyPickExecutionHoldAndChecked, | //applyPickExecutionHoldAndChecked, | ||||
| fetchFGPickOrdersByUserIdWorkbench, | fetchFGPickOrdersByUserIdWorkbench, | ||||
| FGPickOrderResponse, | FGPickOrderResponse, | ||||
| @@ -3563,89 +3562,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| console.log("Pick execution form opened for lot ID:", lot.lotId); | 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 | // Calculate remaining required quantity | ||||
| const calculateRemainingRequiredQty = useCallback((lot: any) => { | const calculateRemainingRequiredQty = useCallback((lot: any) => { | ||||
| const requiredQty = lot.requiredQty || 0; | const requiredQty = lot.requiredQty || 0; | ||||
| @@ -4748,35 +4665,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| scannedLot={scannedLotData} | scannedLot={scannedLotData} | ||||
| isLoading={isConfirmingLot} | 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> | </FormProvider> | ||||
| </TestQrCodeProvider> | </TestQrCodeProvider> | ||||
| ); | ); | ||||
| @@ -38,6 +38,7 @@ import Checklist from "@mui/icons-material/Checklist"; | |||||
| import Science from "@mui/icons-material/Science"; | import Science from "@mui/icons-material/Science"; | ||||
| import UploadFile from "@mui/icons-material/UploadFile"; | import UploadFile from "@mui/icons-material/UploadFile"; | ||||
| import Sync from "@mui/icons-material/Sync"; | import Sync from "@mui/icons-material/Sync"; | ||||
| import Layers from "@mui/icons-material/Layers"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| @@ -333,6 +334,11 @@ const NavigationContent: React.FC = () => { | |||||
| label: "ShopAndTruck", | label: "ShopAndTruck", | ||||
| path: "/settings/shop", | path: "/settings/shop", | ||||
| }, | }, | ||||
| { | |||||
| icon: <Layers />, | |||||
| label: "DO floor (supplier)", | |||||
| path: "/settings/deliveryOrderFloor", | |||||
| }, | |||||
| { | { | ||||
| icon: <TrendingUp />, | icon: <TrendingUp />, | ||||
| label: "Demand Forecast Setting", | label: "Demand Forecast Setting", | ||||
| @@ -19,6 +19,7 @@ | |||||
| "Equipment Name": "Equipment Name", | "Equipment Name": "Equipment Name", | ||||
| "Equipment Code": "Equipment Code", | "Equipment Code": "Equipment Code", | ||||
| "ShopAndTruck": "ShopAndTruck", | "ShopAndTruck": "ShopAndTruck", | ||||
| "DO floor (supplier)": "DO floor (supplier)", | |||||
| "TruckLance Code is required": "TruckLance Code is required", | "TruckLance Code is required": "TruckLance Code is required", | ||||
| "Truck shop details updated successfully": "Truck shop details updated successfully", | "Truck shop details updated successfully": "Truck shop details updated successfully", | ||||
| "Failed to save truck shop details": "Failed to save truck shop details", | "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": "批數", | "Batch Count": "批數", | ||||
| "Shop": "店鋪", | "Shop": "店鋪", | ||||
| "ShopAndTruck": "店鋪路線管理", | "ShopAndTruck": "店鋪路線管理", | ||||
| "DO floor (supplier)": "送貨單樓層(供應商)", | |||||
| "Shop Information": "店鋪資訊", | "Shop Information": "店鋪資訊", | ||||
| "Shop Name": "店鋪名稱", | "Shop Name": "店鋪名稱", | ||||
| "Shop Branch": "店鋪分店", | "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": "批量", | "Batch": "批量", | ||||
| "Single": "單量", | "Single": "單量", | ||||
| "Release Type": "放單類型", | "Release Type": "放單類型", | ||||
| "isEtra order": "加單", | |||||
| "isExtra order": "加單", | |||||
| "Etra": "加單", | "Etra": "加單", | ||||
| "Exit Etra view": "離開加單檢視", | "Exit Etra view": "離開加單檢視", | ||||
| "Etra Pick Order Detail": "加單", | "Etra Pick Order Detail": "加單", | ||||
| "Etra incomplete badge tooltip": "當日未完成加單票:{{count}} 張(待處理/已發佈,不含已結案)", | "Etra incomplete badge tooltip": "當日未完成加單票:{{count}} 張(待處理/已發佈,不含已結案)", | ||||
| "Etra incomplete badge tooltip none": "目前無未完成加單票", | "Etra incomplete badge tooltip none": "目前無未完成加單票", | ||||
| "Back to normal assign tab": "返回一般指派分頁", | "Back to normal assign tab": "返回一般指派分頁", | ||||
| "Enter 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": "目前是加單票,顯示與操作已切換為加單模式。", | "Etra Ticket Notice": "目前是加單票,顯示與操作已切換為加單模式。", | ||||
| "Pick Order": "提料單", | "Pick Order": "提料單", | ||||