diff --git a/src/app/(main)/report/ReportSelectionDashboard.tsx b/src/app/(main)/report/ReportSelectionDashboard.tsx index 3e1ff3d..bc256f7 100644 --- a/src/app/(main)/report/ReportSelectionDashboard.tsx +++ b/src/app/(main)/report/ReportSelectionDashboard.tsx @@ -32,6 +32,7 @@ const REPORT_ICON_MAP: Record = { "rep-013": LocalShippingOutlinedIcon, "rep-006": BarChartOutlinedIcon, "rep-005": PieChartOutlineOutlinedIcon, + "rep-015": LayersOutlinedIcon, }; const reportById = Object.fromEntries(REPORTS.map((r) => [r.id, r])); diff --git a/src/app/(main)/report/bomShopSyncReportApi.ts b/src/app/(main)/report/bomShopSyncReportApi.ts new file mode 100644 index 0000000..2af5688 --- /dev/null +++ b/src/app/(main)/report/bomShopSyncReportApi.ts @@ -0,0 +1,205 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { + exportMultiSheetToXlsx, +} from "@/app/(main)/chart/_components/exportChartToXlsx"; + +export interface BomShopSyncReportSummary { + totalAttempts?: number; + success?: number; + skippedUnchanged?: number; + failed?: number; + syncDateStart?: string; + syncDateEnd?: string; +} + +export interface BomShopSyncRow { + syncLogId?: number; + syncDateTime?: string; + bomId?: number; + bomRoutingCode?: string; + finishedItemCode?: string; + finishedItemName?: string; + m18HeaderCode?: string; + version?: string; + m18RecordId?: number; + syncStatus?: string; + synced?: boolean; + m18ApiStatus?: boolean; + failureReason?: string; + message?: string; +} + +export interface BomShopSyncMaterialRow { + syncLogId?: number; + syncDateTime?: string; + bomId?: number; + finishedItemCode?: string; + m18HeaderCode?: string; + version?: string; + syncStatus?: string; + lineNo?: string; + materialName?: string; + udfProductM18Id?: number; + udfBaseUnit?: string; + udfQty?: number; + udfSupplierM18Id?: number; + udfPurchaseUnitM18Id?: number; +} + +export interface BomShopSyncReportResponse { + summary?: BomShopSyncReportSummary; + syncRows?: BomShopSyncRow[]; + materialRows?: BomShopSyncMaterialRow[]; +} + +const SHEET_SYNC = "BOM同步記錄"; +const SHEET_MATERIALS = "BOM物料明細"; + +const NO_DATA_NOTE = + "(篩選範圍內無資料 / No records in the selected range)"; + +/** Column keys for sheet 1 — used for headers when there are no data rows. */ +function emptySyncSheetRow(note: string = NO_DATA_NOTE): Record { + return { + 同步時間: note, + 成品貨號: "", + 成品名稱: "", + BOM路由編號: "", + "M18 BOM Code": "", + 版本: "", + "M18 Record Id": "", + 狀態: "", + 失敗原因: "", + 訊息: "", + "BOM Id": "", + "Sync Log Id": "", + }; +} + +/** Column keys for sheet 2 — used for headers when there are no data rows. */ +function emptyMaterialSheetRow(note: string = NO_DATA_NOTE): Record { + return { + 同步時間: note, + 成品貨號: "", + "M18 BOM Code": "", + 版本: "", + 狀態: "", + 行號: "", + 物料名稱: "", + "M18 Product Id": "", + 單位: "", + 用量: "", + "M18 Supplier Id": "", + "M18 Purchase Unit Id": "", + "Sync Log Id": "", + }; +} + +export async function fetchBomShopSyncReportData( + criteria: Record, +): Promise { + const queryParams = new URLSearchParams(criteria).toString(); + const url = `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history?${queryParams}`; + + const response = await clientAuthFetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + }); + + if (response.status === 401 || response.status === 403) + throw new Error("Unauthorized"); + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`); + + return (await response.json()) as BomShopSyncReportResponse; +} + +function syncStatusLabel(status: string | undefined): string { + switch (status) { + case "SUCCESS": + return "成功"; + case "SKIPPED_UNCHANGED": + return "略過(內容未變)"; + case "FAILED": + return "失敗"; + default: + return status ?? ""; + } +} + +function toSyncExcelRow(r: BomShopSyncRow): Record { + const base = emptySyncSheetRow(""); + return { + ...base, + 同步時間: r.syncDateTime ?? "", + 成品貨號: r.finishedItemCode ?? "", + 成品名稱: r.finishedItemName ?? "", + BOM路由編號: r.bomRoutingCode ?? "", + "M18 BOM Code": r.m18HeaderCode ?? "", + 版本: r.version ?? "", + "M18 Record Id": r.m18RecordId ?? "", + 狀態: syncStatusLabel(r.syncStatus), + 失敗原因: r.failureReason ?? "", + 訊息: r.message ?? "", + "BOM Id": r.bomId ?? "", + "Sync Log Id": r.syncLogId ?? "", + }; +} + +function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record { + const base = emptyMaterialSheetRow(""); + return { + ...base, + 同步時間: r.syncDateTime ?? "", + 成品貨號: r.finishedItemCode ?? "", + "M18 BOM Code": r.m18HeaderCode ?? "", + 版本: r.version ?? "", + 狀態: syncStatusLabel(r.syncStatus), + 行號: r.lineNo ?? "", + 物料名稱: r.materialName ?? "", + "M18 Product Id": r.udfProductM18Id ?? "", + 單位: r.udfBaseUnit ?? "", + 用量: r.udfQty ?? "", + "M18 Supplier Id": r.udfSupplierM18Id ?? "", + "M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "", + "Sync Log Id": r.syncLogId ?? "", + }; +} + +export async function generateBomShopSyncReportExcel( + criteria: Record, + reportTitle: string = "M18 BOM Shop 同步記錄", +): Promise { + const data = await fetchBomShopSyncReportData(criteria); + const syncRows = + (data.syncRows ?? []).length > 0 + ? (data.syncRows ?? []).map(toSyncExcelRow) + : [emptySyncSheetRow()]; + const materialRows = + (data.materialRows ?? []).length > 0 + ? (data.materialRows ?? []).map(toMaterialExcelRow) + : [emptyMaterialSheetRow()]; + + const start = criteria.syncDateStart; + const end = criteria.syncDateEnd; + let datePart: string; + if (start && end && start === end) { + datePart = start; + } else if (start || end) { + datePart = `${start || ""}_to_${end || ""}`; + } else { + datePart = new Date().toISOString().slice(0, 10); + } + const filename = `${reportTitle}_${datePart.replace(/[^\d\-_/]/g, "")}`; + + exportMultiSheetToXlsx( + [ + { name: SHEET_SYNC, rows: syncRows }, + { name: SHEET_MATERIALS, rows: materialRows }, + ], + filename, + ); +} diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index cf16570..a6b32ce 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -30,6 +30,7 @@ import { fetchSemiFGItemCodesWithCategory } from './semiFGProductionAnalysisApi'; import { generateGrnReportExcel } from './grnReportApi'; +import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; import { FEATURE_USAGE, FEATURE_USAGE_ACTION, @@ -261,6 +262,8 @@ export default function ReportPage() { currentReport.title, includeGrnFinancialColumns ); + } else if (currentReport.id === 'rep-015') { + await generateBomShopSyncReportExcel(criteria, currentReport.title); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. const queryParams = diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts index b869064..7c6cced 100644 --- a/src/app/(main)/report/reportCategories.ts +++ b/src/app/(main)/report/reportCategories.ts @@ -33,6 +33,6 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ headerBg: "#f5d4a8", bodyBg: "#fdf6ec", accent: "#e65100", - reportIds: ["rep-006", "rep-005"], + reportIds: ["rep-006", "rep-005", "rep-015"], }, ]; diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index c6c6e0e..549f218 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -38,6 +38,8 @@ export interface DoDetailLine { id: number; itemNo: string; qty: number; + /** Sum of stock_out_line qty for linked pick order line; falls back to qty. */ + actualShippedQty?: number; price: number; status: string; itemName?: string; @@ -680,6 +682,7 @@ export interface SubmitDoReplenishmentLineRequest { sourceDoLineId: number; replenishQty: number; truckLaneCode?: string; + reason?: string; } export interface DoReplenishmentRecord { @@ -692,6 +695,7 @@ export interface DoReplenishmentRecord { itemId: number; itemNo?: string; itemName?: string; + originalQty?: number; replenishQty: number; shortUom?: string; shopCode?: string; @@ -699,8 +703,10 @@ export interface DoReplenishmentRecord { truckLaneCode?: string; targetDoId?: number; targetDoCode?: string; + targetDoEstimatedArrivalDate?: string; pickOrderLineId?: number; status: string; + reason?: string; created?: string; } diff --git a/src/components/DoSearch/DoReplenishmentTab.tsx b/src/components/DoSearch/DoReplenishmentTab.tsx index 13f94d7..3e77785 100644 --- a/src/components/DoSearch/DoReplenishmentTab.tsx +++ b/src/components/DoSearch/DoReplenishmentTab.tsx @@ -5,37 +5,32 @@ import { Autocomplete, Box, Button, + Collapse, Dialog, DialogContent, DialogTitle, FormControl, IconButton, - InputLabel, MenuItem, Paper, Select, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, + TablePagination, Tooltip, Typography, } from "@mui/material"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import FilterListIcon from "@mui/icons-material/FilterList"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; import StorefrontIcon from "@mui/icons-material/Storefront"; -import { Add, Close, Delete, Search } from "@mui/icons-material"; +import { Add, Close, Delete, KeyboardArrowDown, KeyboardArrowRight } from "@mui/icons-material"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import dayjs, { Dayjs } from "dayjs"; import { useTranslation } from "react-i18next"; -import { GridColDef } from "@mui/x-data-grid"; -import Swal from "sweetalert2"; -import StyledDataGrid from "../StyledDataGrid"; +import type { TFunction } from "i18next"; +import Swal, { type SweetAlertOptions } from "sweetalert2"; import { DoDetail, DoDetailLine, @@ -48,24 +43,50 @@ import { import { arrayToDateString } from "@/app/utils/formatUtil"; import { REPLENISHMENT_FIELD_ICON_SX, - REPLENISHMENT_TABLE_AUTOCOMPLETE_SX, - REPLENISHMENT_TABLE_ENTRY_ROW_SX, - REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX, + REPLENISHMENT_AUTOCOMPLETE_SX, + REPLENISHMENT_FILLED_SELECT_SX, REPLENISHMENT_TABLE_SX, + REPLENISHMENT_FIELD_BUTTON_SX, REPLENISHMENT_LOOKUP_BUTTON_SX, + REPLENISHMENT_OUTLINED_ACTION_BUTTON_SX, REPLENISHMENT_SOURCE_HEADER_SX, - REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX, REPLENISHMENT_TEXTFIELD_SX, - ReplenishmentFieldLabel, - ReplenishmentItemEntryPlainText, + ReplenishmentFieldLabelSpacer, + ReplenishmentFilterField, + ReplenishmentReadonlyValue, ReplenishmentTextField, - replenishmentSearchGridInputSx, - replenishmentSearchGridLabelSx, - replenishmentSearchGridShopRowSx, } from "./ReplenishmentFilterField"; export type ReplenishmentStatus = "pending" | "processing" | "completed"; +export type ReplenishmentReasonCode = "quality_issue" | "out_of_stock" | "other"; + +const REPLENISHMENT_REASON_CODES: readonly ReplenishmentReasonCode[] = [ + "quality_issue", + "out_of_stock", + "other", +]; + +function isReplenishmentReasonCode(value: string): value is ReplenishmentReasonCode { + return (REPLENISHMENT_REASON_CODES as readonly string[]).includes(value); +} + +function formatReplenishmentReason(reason: string | undefined, t: TFunction): string { + const trimmed = reason?.trim(); + if (!trimmed) return "—"; + if (isReplenishmentReasonCode(trimmed)) { + return t(`replenishmentReason.${trimmed}`); + } + return trimmed; +} + +function fireReplenishmentAlert(t: TFunction, options: SweetAlertOptions) { + return Swal.fire({ + confirmButtonText: t("Confirm"), + ...options, + }); +} + export type ReplenishmentDraftRow = { rowId: string; deliveryDate: string; @@ -81,6 +102,7 @@ export type ReplenishmentDraftRow = { shopCode?: string; shopName?: string; truckLaneCode?: string; + reason?: string; }; export type ReplenishmentRecord = ReplenishmentDraftRow & { @@ -88,11 +110,68 @@ export type ReplenishmentRecord = ReplenishmentDraftRow & { code: string; targetDoId?: number; targetDoCode?: string; + targetDoEstimatedArrivalDate?: string; pickOrderLineId?: number; status: ReplenishmentStatus; created: string; }; +function resolveOriginalShipmentQty(line: DoDetailLine): number { + if (line.actualShippedQty != null && Number.isFinite(Number(line.actualShippedQty))) { + return Number(line.actualShippedQty); + } + return line.qty ?? 0; +} + +function formatQtyWithUom(qty: number | undefined, uom?: string): string { + const unit = uom?.trim(); + if (qty == null || Number.isNaN(qty)) { + return unit ? `— ${unit}` : "—"; + } + return unit ? `${qty} ${unit}` : String(qty); +} + +function TrackingFieldBlock({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) { + return ( + + + {label} + + + {value ?? "—"} + + + ); +} + +function TrackingInlineLine({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {label}: + {" "} + {children} + + ); +} + type SourceDoContext = { doId: number; doCode: string; @@ -113,7 +192,7 @@ function mapApiRecord(record: DoReplenishmentRecord): ReplenishmentRecord { itemId: record.itemId, itemNo: record.itemNo ?? "", itemName: record.itemName ?? "", - originalQty: 0, + originalQty: record.originalQty != null ? Number(record.originalQty) : 0, replenishQty: Number(record.replenishQty), shortUom: record.shortUom, shopCode: record.shopCode, @@ -125,6 +204,7 @@ function mapApiRecord(record: DoReplenishmentRecord): ReplenishmentRecord { targetDoCode: record.targetDoCode, pickOrderLineId: record.pickOrderLineId, status: record.status as ReplenishmentStatus, + reason: record.reason, created: record.created ?? "", }; } @@ -151,14 +231,30 @@ function lineUomDisplay(line?: DoDetailLine | null): string { return (line.shortUom ?? line.uomCode ?? line.uom ?? "").trim(); } -function filterSourceDoLines(lines: DoDetailLine[], inputValue: string): DoDetailLine[] { +function filterSourceDoLinesByItemNo( + lines: DoDetailLine[], + inputValue: string, +): DoDetailLine[] { const query = inputValue.trim().toLowerCase(); if (!query) return lines; - return lines.filter((line) => { - const itemNo = (line.itemNo ?? "").toLowerCase(); - const itemName = (line.itemName ?? "").toLowerCase(); - return itemNo.includes(query) || itemName.includes(query); - }); + return lines.filter((line) => + (line.itemNo ?? "").toLowerCase().includes(query), + ); +} + +function filterSourceDoLinesByItemName( + lines: DoDetailLine[], + inputValue: string, +): DoDetailLine[] { + const query = inputValue.trim().toLowerCase(); + if (!query) return lines; + return lines.filter((line) => + (line.itemName ?? "").toLowerCase().includes(query), + ); +} + +function sourceDoLineNameLabel(line: DoDetailLine): string { + return (line.itemName ?? line.itemNo ?? "").trim(); } type DraftDoGroup = { @@ -174,10 +270,27 @@ type DraftShopGroup = { dos: DraftDoGroup[]; }; +type DraftLaneGroup = { + laneKey: string; + truckLaneCode: string; + shops: DraftShopGroup[]; +}; + function draftShopGroupKey(row: ReplenishmentDraftRow): string { return row.shopCode?.trim() || row.shopName?.trim() || "—"; } +function draftLaneGroupKey(row: ReplenishmentDraftRow): string { + return row.truckLaneCode?.trim() || ""; +} + +function formatDraftShopDisplay(shopGroup: DraftShopGroup): string { + const name = shopGroup.shopName?.trim(); + const code = shopGroup.shopCode?.trim(); + if (code && name) return `${code} - ${name}`; + return name || code || shopGroup.shopKey; +} + function groupDraftRowsByShopAndDo(rows: ReplenishmentDraftRow[]): DraftShopGroup[] { const shopMap = new Map(); @@ -221,10 +334,132 @@ function groupDraftRowsByShopAndDo(rows: ReplenishmentDraftRow[]): DraftShopGrou })); } +function draftLaneCollapseKey(laneKey: string): string { + return laneKey || "__truck_x__"; +} + +function draftShopCollapseKey(laneKey: string, shopKey: string): string { + return `${draftLaneCollapseKey(laneKey)}::${shopKey}`; +} + +function draftDoCollapseKey(laneKey: string, shopKey: string, sourceDoId: number): string { + return `${draftShopCollapseKey(laneKey, shopKey)}::${sourceDoId}`; +} + +function toggleCollapsedKey( + setter: React.Dispatch>>, + key: string, +) { + setter((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); +} + +type DraftPreviewCollapseHeaderProps = { + collapsed: boolean; + onToggle: () => void; + label: string; + title: React.ReactNode; + titleVariant?: "subtitle2" | "body2"; + titleTooltip?: string; +}; + +function DraftPreviewCollapseHeader({ + collapsed, + onToggle, + label, + title, + titleVariant = "subtitle2", + titleTooltip, +}: DraftPreviewCollapseHeaderProps) { + const titleNode = ( + + {title} + + ); + + return ( + + { + event.stopPropagation(); + onToggle(); + }} + sx={{ p: 0.25, mt: -0.25, flexShrink: 0 }} + aria-expanded={!collapsed} + > + {collapsed ? ( + + ) : ( + + )} + + + + {label} + + {titleTooltip ? ( + + {titleNode} + + ) : ( + titleNode + )} + + + ); +} + +function groupDraftRowsByLaneShopAndDo(rows: ReplenishmentDraftRow[]): DraftLaneGroup[] { + const laneBuckets = new Map(); + + for (const row of rows) { + const laneKey = draftLaneGroupKey(row); + const bucket = laneBuckets.get(laneKey) ?? []; + bucket.push(row); + laneBuckets.set(laneKey, bucket); + } + + return Array.from(laneBuckets.entries()) + .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) + .map(([laneKey, laneRows]) => ({ + laneKey, + truckLaneCode: laneKey, + shops: groupDraftRowsByShopAndDo(laneRows), + })); +} + const DoReplenishmentTab: React.FC = () => { const { t } = useTranslation("do"); const inFlightRef = useRef(false); - const itemCodeInputRef = useRef(null); + const itemCodeInputRef = useRef( + null, + ) as React.MutableRefObject; const [isSubmitting, setIsSubmitting] = useState(false); const [isLookingUp, setIsLookingUp] = useState(false); @@ -234,12 +469,20 @@ const DoReplenishmentTab: React.FC = () => { const [sourceDo, setSourceDo] = useState(null); const [selectedLine, setSelectedLine] = useState(null); const [replenishQtyInput, setReplenishQtyInput] = useState(""); + const [replenishReasonInput, setReplenishReasonInput] = useState( + "", + ); const [draftRows, setDraftRows] = useState([]); + const [collapsedLaneKeys, setCollapsedLaneKeys] = useState>(() => new Set()); + const [collapsedShopKeys, setCollapsedShopKeys] = useState>(() => new Set()); + const [collapsedDoKeys, setCollapsedDoKeys] = useState>(() => new Set()); const [records, setRecords] = useState([]); const [isLoadingTracking, setIsLoadingTracking] = useState(false); const [trackStatusFilter, setTrackStatusFilter] = useState("all"); const [trackDateFilter, setTrackDateFilter] = useState(null); + const [trackPage, setTrackPage] = useState(0); + const [trackRowsPerPage, setTrackRowsPerPage] = useState(10); const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); const deliveryDateStr = deliveryDate?.format("YYYY-MM-DD") ?? ""; @@ -248,18 +491,18 @@ const DoReplenishmentTab: React.FC = () => { const suffix = doCodeSuffix.trim(); const shop = shopInput.trim(); if (suffix.length !== 4) { - await Swal.fire({ + await fireReplenishmentAlert(t,{ icon: "warning", title: t("DO code suffix must be exactly 4 characters"), }); return; } if (!deliveryDateStr) { - await Swal.fire({ icon: "warning", title: t("Delivery date is required") }); + await fireReplenishmentAlert(t,{ icon: "warning", title: t("Delivery date is required") }); return; } if (!shop) { - await Swal.fire({ icon: "warning", title: t("Shop code or name is required") }); + await fireReplenishmentAlert(t,{ icon: "warning", title: t("Shop code or name is required") }); return; } setIsLookingUp(true); @@ -284,19 +527,19 @@ const DoReplenishmentTab: React.FC = () => { matchesDeliveryDate(r.estimatedArrivalDate, deliveryDateStr), ); if (candidates.length === 0) { - await Swal.fire({ icon: "error", title: t("Source DO not found") }); + await fireReplenishmentAlert(t,{ icon: "error", title: t("Source DO not found") }); setSourceDo(null); return; } const details = await Promise.all(candidates.map((c) => fetchDoDetail(c.id))); const matched = details.filter((d) => matchesShopInput(d, shop)); if (matched.length === 0) { - await Swal.fire({ icon: "error", title: t("Source DO not found") }); + await fireReplenishmentAlert(t,{ icon: "error", title: t("Source DO not found") }); setSourceDo(null); return; } if (matched.length > 1) { - await Swal.fire({ + await fireReplenishmentAlert(t,{ icon: "error", title: t("Multiple source DOs matched"), text: t("Please verify DO code suffix, delivery date and shop."), @@ -309,7 +552,7 @@ const DoReplenishmentTab: React.FC = () => { const resolvedTruckLaneCode = detail.truckLaneCode?.trim() || matchedCandidate?.truckLanceCode?.trim() || null; if (detail.status !== "completed") { - await Swal.fire({ + await fireReplenishmentAlert(t,{ icon: "error", title: t("Source DO must be completed"), text: t("Only completed delivery orders can be used as replenishment source."), @@ -328,8 +571,9 @@ const DoReplenishmentTab: React.FC = () => { }); setSelectedLine(null); setReplenishQtyInput(""); + setReplenishReasonInput(""); } catch { - await Swal.fire({ icon: "error", title: t("Failed to lookup source DO") }); + await fireReplenishmentAlert(t,{ icon: "error", title: t("Failed to lookup source DO") }); } finally { setIsLookingUp(false); } @@ -337,20 +581,20 @@ const DoReplenishmentTab: React.FC = () => { const handleAddDraftRow = useCallback(() => { if (!sourceDo) { - void Swal.fire({ icon: "warning", title: t("Please lookup source DO first") }); + void fireReplenishmentAlert(t,{ icon: "warning", title: t("Please lookup source DO first") }); return; } if (!deliveryDateStr) { - void Swal.fire({ icon: "warning", title: t("Delivery date is required") }); + void fireReplenishmentAlert(t,{ icon: "warning", title: t("Delivery date is required") }); return; } if (!selectedLine) { - void Swal.fire({ icon: "warning", title: t("Please select an item") }); + void fireReplenishmentAlert(t,{ icon: "warning", title: t("Please select an item") }); return; } const qty = Number(replenishQtyInput); if (!Number.isFinite(qty) || qty <= 0) { - void Swal.fire({ icon: "warning", title: t("Replenish qty must be greater than zero") }); + void fireReplenishmentAlert(t,{ icon: "warning", title: t("Replenish qty must be greater than zero") }); return; } const line = selectedLine; @@ -359,11 +603,16 @@ const DoReplenishmentTab: React.FC = () => { (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, ); + const reason = replenishReasonInput || undefined; if (existingRowIndex >= 0) { setDraftRows((prev) => prev.map((row, index) => index === existingRowIndex - ? { ...row, replenishQty: row.replenishQty + qty } + ? { + ...row, + replenishQty: row.replenishQty + qty, + reason: row.reason?.trim() || reason, + } : row, ), ); @@ -378,22 +627,25 @@ const DoReplenishmentTab: React.FC = () => { sourceDoLineId: line.id, itemNo: line.itemNo ?? "", itemName: line.itemName ?? line.itemNo ?? "", - originalQty: line.qty ?? 0, + originalQty: resolveOriginalShipmentQty(line), replenishQty: qty, shortUom: lineUomDisplay(line) || undefined, shopCode: sourceDo.shopCode, shopName: sourceDo.shopName, truckLaneCode: sourceDo.truckLaneCode?.trim() || undefined, + reason, }, ]); } setSelectedLine(null); setReplenishQtyInput(""); + setReplenishReasonInput(""); window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); }, [ deliveryDateStr, draftRows, replenishQtyInput, + replenishReasonInput, selectedLine, sourceDo, t, @@ -405,12 +657,15 @@ const DoReplenishmentTab: React.FC = () => { const handleClearDraftRows = useCallback(() => { setDraftRows([]); + setCollapsedLaneKeys(new Set()); + setCollapsedShopKeys(new Set()); + setCollapsedDoKeys(new Set()); }, []); const handleSubmit = useCallback(async () => { if (inFlightRef.current) return; if (draftRows.length === 0) { - await Swal.fire({ icon: "warning", title: t("No draft rows to submit") }); + await fireReplenishmentAlert(t,{ icon: "warning", title: t("No draft rows to submit") }); return; } inFlightRef.current = true; @@ -423,10 +678,11 @@ const DoReplenishmentTab: React.FC = () => { sourceDoLineId: row.sourceDoLineId, replenishQty: row.replenishQty, truckLaneCode: row.truckLaneCode, + reason: row.reason?.trim() || undefined, })), ); setDraftRows([]); - await Swal.fire({ + await fireReplenishmentAlert(t,{ icon: "success", title: t("Replenishment submitted successfully"), text: created.map((row) => row.code).join(", "), @@ -434,7 +690,7 @@ const DoReplenishmentTab: React.FC = () => { } catch (error: unknown) { const message = error instanceof Error ? error.message : t("Failed to submit replenishment"); - await Swal.fire({ icon: "error", title: message }); + await fireReplenishmentAlert(t,{ icon: "error", title: message }); } finally { setIsSubmitting(false); inFlightRef.current = false; @@ -449,8 +705,9 @@ const DoReplenishmentTab: React.FC = () => { status: trackStatusFilter, }); setRecords(data.map(mapApiRecord)); + setTrackPage(0); } catch { - await Swal.fire({ icon: "error", title: t("Failed to load replenishment records") }); + await fireReplenishmentAlert(t,{ icon: "error", title: t("Failed to load replenishment records") }); } finally { setIsLoadingTracking(false); } @@ -462,57 +719,12 @@ const DoReplenishmentTab: React.FC = () => { } }, [trackingDialogOpen, loadTrackingRecords]); - const trackColumns: GridColDef[] = useMemo( - () => [ - { field: "code", headerName: t("Replenishment Code"), width: 140 }, - { field: "sourceDoCode", headerName: t("Source DO"), width: 120 }, - { field: "shopName", headerName: t("Shop Name"), flex: 1, minWidth: 120 }, - { - field: "truckLaneCode", - headerName: t("Truck Lance Code"), - width: 120, - valueGetter: (params) => params.row.truckLaneCode ?? "—", - }, - { field: "itemNo", headerName: t("Item No."), width: 100 }, - { field: "itemName", headerName: t("Item Name"), flex: 1, minWidth: 120 }, - { - field: "replenishQty", - headerName: t("Replenish Qty"), - width: 120, - valueGetter: (params) => { - const row = params.row as ReplenishmentRecord; - return row.shortUom ? `${row.replenishQty} ${row.shortUom}` : row.replenishQty; - }, - }, - { - field: "targetDoCode", - headerName: t("Target DO"), - width: 120, - valueGetter: (params) => params.row.targetDoCode ?? "—", - }, - { - field: "status", - headerName: t("Status"), - width: 110, - valueFormatter: (params) => t(String(params.value)), - }, - { - field: "created", - headerName: t("Created"), - width: 160, - valueFormatter: (params) => - params.value ? dayjs(String(params.value)).format("YYYY-MM-DD HH:mm") : "", - }, - ], - [t], - ); - const selectedLineUom = lineUomDisplay(selectedLine); - const sourceTruckLaneDisplay = sourceDo - ? sourceDo.truckLaneCode?.trim() - ? sourceDo.truckLaneCode - : t("Truck X") - : ""; + + const paginatedTrackRecords = useMemo(() => { + const start = trackPage * trackRowsPerPage; + return records.slice(start, start + trackRowsPerPage); + }, [records, trackPage, trackRowsPerPage]); const datePickerSlotProps = useMemo( () => ({ @@ -528,8 +740,8 @@ const DoReplenishmentTab: React.FC = () => { [t], ); - const groupedDraftRows = useMemo( - () => groupDraftRowsByShopAndDo(draftRows), + const groupedDraftByLane = useMemo( + () => groupDraftRowsByLaneShopAndDo(draftRows), [draftRows], ); @@ -593,12 +805,13 @@ const DoReplenishmentTab: React.FC = () => { "& > *": { flexShrink: 0 }, }} > - {groupedDraftRows.map((shopGroup) => { - const shopDisplay = - shopGroup.shopCode || shopGroup.shopName?.trim() || shopGroup.shopKey; + {groupedDraftByLane.map((laneGroup) => { + const laneDisplay = laneGroup.truckLaneCode || t("Truck X"); + const laneCollapseKey = draftLaneCollapseKey(laneGroup.laneKey); + const laneCollapsed = collapsedLaneKeys.has(laneCollapseKey); return ( { theme.palette.mode === "dark" ? "grey.800" : "grey.50", }} > - - {t("Shop Code")} - - - - {shopDisplay} - - + toggleCollapsedKey(setCollapsedLaneKeys, laneCollapseKey)} + label={t("Truck Lane")} + title={laneDisplay} + titleTooltip={laneDisplay} + /> - - {shopGroup.dos.map((doGroup) => ( - ({ - border: `1px solid ${theme.palette.divider}`, - borderRadius: 1.5, - p: 1.25, - bgcolor: - theme.palette.mode === "dark" ? "grey.900" : "common.white", - })} - > - - {t("Delivery Order Code")} - - - - {doGroup.sourceDoCode} - - - - - {doGroup.rows.map((row) => { - const qtyLabel = row.shortUom - ? `${row.replenishQty} ${row.shortUom}` - : String(row.replenishQty); - return ( + + + {laneGroup.shops.map((shopGroup) => { + const shopDisplay = formatDraftShopDisplay(shopGroup); + const shopCode = shopGroup.shopCode?.trim(); + const shopCollapseKey = draftShopCollapseKey( + laneGroup.laneKey, + shopGroup.shopKey, + ); + const shopCollapsed = collapsedShopKeys.has(shopCollapseKey); + return ( + + theme.palette.mode === "dark" ? "grey.900" : "common.white", + }} + > + + toggleCollapsedKey(setCollapsedShopKeys, shopCollapseKey) + } + label={t("Shop Name")} + title={shopDisplay} + titleTooltip={ + shopCode && shopGroup.shopName?.trim() ? shopCode : undefined + } + /> + + + + {shopGroup.dos.map((doGroup) => { + const doCollapseKey = draftDoCollapseKey( + laneGroup.laneKey, + shopGroup.shopKey, + doGroup.sourceDoId, + ); + const doCollapsed = collapsedDoKeys.has(doCollapseKey); + return ( ({ - position: "relative", - pr: 4, - py: 0.75, - px: 1, - borderRadius: 1, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1.5, + p: 1.25, bgcolor: - theme.palette.mode === "dark" - ? "grey.800" - : "grey.50", + theme.palette.mode === "dark" ? "grey.800" : "grey.50", })} > - handleRemoveDraftRow(row.rowId)} - aria-label={t("Delete")} - sx={{ position: "absolute", top: 2, right: 2 }} - > - - - - - - {row.itemNo} - - - {row.itemName} - - + + toggleCollapsedKey(setCollapsedDoKeys, doCollapseKey) + } + label={t("Delivery Order Code")} + title={doGroup.sourceDoCode} + titleVariant="body2" + titleTooltip={doGroup.sourceDoCode} + /> + + + + {doGroup.rows.map((row) => { + const qtyLabel = row.shortUom + ? `${row.replenishQty} ${row.shortUom}` + : String(row.replenishQty); + return ( + ({ + display: "flex", + alignItems: "flex-start", + gap: 1, + py: 0.75, + px: 1, + borderRadius: 1, + bgcolor: + theme.palette.mode === "dark" + ? "grey.900" + : "common.white", + })} + > + + + + {row.itemNo} + + + {row.itemName} + + - - - {t("Replenish Qty")}:{" "} - - {qtyLabel} - + + + {t("Replenish Qty")}:{" "} + + {qtyLabel} + + {row.reason?.trim() ? ( + + + {t("Remark")}:{" "} + + {formatReplenishmentReason(row.reason, t)} + + ) : null} + + + handleRemoveDraftRow(row.rowId)} + aria-label={t("Delete")} + sx={{ flexShrink: 0, mt: -0.25 }} + > + + + + ); + })} + + - ); - })} - - - ))} - + ); + })} + + + + ); + })} + + ); })} @@ -732,10 +998,20 @@ const DoReplenishmentTab: React.FC = () => { {draftRows.length > 0 && ( - - @@ -748,7 +1024,7 @@ const DoReplenishmentTab: React.FC = () => { { (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), }} > - - setTrackingDialogOpen(true)} - aria-label={t("Replenishment Tracking")} + + + - - - - - - } - title={t("Estimated Arrival Date")} - required - sx={replenishmentSearchGridLabelSx(1)} - /> - - - { - setDeliveryDate(v); + } + title={t("Estimated Arrival Date")} + required + > + + { + setDeliveryDate(v); + setSourceDo(null); + }} + slotProps={datePickerSlotProps} + sx={{ width: "100%" }} + /> + + + + } + title={t("DO Code Last 4")} + required + > + { + setDoCodeSuffix(e.target.value.slice(0, 4)); setSourceDo(null); }} - slotProps={datePickerSlotProps} - sx={{ width: "100%" }} + placeholder={t("replenishmentDoSuffixPlaceholder")} + inputProps={{ maxLength: 4 }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleLookupSourceDo(); + } + }} /> - - + - } - title={t("DO Code Last 4")} - required - sx={replenishmentSearchGridLabelSx(2)} - /> - - { - setDoCodeSuffix(e.target.value.slice(0, 4)); - setSourceDo(null); - }} - placeholder={t("replenishmentDoSuffixPlaceholder")} - inputProps={{ maxLength: 4 }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleLookupSourceDo(); - } + + > + } + title={t("Shop Code")} + required + > + { + setShopInput(e.target.value); + setSourceDo(null); + }} + placeholder={t("replenishmentShopPlaceholder")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleLookupSourceDo(); + } + }} + /> + + - } - title={t("Shop Code")} - required - sx={replenishmentSearchGridLabelSx(3)} - /> - - { - setShopInput(e.target.value); - setSourceDo(null); - }} - placeholder={t("replenishmentShopPlaceholder")} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleLookupSourceDo(); - } - }} - /> + - - + + + {sourceDo && ( - - {t("Delivery Order Code")}: {sourceDo.doCode} - {" "} - {t("Shop Name")}: {sourceDo.shopName ?? "—"} - {" "} - {t("Truck Lance Code")}:{" "} - {sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")} - + + + {t("Delivery Order Code")}: {sourceDo.doCode} + {" "} + {t("Shop Name")}: {sourceDo.shopName ?? "—"} + + + {t("Truck Lane")}:{" "} + {sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")} + + )} {sourceDo && ( - ({ border: `1px solid ${theme.palette.divider}`, borderRadius: 2, + p: 1.5, bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white", })} > - - - - - {t("Replenishment item code")} - - {t("Item Name")} - - {t("Original Shipment Qty")} - - - {t("Replenish Qty")} - - - {t("uom")} - - - {t("Truck Lance Code")} - - - {t("Action")} - - - - - {currentDoDraftRows.map((row) => ( - ({ - bgcolor: - theme.palette.mode === "dark" - ? "action.selected" - : "action.hover", - })} - > - {row.itemNo} - {row.itemName} - {row.originalQty} - {row.replenishQty} - {row.shortUom || "—"} - - - - {row.truckLaneCode?.trim() || - sourceDo.truckLaneCode?.trim() || - t("Truck X")} - - - - - - handleRemoveDraftRow(row.rowId)} - aria-label={t("Delete")} - > - - - - - - ))} - - - setSelectedLine(newValue)} - getOptionLabel={(line) => line.itemNo ?? ""} - isOptionEqualToValue={(a, b) => a.id === b.id} - filterOptions={(options, { inputValue }) => - filterSourceDoLines(options, inputValue) + + + } + title={t("Replenishment item code")} + required + > + setSelectedLine(newValue)} + onInputChange={(_, inputValue, reason) => { + if (reason === "clear") { + setSelectedLine(null); + return; } - renderInput={(params) => { - const { inputProps } = params; - return ( - { - itemCodeInputRef.current = node; - const { ref } = inputProps; - if (typeof ref === "function") ref(node); - else if (ref) { - ( - ref as React.MutableRefObject - ).current = node; - } - }, - }} - InputProps={{ ...params.InputProps, disableUnderline: true }} - /> - ); - }} - sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX} - /> - - - setSelectedLine(newValue)} - getOptionLabel={(line) => line.itemName ?? line.itemNo ?? ""} - isOptionEqualToValue={(a, b) => a.id === b.id} - filterOptions={(options, { inputValue }) => - filterSourceDoLines(options, inputValue) + if (reason === "input" && selectedLine) { + const selectedCode = (selectedLine.itemNo ?? "").trim(); + if (inputValue.trim() !== selectedCode) { + setSelectedLine(null); + } } - renderInput={(params) => ( + }} + getOptionLabel={(line) => line.itemNo ?? ""} + isOptionEqualToValue={(a, b) => a.id === b.id} + filterOptions={(options, { inputValue }) => + filterSourceDoLinesByItemNo(options, inputValue) + } + renderInput={(params) => { + const { inputProps } = params; + return ( { + itemCodeInputRef.current = node; + const { ref } = inputProps; + if (typeof ref === "function") ref(node); + else if (ref) { + ( + ref as React.MutableRefObject + ).current = node; + } + }, + }} InputProps={{ ...params.InputProps, disableUnderline: true }} /> - )} - sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX} - /> - - - - - - + ); + }} + sx={REPLENISHMENT_AUTOCOMPLETE_SX} + /> + + + + setSelectedLine(newValue)} + onInputChange={(_, inputValue, reason) => { + if (reason === "clear") { + setSelectedLine(null); + return; + } + if (reason === "input" && selectedLine) { + const selectedName = sourceDoLineNameLabel(selectedLine); + if (inputValue.trim() !== selectedName) { + setSelectedLine(null); + } + } + }} + getOptionLabel={sourceDoLineNameLabel} + isOptionEqualToValue={(a, b) => a.id === b.id} + filterOptions={(options, { inputValue }) => + filterSourceDoLinesByItemName(options, inputValue) + } + renderInput={(params) => ( setReplenishQtyInput(e.target.value)} - inputProps={{ min: 0, step: "any", style: { textAlign: "right" } }} - sx={(theme) => ({ - ...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme), - width: 72, - "& .MuiFilledInput-input": { - textAlign: "right", - }, - })} + {...params} + placeholder={t("Replenishment item name")} + InputProps={{ ...params.InputProps, disableUnderline: true }} /> - - - - - - + + + + + + + {selectedLine + ? `${resolveOriginalShipmentQty(selectedLine)}${selectedLineUom ? ` ${selectedLineUom}` : ""}` + : null} + + + + + setReplenishQtyInput(e.target.value)} + placeholder={t("Replenish Qty")} + disabled={!selectedLine} + inputProps={{ min: 0, step: "any" }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddDraftRow(); + } }} + /> + + + + - - theme.spacing(5), - lineHeight: (theme) => theme.spacing(5), - }} - > - {sourceTruckLaneDisplay} - - - - - - - - - - -
-
+ + + + + + + + +
+ + + + {currentDoDraftRows.length > 0 ? ( + + {t("replenishmentCurrentDoDraftHint", { count: currentDoDraftRows.length })} + + ) : null} )} - + {draftPreviewPanel} - {draftPreviewPanel} + {draftPreviewPanel} { - - - - + (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), + }} + > + + + } + title={t("replenishmentTargetDoEstimatedArrivalDate")} + > setTrackDateFilter(v)} + onChange={(v) => { + setTrackDateFilter(v); + setTrackPage(0); + }} slotProps={datePickerSlotProps} + sx={{ width: "100%" }} /> - - - {t("Status")} - - - - - + + + + + + ({ + borderRadius: 2, + bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white", + opacity: isLoadingTracking ? 0.6 : 1, + pointerEvents: isLoadingTracking ? "none" : "auto", + display: "flex", + flexDirection: "column", + maxHeight: "65vh", + })} + > + *": { flexShrink: 0 }, + }} + > + {paginatedTrackRecords.length === 0 ? ( + + {isLoadingTracking ? t("Loading") : t("No data")} + + ) : ( + paginatedTrackRecords.map((row) => ( + ({ + p: 2, + borderRadius: 1.5, + flexShrink: 0, + overflow: "visible", + bgcolor: + theme.palette.mode === "dark" ? "grey.800" : "grey.50", + })} + > + + + + + + + + + + + + {formatQtyWithUom(row.originalQty, row.shortUom)} + + + {formatQtyWithUom(row.replenishQty, row.shortUom)} + + + {formatReplenishmentReason(row.reason, t)} + + + + + + + + + + + )) + )} + + setTrackPage(page)} + rowsPerPage={trackRowsPerPage} + onRowsPerPageChange={(e) => { + setTrackRowsPerPage(Number(e.target.value)); + setTrackPage(0); + }} + rowsPerPageOptions={[10, 25, 50]} + labelRowsPerPage={t("Rows per page")} + sx={{ flexShrink: 0, borderTop: 1, borderColor: "divider" }} + /> + + diff --git a/src/components/DoSearch/ReplenishmentFilterField.tsx b/src/components/DoSearch/ReplenishmentFilterField.tsx index d8bc265..2173010 100644 --- a/src/components/DoSearch/ReplenishmentFilterField.tsx +++ b/src/components/DoSearch/ReplenishmentFilterField.tsx @@ -11,7 +11,7 @@ export const REPLENISHMENT_FIELD_LABEL_SX = (theme: Theme) => ({ theme.palette.mode === "dark" ? theme.palette.grey[100] : theme.palette.common.black, - fontWeight: 600, + fontWeight: 700, }); export const REPLENISHMENT_FIELD_ICON_SX = (theme: Theme) => ({ @@ -28,6 +28,7 @@ export const REPLENISHMENT_TEXTFIELD_SX = (theme: Theme) => borderRadius: 2, bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", border: `1px solid ${theme.palette.divider}`, + ...REPLENISHMENT_FIELD_CONTROL_ROOT_SX, }, "& .MuiFilledInput-root.Mui-focused": { bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", @@ -56,6 +57,7 @@ export const REPLENISHMENT_AUTOCOMPLETE_SX = (theme: Theme) => width: "100%", }, "& .MuiAutocomplete-inputRoot": { + ...REPLENISHMENT_FIELD_CONTROL_ROOT_SX, paddingTop: `${REPLENISHMENT_FIELD_BODY_PY} !important`, paddingBottom: `${REPLENISHMENT_FIELD_BODY_PY} !important`, paddingLeft: `${theme.spacing(REPLENISHMENT_FIELD_BODY_PX)} !important`, @@ -75,6 +77,75 @@ export const REPLENISHMENT_FIELD_BODY_PY = "12px"; /** Horizontal padding aligned with MUI filled input (spacing 1.5 = 12px). */ export const REPLENISHMENT_FIELD_BODY_PX = 1.5; +/** Fixed height for replenishment inputs, selects, and read-only value boxes. */ +export const REPLENISHMENT_FIELD_CONTROL_HEIGHT = 44; + +export const REPLENISHMENT_FIELD_CONTROL_ROOT_SX = { + height: REPLENISHMENT_FIELD_CONTROL_HEIGHT, + minHeight: REPLENISHMENT_FIELD_CONTROL_HEIGHT, + maxHeight: REPLENISHMENT_FIELD_CONTROL_HEIGHT, + boxSizing: "border-box" as const, +}; + +/** Read-only value box — same outer height as {@link ReplenishmentTextField}. */ +export const REPLENISHMENT_READONLY_VALUE_SX = (theme: Theme) => + ({ + ...REPLENISHMENT_FIELD_CONTROL_ROOT_SX, + borderRadius: 2, + border: `1px solid ${theme.palette.divider}`, + bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", + px: REPLENISHMENT_FIELD_BODY_PX, + display: "flex", + alignItems: "center", + minWidth: 0, + overflow: "hidden", + }) as const; + +export function ReplenishmentReadonlyValue({ + children, + fontWeight, +}: { + children: React.ReactNode; + fontWeight?: number; +}) { + return ( + + + {children ?? "\u00A0"} + + + ); +} + +/** Invisible label spacer so action buttons align with labelled fields. */ +export function ReplenishmentFieldLabelSpacer() { + return ( + + {"\u00A0"} + + ); +} + /** Source DO summary header: same inset as textbox content area. */ export const REPLENISHMENT_SOURCE_HEADER_SX = (theme: Theme) => ({ @@ -89,7 +160,7 @@ export const REPLENISHMENT_SOURCE_HEADER_SX = (theme: Theme) => }) as const; type ReplenishmentFieldLabelProps = { - icon: ReactNode; + icon?: ReactNode; title: string; required?: boolean; sx?: SxProps; @@ -102,14 +173,22 @@ export function ReplenishmentFieldLabel({ sx, }: ReplenishmentFieldLabelProps) { return ( - - {icon} - + + {icon ?? null} + ({ + ...REPLENISHMENT_FIELD_LABEL_SX(theme), + whiteSpace: "normal", + lineHeight: 1.35, + })} + component="span" + > {title} {required ? ( - + ) : null} @@ -143,16 +222,33 @@ export function ReplenishmentTextField(props: ReplenishmentTextFieldProps) { size="small" fullWidth variant="filled" - sx={(theme) => ({ - ...REPLENISHMENT_TEXTFIELD_SX(theme), - ...(typeof sx === "function" ? sx(theme) : sx), - })} + sx={[REPLENISHMENT_TEXTFIELD_SX, sx] as SxProps} InputProps={{ disableUnderline: true, ...InputProps }} {...rest} /> ); } +/** Filled select matching {@link ReplenishmentTextField} border and padding. */ +export const REPLENISHMENT_FILLED_SELECT_SX = (theme: Theme) => + ({ + ...REPLENISHMENT_TEXTFIELD_SX(theme), + "& .MuiFilledInput-root": { + alignItems: "center", + borderRadius: 2, + bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", + border: `1px solid ${theme.palette.divider}`, + "&::before, &::after": { display: "none" }, + ...REPLENISHMENT_FIELD_CONTROL_ROOT_SX, + }, + "& .MuiSelect-select": { + paddingTop: REPLENISHMENT_FIELD_BODY_PY, + paddingBottom: REPLENISHMENT_FIELD_BODY_PY, + display: "flex", + alignItems: "center", + }, + }) as const; + /** Read-only item row value — blank until a line is selected. */ export function ReplenishmentItemEntryPlainText({ value, @@ -170,14 +266,18 @@ export function ReplenishmentItemEntryPlainText({ return ( ({ - display: "block", - color: theme.palette.text.primary, - wordBreak: "break-word", - minWidth: 0, - minHeight: reserveSpace ? theme.spacing(5) : undefined, - ...(typeof sx === "function" ? sx(theme) : sx), - })} + sx={ + [ + (theme) => ({ + display: "block", + color: theme.palette.text.primary, + wordBreak: "break-word", + minWidth: 0, + minHeight: reserveSpace ? theme.spacing(5) : undefined, + }), + sx, + ] as SxProps + } > {isEmpty ? "\u00A0" : value} @@ -225,17 +325,57 @@ export function ReplenishmentQtyWithUomField({ ); } +/** Tracking dialog table — horizontal scroll, no fixed layout (avoids column text stacking). */ +export const REPLENISHMENT_TRACKING_TABLE_SX = { + width: "max-content", + minWidth: "100%", + "& .MuiTableCell-root": { + typography: "body2", + borderColor: "divider", + py: 1, + px: 1.25, + whiteSpace: "nowrap", + }, + "& .MuiTableCell-root:first-of-type": { + pl: 1.5, + }, + "& .MuiTableHead-root .MuiTableCell-root": { + fontWeight: 600, + color: "text.secondary", + bgcolor: "action.hover", + borderBottom: "1px solid", + borderColor: "divider", + }, + "& .MuiTableBody-root .MuiTableRow-root:not(:last-of-type) .MuiTableCell-root": { + borderBottom: "1px solid", + borderColor: "divider", + }, +} as const; + +export const REPLENISHMENT_TRACKING_CELL_ELLIPSIS_SX = { + maxWidth: 160, + overflow: "hidden", + textOverflow: "ellipsis", +} as const; + +export const REPLENISHMENT_TRACKING_CELL_WRAP_SX = { + minWidth: 120, + maxWidth: 200, + whiteSpace: "normal", + wordBreak: "break-word", +} as const; + export const REPLENISHMENT_TABLE_SX = { - tableLayout: { md: "fixed" }, width: "100%", + tableLayout: "fixed", "& .MuiTableCell-root": { typography: "body2", borderColor: "divider", - py: 1.25, - px: 2, + py: 1, + px: 1.25, }, "& .MuiTableCell-root:first-of-type": { - pl: 3.5, + pl: 1.5, }, "& .MuiTableHead-root .MuiTableCell-root": { fontWeight: 600, @@ -361,10 +501,27 @@ export const REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX = { width: "100%", } as const; +/** In-table select — compact padding; truncate long selected labels. */ +export const REPLENISHMENT_TABLE_INLINE_SELECT_SX = (theme: Theme) => + ({ + ...REPLENISHMENT_FILLED_SELECT_SX(theme), + "& .MuiSelect-select": { + paddingTop: "6px", + paddingBottom: "6px", + paddingLeft: theme.spacing(1), + paddingRight: `${theme.spacing(3)} !important`, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + }) as const; + export const replenishmentSearchGridLabelSx = (col: number) => ({ gridColumn: { xs: 1, lg: col }, gridRow: { xs: "auto", lg: 1 }, - minWidth: 0, + minWidth: "min-content", + overflow: "hidden", + textOverflow: "ellipsis", }); export const replenishmentSearchGridInputSx = (col: number) => ({ @@ -378,53 +535,74 @@ export const replenishmentSearchGridInputSx = (col: number) => ({ }, }); -/** Shop input + lookup button share one row; button height follows the textbox. */ -export const replenishmentSearchGridShopRowSx = { - gridColumn: { xs: 1, lg: 3 }, +/** Lookup / tracking buttons beside the three filter inputs (4th grid column on lg). */ +export const replenishmentSearchGridActionsSx = { + gridColumn: { xs: 1, lg: 4 }, gridRow: { xs: "auto", lg: 2 }, - minWidth: 0, display: "flex", + justifyContent: { xs: "stretch", lg: "flex-start" }, alignItems: "stretch", - gap: 1, - "& .MuiTextField-root": { - flex: 1, - minWidth: 0, - }, - "& .MuiFormControl-root": { - height: "100%", - }, - "& .MuiFilledInput-root": { - height: "100%", - boxSizing: "border-box", + flexWrap: { xs: "wrap", lg: "nowrap" }, + gap: 1.5, + minWidth: 0, + "& .MuiButton-root": { + flex: { xs: 1, lg: "0 0 auto" }, + alignSelf: "stretch", }, }; -/** Match {@link ReplenishmentFieldLabel} typography on contained buttons. */ +/** Match {@link ReplenishmentFieldLabel} typography on field-height buttons. */ export const REPLENISHMENT_LOOKUP_BUTTON_TEXT_SX = (theme: Theme) => ({ fontSize: theme.typography.body2.fontSize, fontWeight: 600, lineHeight: 1, }); -export const REPLENISHMENT_LOOKUP_BUTTON_SX = (theme: Theme) => ({ - ...REPLENISHMENT_LOOKUP_BUTTON_TEXT_SX(theme), - alignSelf: "stretch", - minHeight: "unset", - height: "auto", - py: 0, - px: 1.5, - borderRadius: 2, - boxShadow: "none", - textTransform: "none", - whiteSpace: "nowrap", - flexShrink: 0, - minWidth: { xs: "100%", lg: 108 }, - "& .MuiButton-startIcon": { - margin: 0, - marginRight: theme.spacing(0.75), - "& > *:nth-of-type(1)": { - fontSize: 20, +/** Base button style — same 44px height as {@link ReplenishmentTextField}. */ +export const REPLENISHMENT_FIELD_BUTTON_SX = (theme: Theme) => + ({ + ...REPLENISHMENT_LOOKUP_BUTTON_TEXT_SX(theme), + ...REPLENISHMENT_FIELD_CONTROL_ROOT_SX, + paddingTop: 0, + paddingBottom: 0, + px: REPLENISHMENT_FIELD_BODY_PX, + borderRadius: 2, + boxShadow: "none", + textTransform: "none", + whiteSpace: "nowrap", + flexShrink: 0, + "&.MuiButton-root": { + ...REPLENISHMENT_FIELD_CONTROL_ROOT_SX, }, - }, -}); + "& .MuiButton-startIcon": { + margin: 0, + marginRight: theme.spacing(0.75), + "& > *:nth-of-type(1)": { + fontSize: 18, + }, + }, + }) as const; + +export const REPLENISHMENT_LOOKUP_BUTTON_SX = (theme: Theme) => + ({ + ...REPLENISHMENT_FIELD_BUTTON_SX(theme), + alignSelf: "stretch", + px: REPLENISHMENT_FIELD_BODY_PX, + minWidth: { xs: "auto", lg: 108 }, + }) as const; + +/** Outlined companion button (e.g. replenishment tracking) beside lookup. */ +export const REPLENISHMENT_OUTLINED_ACTION_BUTTON_SX = (theme: Theme) => + ({ + ...REPLENISHMENT_FIELD_BUTTON_SX(theme), + minWidth: "auto", + px: REPLENISHMENT_FIELD_BODY_PX, + borderColor: theme.palette.divider, + color: theme.palette.text.primary, + bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", + "&:hover": { + borderColor: theme.palette.primary.main, + bgcolor: theme.palette.mode === "dark" ? "grey.700" : "grey.50", + }, + }) as const; diff --git a/src/components/DoSearch/batchReleaseReplenishmentHtml.ts b/src/components/DoSearch/batchReleaseReplenishmentHtml.ts index 2008c81..303e4bd 100644 --- a/src/components/DoSearch/batchReleaseReplenishmentHtml.ts +++ b/src/components/DoSearch/batchReleaseReplenishmentHtml.ts @@ -61,16 +61,16 @@ export function deriveReplenishmentFetchParams( }; } - const shopTokens = [ - ...new Set(dosForRelease.map(shopTokenFromDoRow).filter(Boolean)), - ]; - const trucks = [ - ...new Set( + const shopTokens = Array.from( + new Set(dosForRelease.map(shopTokenFromDoRow).filter(Boolean)), + ); + const trucks = Array.from( + new Set( dosForRelease .map((row) => row.truckLanceCode?.trim()) .filter((value): value is string => Boolean(value)), ), - ]; + ); if (shopTokens.length === 1 && trucks.length === 1) { return { shopName: shopTokens[0], truckLaneCode: trucks[0] }; diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index c3fe0c5..f917936 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -290,5 +290,27 @@ export const REPORTS: ReportDefinition[] = [ dynamicOptionsParam: "stockCategory", options: [] }, ] - } + }, + { + id: "rep-015", + title: "M18 BOM Shop 同步記錄", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history`, + responseType: "excel", + fields: [ + { label: "同步日期:由 Sync Date Start", name: "syncDateStart", type: "date", required: false }, + { label: "同步日期:至 Sync Date End", name: "syncDateEnd", type: "date", required: false }, + { label: "成品貨號 Finished Item Code", name: "finishedItemCode", type: "text", required: false }, + { + label: "同步狀態 Sync Status", + name: "syncStatus", + type: "select", + required: false, + options: [ + { label: "全部 All", value: "all" }, + { label: "成功 Success", value: "success" }, + { label: "失敗 Failed", value: "failed" }, + ], + }, + ], + }, ] \ No newline at end of file diff --git a/src/i18n/en/do.json b/src/i18n/en/do.json index c23c21a..b85d647 100644 --- a/src/i18n/en/do.json +++ b/src/i18n/en/do.json @@ -48,6 +48,8 @@ "Location": "Location", "Lot No.": "Lot No.", "No trucks available": "No trucks available", + "No data": "No data", + "Rows per page": "Rows per page", "Order Date": "Order Date", "Order Date From": "Order Date From", "Order Date To": "Order Date To", @@ -71,27 +73,35 @@ "Delivery Date": "Delivery Date", "Enter last 4 characters of DO code": "Enter last 4 characters of DO code", "Shop code, or first characters of shop name": "Shop code (partial match), or first characters of shop name", - "Multiple source DOs matched": "Multiple source DOs matched", + "Multiple source DOs matched": "Multiple original DOs matched", "Please verify DO code suffix, delivery date and shop.": "Please verify DO code suffix, delivery date and shop.", "Shop code or name is required": "Shop code or name is required", "Draft List": "Draft List", - "Replenishment preview hint": "Add items from different source DOs, then batch submit from here.", - "Replenishment preview empty": "Added items appear here. Look up another source DO to keep adding.", + "Replenishment preview hint": "Add items from different original DOs, then batch submit from here.", + "Replenishment preview empty": "Added items appear here. Match another original DO to keep adding.", + "replenishmentCurrentDoDraftHint": "Added to draft list ({{count}} for this DO)", + "replenishmentTargetDoEstimatedArrivalDate": "Target DO Estimated Arrival Date", + "replenishmentOriginalSourceDoCode": "Original DO Code", + "replenishmentTargetDoCode": "Target DO Code", + "replenishmentStatusLabel": "Replenishment Status", + "replenishmentItemInfo": "Item Information", "Clear": "Clear", "Enter item code to search": "Enter item code to search", - "Failed to lookup source DO": "Failed to lookup source DO", + "Failed to lookup source DO": "Failed to match original DO", "Item": "Item", - "Lookup": "Lookup", + "Lookup": "Match DO", "No draft rows to submit": "No draft rows to submit", - "Only completed delivery orders can be used as replenishment source.": "Only completed delivery orders can be used as replenishment source.", + "Only completed delivery orders can be used as replenishment source.": "Only completed delivery orders can be used as the original DO.", "Original Shipment Qty": "Original Shipment Qty", - "Please lookup source DO first": "Please lookup source DO first", + "Original Shipment Qty short": "Orig. Qty", + "Please lookup source DO first": "Please match original DO first", "Picker Name": "Picker Name", "Please select an item": "Please select an item", "Records saved locally for preview. Backend integration pending.": "Records saved locally for preview. Backend integration pending.", "Replenishment Code": "Replenishment No.", "Ref Code": "Ref Code", "Replenish Qty": "Replenish Qty", + "Replenish Qty short": "Replenish", "Replenish qty must be greater than zero": "Replenish qty must be greater than zero", "Replenishment": "Replenishment", "Delivery date is required": "Delivery date is required", @@ -110,11 +120,18 @@ "replenishmentDatePlaceholder": "YYYY-MM-DD", "replenishmentDoSuffixPlaceholder": "DO No. (last 4)", "replenishmentShopPlaceholder": "Shop Code", - "Source DO": "Source DO", - "Source DO Code": "Source DO Code", - "Source DO code is required": "Source DO code is required", - "Source DO must be completed": "Source DO must be completed", - "Source DO not found": "Source DO not found", + "replenishmentRemarkPlaceholder": "Optional", + "replenishmentRemarkShort": "Optional", + "replenishmentReason": { + "quality_issue": "Quality issue", + "out_of_stock": "Out of stock", + "other": "Other" + }, + "Source DO": "Original DO", + "Source DO Code": "Original DO Code", + "Source DO code is required": "Original DO code is required", + "Source DO must be completed": "Original DO must be completed", + "Source DO not found": "Original DO not found", "Submit": "Submit", "Target DO": "Target DO", "This item is already in the draft list": "This item is already in the draft list", @@ -143,6 +160,7 @@ "Supplier Name": "Supplier Name", "Truck Availability Warning": "Truck Availability Warning", "Truck Lance Code": "Truck Lance Code", + "Truck Lane": "Truck Lane", "Truck X": "Truck X", "Truck lane search requires date message": "Truck lane search requires date message", "Truck lane search requires date title": "Truck lane search requires date title", diff --git a/src/i18n/zh/do.json b/src/i18n/zh/do.json index f078218..9d50d73 100644 --- a/src/i18n/zh/do.json +++ b/src/i18n/zh/do.json @@ -19,26 +19,34 @@ "Enter last 4 characters of DO code": "請輸入送貨單號末四位", "Enter item code to search": "輸入貨品編號搜尋", "Shop code, or first characters of shop name": "店鋪代碼(部分符合),或店鋪名稱開頭字元", - "Multiple source DOs matched": "找到多張符合的來源送貨單", + "Multiple source DOs matched": "找到多張符合的原送貨單", "Please verify DO code suffix, delivery date and shop.": "請核對送貨單號末四位、送貨日及店鋪資料。", "Shop code or name is required": "請輸入店鋪代碼或名稱", "Draft List": "待提交列表", - "Replenishment preview hint": "可從不同來源送貨單加入品項,在此批次提交。", - "Replenishment preview empty": "加入的品項會顯示於此;可再查詢其他來源送貨單繼續加入。", + "Replenishment preview hint": "可從不同原送貨單加入品項,在此批次提交。", + "Replenishment preview empty": "加入的品項會顯示於此;可再對單其他原送貨單繼續加入。", + "replenishmentCurrentDoDraftHint": "已加入待提交列表(此送貨單 {{count}} 項)", + "replenishmentTargetDoEstimatedArrivalDate": "目標送貨單預計送貨日期", + "replenishmentOriginalSourceDoCode": "原送貨單編號", + "replenishmentTargetDoCode": "目標送貨單編號", + "replenishmentStatusLabel": "補貨狀態", + "replenishmentItemInfo": "貨品資訊", "Clear": "清空", - "Failed to lookup source DO": "查詢來源送貨單失敗", + "Failed to lookup source DO": "原送貨單對單失敗", "Item": "物品", - "Lookup": "查詢", + "Lookup": "對單", "No draft rows to submit": "沒有待提交的行", - "Only completed delivery orders can be used as replenishment source.": "只有已送貨(completed)的送貨單可作為補貨來源。", + "Only completed delivery orders can be used as replenishment source.": "只有已送貨(completed)的送貨單可作為原送貨單。", "Original Shipment Qty": "原出貨數", - "Please lookup source DO first": "請先查詢來源送貨單", + "Original Shipment Qty short": "原出貨", + "Please lookup source DO first": "請先對單(原送貨單)", "Picker Name": "揀貨員名稱", "Please select an item": "請選擇物品", "Records saved locally for preview. Backend integration pending.": "記錄已暫存於本地預覽,後端 API 尚未就緒。", "Replenishment Code": "補貨編號", "Ref Code": "參考編號", "Replenish Qty": "補貨數量", + "Replenish Qty short": "補貨", "Replenish qty must be greater than zero": "補貨數量必須大於零", "Replenishment": "補貨", "Delivery date is required": "請選擇送貨日期", @@ -57,11 +65,18 @@ "replenishmentDatePlaceholder": "YYYY-MM-DD", "replenishmentDoSuffixPlaceholder": "送貨單號末四位", "replenishmentShopPlaceholder": "店鋪編號", - "Source DO": "來源送貨單", - "Source DO Code": "來源送貨單編號", - "Source DO code is required": "請輸入來源送貨單編號", - "Source DO must be completed": "來源送貨單須為已送貨狀態", - "Source DO not found": "找不到來源送貨單", + "replenishmentRemarkPlaceholder": "請選擇(選填)", + "replenishmentRemarkShort": "選填", + "replenishmentReason": { + "quality_issue": "質素問題", + "out_of_stock": "缺貨", + "other": "其他" + }, + "Source DO": "原送貨單", + "Source DO Code": "原送貨單編號", + "Source DO code is required": "請輸入原送貨單編號", + "Source DO must be completed": "原送貨單須為已送貨狀態", + "Source DO not found": "找不到原送貨單", "Submit": "提交", "Target DO": "目標送貨單", "This item is already in the draft list": "此物品已在待提交列表中", @@ -73,6 +88,8 @@ "Loading": "正在加載...", "No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection.": "沒有選擇送貨訂單進行批量放單。取消勾選您想排除的訂單,或重新搜索以重置選擇。", "No Records": "沒有找到記錄", + "No data": "沒有資料", + "Rows per page": "每頁數量", "OK": "確認", "Truck X": "車線-X", "Order Date From": "訂單日期", @@ -82,6 +99,7 @@ "Truck lane search requires date title": "需選擇預計送貨日期", "Truck lane search requires date message": "已填寫車線號碼時,請一併選擇預計送貨日期後再搜索。", "Truck Lance Code": "車線號碼", + "Truck Lane": "車線", "Select Remark": "選擇備註", "Confirm Assignment": "確認分配", "Submit Qty": "提交數量",