# Conflicts: # src/components/DoSearch/DoReplenishmentTab.tsxproduction
| @@ -32,6 +32,7 @@ const REPORT_ICON_MAP: Record<string, SvgIconComponent> = { | |||
| "rep-013": LocalShippingOutlinedIcon, | |||
| "rep-006": BarChartOutlinedIcon, | |||
| "rep-005": PieChartOutlineOutlinedIcon, | |||
| "rep-015": LayersOutlinedIcon, | |||
| }; | |||
| const reportById = Object.fromEntries(REPORTS.map((r) => [r.id, r])); | |||
| @@ -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<string, unknown> { | |||
| 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<string, unknown> { | |||
| return { | |||
| 同步時間: note, | |||
| 成品貨號: "", | |||
| "M18 BOM Code": "", | |||
| 版本: "", | |||
| 狀態: "", | |||
| 行號: "", | |||
| 物料名稱: "", | |||
| "M18 Product Id": "", | |||
| 單位: "", | |||
| 用量: "", | |||
| "M18 Supplier Id": "", | |||
| "M18 Purchase Unit Id": "", | |||
| "Sync Log Id": "", | |||
| }; | |||
| } | |||
| export async function fetchBomShopSyncReportData( | |||
| criteria: Record<string, string>, | |||
| ): Promise<BomShopSyncReportResponse> { | |||
| 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<string, unknown> { | |||
| 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<string, unknown> { | |||
| 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<string, string>, | |||
| reportTitle: string = "M18 BOM Shop 同步記錄", | |||
| ): Promise<void> { | |||
| 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, | |||
| ); | |||
| } | |||
| @@ -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 = | |||
| @@ -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"], | |||
| }, | |||
| ]; | |||
| @@ -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; | |||
| } | |||
| @@ -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 ( | |||
| <Box sx={REPLENISHMENT_READONLY_VALUE_SX}> | |||
| <Typography | |||
| variant="body2" | |||
| component="div" | |||
| fontWeight={fontWeight} | |||
| sx={{ | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| whiteSpace: "nowrap", | |||
| width: "100%", | |||
| minWidth: 0, | |||
| lineHeight: 1.43, | |||
| }} | |||
| > | |||
| {children ?? "\u00A0"} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| } | |||
| /** Invisible label spacer so action buttons align with labelled fields. */ | |||
| export function ReplenishmentFieldLabelSpacer() { | |||
| return ( | |||
| <Typography | |||
| variant="body2" | |||
| aria-hidden | |||
| sx={{ | |||
| visibility: "hidden", | |||
| lineHeight: 1.35, | |||
| userSelect: "none", | |||
| }} | |||
| > | |||
| {"\u00A0"} | |||
| </Typography> | |||
| ); | |||
| } | |||
| /** 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<Theme>; | |||
| @@ -102,14 +173,22 @@ export function ReplenishmentFieldLabel({ | |||
| sx, | |||
| }: ReplenishmentFieldLabelProps) { | |||
| return ( | |||
| <Stack direction="row" spacing={1} alignItems="center" sx={sx}> | |||
| {icon} | |||
| <Typography variant="body2" sx={REPLENISHMENT_FIELD_LABEL_SX} component="span"> | |||
| <Stack direction="row" spacing={icon ? 1 : 0} alignItems="center" sx={sx}> | |||
| {icon ?? null} | |||
| <Typography | |||
| variant="body2" | |||
| sx={(theme) => ({ | |||
| ...REPLENISHMENT_FIELD_LABEL_SX(theme), | |||
| whiteSpace: "normal", | |||
| lineHeight: 1.35, | |||
| })} | |||
| component="span" | |||
| > | |||
| {title} | |||
| {required ? ( | |||
| <Typography component="span" color="error.main" aria-hidden="true"> | |||
| <Box component="span" color="error.main" aria-hidden="true"> | |||
| {" *"} | |||
| </Typography> | |||
| </Box> | |||
| ) : null} | |||
| </Typography> | |||
| </Stack> | |||
| @@ -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<Theme>} | |||
| 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 ( | |||
| <Box | |||
| component="span" | |||
| sx={(theme) => ({ | |||
| 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<Theme> | |||
| } | |||
| > | |||
| {isEmpty ? "\u00A0" : value} | |||
| </Box> | |||
| @@ -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; | |||
| @@ -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] }; | |||
| @@ -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" }, | |||
| ], | |||
| }, | |||
| ], | |||
| }, | |||
| ] | |||
| @@ -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", | |||
| @@ -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": "提交數量", | |||