소스 검색

Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production

production
CANCERYS\kw093 1 주 전
부모
커밋
9b113dd98f
21개의 변경된 파일1709개의 추가작업 그리고 144개의 파일을 삭제
  1. +14
    -1
      src/app/(main)/report/layout.tsx
  2. +3
    -0
      src/app/api/do/actions.tsx
  3. +12
    -0
      src/app/api/shop/actions.ts
  4. +8
    -0
      src/app/api/shop/client.ts
  5. +2
    -2
      src/authorities.ts
  6. +785
    -0
      src/components/DoSearch/DoReplenishmentTab.tsx
  7. +42
    -32
      src/components/DoSearch/DoSearch.tsx
  8. +430
    -0
      src/components/DoSearch/ReplenishmentFilterField.tsx
  9. +9
    -2
      src/components/NavigationContent/NavigationContent.tsx
  10. +36
    -21
      src/components/Shop/RouteBoard.tsx
  11. +226
    -33
      src/components/Shop/ScheduleTaskHistoryModal.tsx
  12. +3
    -3
      src/components/Shop/ShopDetail.tsx
  13. +1
    -1
      src/components/Shop/TruckLaneDetail.tsx
  14. +46
    -1
      src/i18n/en/do.json
  15. +2
    -0
      src/i18n/en/pickOrder.json
  16. +0
    -4
      src/i18n/en/routeboard.json
  17. +5
    -3
      src/i18n/en/shop.json
  18. +46
    -2
      src/i18n/zh/do.json
  19. +2
    -0
      src/i18n/zh/pickOrder.json
  20. +17
    -21
      src/i18n/zh/routeboard.json
  21. +20
    -18
      src/i18n/zh/shop.json

+ 14
- 1
src/app/(main)/report/layout.tsx 파일 보기

@@ -1,10 +1,23 @@
import { I18nProvider } from "@/i18n";
import { authOptions } from "@/config/authConfig";
import { AUTH } from "@/authorities";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default function ReportLayout({
export default async function ReportLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
const canAccess =
abilities.includes(AUTH.REPORT_MGMT) || abilities.includes(AUTH.ADMIN);

if (!canAccess) {
redirect("/dashboard");
}

return (
<I18nProvider namespaces={["report", "navigation", "common"]}>
{children}


+ 3
- 0
src/app/api/do/actions.tsx 파일 보기

@@ -27,6 +27,8 @@ export interface DoDetail {
status: string;
/** 加單 DO */
isExtra?: boolean;
/** 揀貨員名稱(delivery_order_pick_order.handlerName) */
handlerName?: string | null;
deliveryOrderLines: DoDetailLine[];
}

@@ -56,6 +58,7 @@ export interface DoSearchAll {
shopName: string;
shopAddress?: string;
isExtra?: boolean;
truckLanceCode?: string | null;
}
export interface DoSearchLiteResponse {
records: DoSearchAll[];


+ 12
- 0
src/app/api/shop/actions.ts 파일 보기

@@ -855,6 +855,18 @@ export const retryFailedTruckLaneScheduleAction = async (
});
};

export const reactivateCancelledTruckLaneScheduleAction = async (
id: number,
body?: RetryFailedTruckLaneScheduleRequest,
): Promise<TruckLaneScheduleResponse> => {
const endpoint = `${BASE_API_URL}/truckLaneSchedule/${id}/reactivate`;
return serverFetchJson<TruckLaneScheduleResponse>(endpoint, {
method: "POST",
body: JSON.stringify(body ?? {}),
headers: { "Content-Type": "application/json" },
});
};

export const ignoreTruckLaneScheduleAction = async (
id: number,
): Promise<TruckLaneScheduleResponse> => {


+ 8
- 0
src/app/api/shop/client.ts 파일 보기

@@ -38,6 +38,7 @@ import {
cancelTruckLaneScheduleAction,
applyNowTruckLaneScheduleAction,
retryFailedTruckLaneScheduleAction,
reactivateCancelledTruckLaneScheduleAction,
ignoreTruckLaneScheduleAction,
type RetryFailedTruckLaneScheduleRequest,
parseTruckLaneScheduleExcelAction,
@@ -255,6 +256,13 @@ export const retryFailedTruckLaneScheduleClient = async (
return await retryFailedTruckLaneScheduleAction(id, body);
};

export const reactivateCancelledTruckLaneScheduleClient = async (
id: number,
body?: RetryFailedTruckLaneScheduleRequest,
): Promise<TruckLaneScheduleResponse> => {
return await reactivateCancelledTruckLaneScheduleAction(id, body);
};

export const ignoreTruckLaneScheduleClient = async (
id: number,
): Promise<TruckLaneScheduleResponse> => {


+ 2
- 2
src/authorities.ts 파일 보기

@@ -16,6 +16,6 @@ export const AUTH = {
JOB_CREATE: "JOB_CREATE",
JOB_PICK: "JOB_PICK",
JOB_MAT: "JOB_MAT",
JOB_PROD: "JOB_PROD",
JOB_PROD: "JOB_PROD",
REPORT_MGMT: "REPORT_MGMT",
} as const;

+ 785
- 0
src/components/DoSearch/DoReplenishmentTab.tsx 파일 보기

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

import React, { useCallback, useMemo, useRef, useState } from "react";
import {
Autocomplete,
Box,
Button,
Dialog,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
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 { 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 { DoDetail, DoDetailLine, fetchDoDetail, fetchDoSearch } from "@/app/api/do/actions";
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_TABLE_SX,
REPLENISHMENT_LOOKUP_BUTTON_SX,
REPLENISHMENT_SOURCE_HEADER_SX,
REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX,
REPLENISHMENT_TEXTFIELD_SX,
ReplenishmentFieldLabel,
ReplenishmentItemEntryPlainText,
ReplenishmentTextField,
replenishmentSearchGridInputSx,
replenishmentSearchGridLabelSx,
replenishmentSearchGridShopRowSx,
} from "./ReplenishmentFilterField";

export type ReplenishmentStatus = "pending" | "processing" | "completed";

export type ReplenishmentDraftRow = {
rowId: string;
deliveryDate: string;
sourceDoId: number;
sourceDoCode: string;
sourceDoLineId: number;
itemId?: number;
itemNo: string;
itemName: string;
originalQty: number;
replenishQty: number;
shortUom?: string;
shopCode?: string;
shopName?: string;
truckLaneCode?: string;
};

export type ReplenishmentRecord = ReplenishmentDraftRow & {
id: number;
code: string;
targetDoId?: number;
targetDoCode?: string;
pickOrderLineId?: number;
status: ReplenishmentStatus;
created: string;
};

type SourceDoContext = {
doId: number;
doCode: string;
shopCode?: string;
shopName?: string;
truckLaneCode?: string | null;
status: string;
lines: DoDetailLine[];
};

let localIdSeq = 1;
let replenishmentCodeSeq = 1;

function nextReplenishmentCode(deliveryDate: string): string {
const ymd = deliveryDate.replace(/-/g, "");
const seq = String(replenishmentCodeSeq++).padStart(3, "0");
return `RP-${ymd}-${seq}`;
}

/** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */
function matchesShopInput(detail: DoDetail, shopInput: string): boolean {
const normalized = shopInput.trim().toLowerCase();
if (!normalized) return false;
const code = detail.shopCode?.toLowerCase() ?? "";
const name = detail.shopName?.toLowerCase() ?? "";
return code.includes(normalized) || name.startsWith(normalized);
}

function matchesDeliveryDate(
estimatedArrivalDate: number[] | undefined,
deliveryDateStr: string,
): boolean {
if (!estimatedArrivalDate?.length) return false;
return arrayToDateString(estimatedArrivalDate) === deliveryDateStr;
}

function lineUomDisplay(line?: DoDetailLine | null): string {
if (!line) return "";
return (line.shortUom ?? line.uomCode ?? line.uom ?? "").trim();
}

const DoReplenishmentTab: React.FC = () => {
const { t } = useTranslation("do");
const inFlightRef = useRef(false);
const itemCodeInputRef = useRef<HTMLInputElement>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLookingUp, setIsLookingUp] = useState(false);

const [deliveryDate, setDeliveryDate] = useState<Dayjs | null>(dayjs());
const [doCodeSuffix, setDoCodeSuffix] = useState("");
const [shopInput, setShopInput] = useState("");
const [sourceDo, setSourceDo] = useState<SourceDoContext | null>(null);
const [selectedLine, setSelectedLine] = useState<DoDetailLine | null>(null);
const [replenishQtyInput, setReplenishQtyInput] = useState("");

const [draftRows, setDraftRows] = useState<ReplenishmentDraftRow[]>([]);
const [records, setRecords] = useState<ReplenishmentRecord[]>([]);
const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all");
const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(null);
const [trackingDialogOpen, setTrackingDialogOpen] = useState(false);

const deliveryDateStr = deliveryDate?.format("YYYY-MM-DD") ?? "";

const handleLookupSourceDo = useCallback(async () => {
const suffix = doCodeSuffix.trim();
const shop = shopInput.trim();
if (suffix.length !== 4) {
await Swal.fire({
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") });
return;
}
if (!shop) {
await Swal.fire({ icon: "warning", title: t("Shop code or name is required") });
return;
}
setIsLookingUp(true);
try {
const searchRes = await fetchDoSearch(
suffix,
"",
"completed",
"",
"",
`${deliveryDateStr}T00:00:00`,
"",
1,
100,
undefined,
null,
null,
);
const candidates = searchRes.records.filter(
(r) =>
r.code.endsWith(suffix) &&
matchesDeliveryDate(r.estimatedArrivalDate, deliveryDateStr),
);
if (candidates.length === 0) {
await Swal.fire({ 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") });
setSourceDo(null);
return;
}
if (matched.length > 1) {
await Swal.fire({
icon: "error",
title: t("Multiple source DOs matched"),
text: t("Please verify DO code suffix, delivery date and shop."),
});
setSourceDo(null);
return;
}
const detail = matched[0];
const matchedCandidate = candidates.find((c) => c.id === detail.id);
if (detail.status !== "completed") {
await Swal.fire({
icon: "error",
title: t("Source DO must be completed"),
text: t("Only completed delivery orders can be used as replenishment source."),
});
setSourceDo(null);
return;
}
setSourceDo({
doId: detail.id,
doCode: detail.code,
shopCode: detail.shopCode,
shopName: detail.shopName,
truckLaneCode: matchedCandidate?.truckLanceCode ?? null,
status: detail.status,
lines: detail.deliveryOrderLines ?? [],
});
setDraftRows([]);
setSelectedLine(null);
setReplenishQtyInput("");
} catch {
await Swal.fire({ icon: "error", title: t("Failed to lookup source DO") });
} finally {
setIsLookingUp(false);
}
}, [deliveryDateStr, doCodeSuffix, shopInput, t]);

const handleAddDraftRow = useCallback(() => {
if (!sourceDo) {
void Swal.fire({ icon: "warning", title: t("Please lookup source DO first") });
return;
}
if (!deliveryDateStr) {
void Swal.fire({ icon: "warning", title: t("Delivery date is required") });
return;
}
if (!selectedLine) {
void Swal.fire({ 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") });
return;
}
const line = selectedLine;

const duplicate = draftRows.some(
(r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId,
);
if (duplicate) {
void Swal.fire({ icon: "warning", title: t("This item is already in the draft list") });
return;
}

setDraftRows((prev) => [
...prev,
{
rowId: `draft-${Date.now()}-${prev.length}`,
deliveryDate: deliveryDateStr,
sourceDoId: sourceDo.doId,
sourceDoCode: sourceDo.doCode,
sourceDoLineId: line.id,
itemNo: line.itemNo ?? "",
itemName: line.itemName ?? line.itemNo ?? "",
originalQty: line.qty ?? 0,
replenishQty: qty,
shortUom: lineUomDisplay(line) || undefined,
shopCode: sourceDo.shopCode,
shopName: sourceDo.shopName,
truckLaneCode: undefined,
},
]);
setSelectedLine(null);
setReplenishQtyInput("");
window.setTimeout(() => itemCodeInputRef.current?.focus(), 0);
}, [
deliveryDateStr,
draftRows,
replenishQtyInput,
selectedLine,
sourceDo,
t,
]);

const handleRemoveDraftRow = useCallback((rowId: string) => {
setDraftRows((prev) => prev.filter((r) => r.rowId !== rowId));
}, []);

const handleSubmit = useCallback(async () => {
if (inFlightRef.current) return;
if (draftRows.length === 0) {
await Swal.fire({ icon: "warning", title: t("No draft rows to submit") });
return;
}
inFlightRef.current = true;
setIsSubmitting(true);
try {
const now = new Date().toISOString();
const newRecords: ReplenishmentRecord[] = draftRows.map((row) => ({
...row,
id: localIdSeq++,
code: nextReplenishmentCode(row.deliveryDate),
status: "pending",
created: now,
}));
setRecords((prev) => [...newRecords, ...prev]);
setDraftRows([]);
await Swal.fire({
icon: "info",
title: t("Replenishment API not ready"),
text: t("Records saved locally for preview. Backend integration pending."),
});
} finally {
setIsSubmitting(false);
inFlightRef.current = false;
}
}, [draftRows, t]);

const trackColumns: GridColDef<ReplenishmentRecord>[] = 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 filteredRecords = useMemo(() => {
return records.filter((r) => {
if (trackStatusFilter !== "all" && r.status !== trackStatusFilter) return false;
if (trackDateFilter && r.deliveryDate !== trackDateFilter.format("YYYY-MM-DD")) {
return false;
}
return true;
});
}, [records, trackDateFilter, trackStatusFilter]);

const datePickerSlotProps = useMemo(
() => ({
textField: {
size: "small" as const,
fullWidth: true,
variant: "filled" as const,
placeholder: t("replenishmentDatePlaceholder"),
sx: REPLENISHMENT_TEXTFIELD_SX,
InputProps: { disableUnderline: true },
},
}),
[t],
);

return (
<Stack spacing={2}>
<Paper
variant="outlined"
sx={{
position: "relative",
p: 2,
bgcolor: (theme) => (theme.palette.mode === "dark" ? "grey.900" : "grey.50"),
}}
>
<Tooltip title={t("Replenishment Tracking")}>
<IconButton
size="small"
onClick={() => setTrackingDialogOpen(true)}
aria-label={t("Replenishment Tracking")}
sx={{
position: "absolute",
top: 8,
right: 8,
zIndex: 1,
color: "text.secondary",
}}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
columnGap: 2,
rowGap: 1,
alignItems: "stretch",
pr: { xs: 4, lg: 4 },
}}
>
<ReplenishmentFieldLabel
icon={<CalendarTodayIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />}
title={t("Estimated Arrival Date")}
required
sx={replenishmentSearchGridLabelSx(1)}
/>
<Box sx={replenishmentSearchGridInputSx(1)}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
format="YYYY-MM-DD"
value={deliveryDate}
onChange={(v) => {
setDeliveryDate(v);
setSourceDo(null);
}}
slotProps={datePickerSlotProps}
sx={{ width: "100%" }}
/>
</LocalizationProvider>
</Box>

<ReplenishmentFieldLabel
icon={<ReceiptLongIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />}
title={t("DO Code Last 4")}
required
sx={replenishmentSearchGridLabelSx(2)}
/>
<Box sx={replenishmentSearchGridInputSx(2)}>
<ReplenishmentTextField
value={doCodeSuffix}
onChange={(e) => {
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();
}
}}
/>
</Box>

<ReplenishmentFieldLabel
icon={<StorefrontIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />}
title={t("Shop Code")}
required
sx={replenishmentSearchGridLabelSx(3)}
/>
<Box sx={replenishmentSearchGridShopRowSx}>
<ReplenishmentTextField
value={shopInput}
onChange={(e) => {
setShopInput(e.target.value);
setSourceDo(null);
}}
placeholder={t("replenishmentShopPlaceholder")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleLookupSourceDo();
}
}}
/>
<Button
variant="contained"
disableElevation
startIcon={<Search fontSize="small" />}
onClick={() => void handleLookupSourceDo()}
disabled={isLookingUp}
sx={REPLENISHMENT_LOOKUP_BUTTON_SX}
>
{t("Lookup")}
</Button>
</Box>
</Box>

{sourceDo && (
<Box sx={REPLENISHMENT_SOURCE_HEADER_SX}>
<Typography
variant="body2"
fontWeight={700}
sx={{ wordBreak: "break-word", lineHeight: 1.5, width: "100%" }}
>
{t("Delivery Order Code")}: {sourceDo.doCode}
{" "}
{t("Shop Name")}: {sourceDo.shopName ?? "—"}
{" "}
{t("Truck Lance Code")}:{" "}
{sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")}
</Typography>
</Box>
)}

{sourceDo && (
<Stack spacing={1.5}>
<TableContainer
sx={(theme) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2,
bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white",
})}
>
<Table size="small" sx={REPLENISHMENT_TABLE_SX}>
<TableHead>
<TableRow>
<TableCell sx={{ width: { md: "18%" }, minWidth: { md: 168 } }}>
{t("Replenishment item code")}
</TableCell>
<TableCell sx={{ width: { md: "28%" } }}>{t("Item Name")}</TableCell>
<TableCell align="right" sx={{ width: { md: "11%" }, whiteSpace: "nowrap" }}>
{t("Original Shipment Qty")}
</TableCell>
<TableCell align="right" sx={{ width: { md: "12%" }, whiteSpace: "nowrap" }}>
{t("Replenish Qty")}
</TableCell>
<TableCell sx={{ width: { md: "8%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}>
{t("uom")}
</TableCell>
<TableCell align="center" sx={{ width: { md: 120 }, whiteSpace: "nowrap" }}>
{t("Action")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{draftRows.map((row) => (
<TableRow key={row.rowId} hover>
<TableCell>{row.itemNo}</TableCell>
<TableCell sx={{ wordBreak: "break-word" }}>{row.itemName}</TableCell>
<TableCell align="right">{row.originalQty}</TableCell>
<TableCell align="right">{row.replenishQty}</TableCell>
<TableCell>{row.shortUom || "—"}</TableCell>
<TableCell align="center">
<Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}>
<IconButton
size="small"
color="error"
onClick={() => handleRemoveDraftRow(row.rowId)}
aria-label={t("Delete")}
>
<Delete fontSize="small" />
</IconButton>
</Box>
</TableCell>
</TableRow>
))}
<TableRow sx={REPLENISHMENT_TABLE_ENTRY_ROW_SX}>
<TableCell>
<Autocomplete
size="small"
fullWidth
options={sourceDo.lines.filter(
(line) =>
!draftRows.some(
(r) =>
r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId,
),
)}
value={selectedLine}
onChange={(_, newValue) => setSelectedLine(newValue)}
getOptionLabel={(line) => line.itemNo ?? ""}
isOptionEqualToValue={(a, b) => a.id === b.id}
filterOptions={(options, { inputValue }) => {
const query = inputValue.trim().toLowerCase();
if (!query) return options;
return options.filter((line) =>
(line.itemNo ?? "").toLowerCase().includes(query),
);
}}
renderInput={(params) => {
const { inputProps } = params;
return (
<ReplenishmentTextField
{...params}
placeholder={t("Replenishment item code")}
inputProps={{
...inputProps,
ref: (node: HTMLInputElement | null) => {
itemCodeInputRef.current = node;
const { ref } = inputProps;
if (typeof ref === "function") ref(node);
else if (ref) {
(
ref as React.MutableRefObject<HTMLInputElement | null>
).current = node;
}
},
}}
InputProps={{ ...params.InputProps, disableUnderline: true }}
/>
);
}}
sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX}
/>
</TableCell>
<TableCell>
<ReplenishmentItemEntryPlainText
value={
selectedLine ? (selectedLine.itemName ?? selectedLine.itemNo ?? "") : ""
}
/>
</TableCell>
<TableCell align="right">
<ReplenishmentItemEntryPlainText
reserveSpace
value={selectedLine != null ? String(selectedLine.qty ?? "") : ""}
sx={{ whiteSpace: "nowrap", textAlign: "right", minHeight: "unset" }}
/>
</TableCell>
<TableCell align="right">
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<ReplenishmentTextField
type="number"
hiddenLabel
fullWidth={false}
value={replenishQtyInput}
onChange={(e) => 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",
},
})}
/>
</Box>
</TableCell>
<TableCell>
<ReplenishmentItemEntryPlainText
reserveSpace
value={selectedLineUom}
sx={{ whiteSpace: "nowrap" }}
/>
</TableCell>
<TableCell align="center">
<Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}>
<Button
variant="outlined"
size="small"
startIcon={<Add />}
onClick={handleAddDraftRow}
sx={{
borderRadius: 2,
textTransform: "none",
whiteSpace: "nowrap",
px: 1.5,
fontSize: (theme) => theme.typography.body2.fontSize,
}}
>
{t("Add Row")}
</Button>
</Box>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>

{draftRows.length > 0 && (
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
variant="contained"
onClick={() => void handleSubmit()}
disabled={isSubmitting}
>
{t("Submit")}
</Button>
</Box>
)}
</Stack>
)}
</Stack>
</Paper>

<Dialog
open={trackingDialogOpen}
onClose={() => setTrackingDialogOpen(false)}
maxWidth="lg"
fullWidth
>
<DialogTitle
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
pr: 1,
}}
>
{t("Replenishment Tracking")}
<IconButton
size="small"
onClick={() => setTrackingDialogOpen(false)}
aria-label={t("Cancel")}
>
<Close fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 0 }}>
<Box sx={{ px: 2, pt: 1.5, pb: 1 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ minWidth: 200, maxWidth: 280 }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
format="YYYY-MM-DD"
value={trackDateFilter}
onChange={(v) => setTrackDateFilter(v)}
slotProps={datePickerSlotProps}
/>
</LocalizationProvider>
</Box>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>{t("Status")}</InputLabel>
<Select
label={t("Status")}
value={trackStatusFilter}
onChange={(e) =>
setTrackStatusFilter(e.target.value as ReplenishmentStatus | "all")
}
>
<MenuItem value="all">{t("All")}</MenuItem>
<MenuItem value="pending">{t("pending")}</MenuItem>
<MenuItem value="processing">{t("processing")}</MenuItem>
<MenuItem value="completed">{t("completed")}</MenuItem>
</Select>
</FormControl>
</Stack>
</Box>
<StyledDataGrid
rows={filteredRecords}
columns={trackColumns}
autoHeight
disableRowSelectionOnClick
pageSizeOptions={[10, 25, 50]}
initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
/>
</DialogContent>
</Dialog>
</Stack>
);
};

export default DoReplenishmentTab;

+ 42
- 32
src/components/DoSearch/DoSearch.tsx 파일 보기

@@ -37,6 +37,7 @@ import Swal from "sweetalert2";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import { useDoSearchRowSelection } from "./useDoSearchRowSelection";
import DoReplenishmentTab from "./DoReplenishmentTab";

type Props = {
filterArgs?: Record<string, any>;
@@ -45,7 +46,7 @@ type Props = {
};
type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>;
type SearchParamNames = keyof SearchBoxInputs;
type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA";
type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA" | "REPLENISH";
type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string };

// put all this into a new component
@@ -313,8 +314,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
case "TRUCK_X":
return { floor: null, isExtra: false, forceTruckKeyword: "x" };
case "ETRA":
default:
return { floor: null, isExtra: true };
case "REPLENISH":
default:
return { floor: null, isExtra: false };
}
}, []);

@@ -741,9 +744,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
<Tab value="4F" label="4/F" />
<Tab value="TRUCK_X" label={t("Truck X")} />
<Tab value="ETRA" label={t("Etra")} />
<Tab value="REPLENISH" label={t("Replenishment")} />
</Tabs>

{hasSearched && hasResults && (
{activeTab !== "REPLENISH" && hasSearched && hasResults && (
<Button
name="batch_release"
variant="contained"
@@ -756,35 +760,41 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
</Paper>


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

<Paper variant="outlined" sx={{ overflow: "hidden" }}>
<StyledDataGrid
rows={searchAllDos}
columns={columns}
checkboxSelection
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={applyRowSelectionChange}
slots={{
footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
/>
<TablePagination
component="div"
count={totalCount}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
/>
</Paper>
{activeTab === "REPLENISH" ? (
<DoReplenishmentTab />
) : (
<>
<SearchBox
key={`tab-reset-${searchBoxResetKey}`}
criteria={searchCriteria}
onSearch={handleSearch}
onReset={onReset}
/>

<Paper variant="outlined" sx={{ overflow: "hidden" }}>
<StyledDataGrid
rows={searchAllDos}
columns={columns}
checkboxSelection
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={applyRowSelectionChange}
slots={{
footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
/>
<TablePagination
component="div"
count={totalCount}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
/>
</Paper>
</>
)}

</Stack>
</FormProvider>


+ 430
- 0
src/components/DoSearch/ReplenishmentFilterField.tsx 파일 보기

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

import React, { type ReactNode } from "react";
import { Box, Stack, TextField, Typography } from "@mui/material";
import type { Theme } from "@mui/material/styles";
import type { SxProps } from "@mui/material/styles";
import type { TextFieldProps } from "@mui/material/TextField";

export const REPLENISHMENT_FIELD_LABEL_SX = (theme: Theme) => ({
color:
theme.palette.mode === "dark"
? theme.palette.grey[100]
: theme.palette.common.black,
fontWeight: 600,
});

export const REPLENISHMENT_FIELD_ICON_SX = (theme: Theme) => ({
color:
theme.palette.mode === "dark"
? theme.palette.grey[100]
: theme.palette.common.black,
});

export const REPLENISHMENT_TEXTFIELD_SX = (theme: Theme) =>
({
"& .MuiFilledInput-root": {
alignItems: "center",
borderRadius: 2,
bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white",
border: `1px solid ${theme.palette.divider}`,
},
"& .MuiFilledInput-root.Mui-focused": {
bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white",
},
"& .MuiFilledInput-input": {
paddingTop: REPLENISHMENT_FIELD_BODY_PY,
paddingBottom: REPLENISHMENT_FIELD_BODY_PY,
},
"& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": {
color: theme.palette.text.secondary,
fontWeight: 400,
opacity: 1,
},
"& .MuiFilledInput-root.Mui-focused .MuiFilledInput-input::placeholder, & .MuiFilledInput-root.Mui-focused .MuiInputBase-input::placeholder":
{
opacity: 0,
},
}) as const;

/** Autocomplete filled input — override theme default padding to match {@link REPLENISHMENT_TEXTFIELD_SX}. */
export const REPLENISHMENT_AUTOCOMPLETE_SX = (theme: Theme) =>
({
width: "100%",
...REPLENISHMENT_TEXTFIELD_SX(theme),
"& .MuiFormControl-root": {
width: "100%",
},
"& .MuiAutocomplete-inputRoot": {
paddingTop: `${REPLENISHMENT_FIELD_BODY_PY} !important`,
paddingBottom: `${REPLENISHMENT_FIELD_BODY_PY} !important`,
paddingLeft: `${theme.spacing(REPLENISHMENT_FIELD_BODY_PX)} !important`,
paddingRight: `${theme.spacing(3)} !important`,
},
"& .MuiAutocomplete-input": {
padding: "0 !important",
},
"& .MuiAutocomplete-endAdornment": {
right: theme.spacing(1),
},
}) as const;

/** Vertical padding inside replenishment filled inputs (see REPLENISHMENT_TEXTFIELD_SX). */
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;

/** Source DO summary header: same inset as textbox content area. */
export const REPLENISHMENT_SOURCE_HEADER_SX = (theme: Theme) =>
({
bgcolor: "action.hover",
borderRadius: 2,
px: REPLENISHMENT_FIELD_BODY_PX,
py: REPLENISHMENT_FIELD_BODY_PY,
display: "flex",
alignItems: "center",
boxSizing: "border-box",
border: `1px solid ${theme.palette.divider}`,
}) as const;

type ReplenishmentFieldLabelProps = {
icon: ReactNode;
title: string;
required?: boolean;
sx?: SxProps<Theme>;
};

export function ReplenishmentFieldLabel({
icon,
title,
required = false,
sx,
}: ReplenishmentFieldLabelProps) {
return (
<Stack direction="row" spacing={1} alignItems="center" sx={sx}>
{icon}
<Typography variant="body2" sx={REPLENISHMENT_FIELD_LABEL_SX} component="span">
{title}
{required ? (
<Typography component="span" color="error.main" aria-hidden="true">
{" *"}
</Typography>
) : null}
</Typography>
</Stack>
);
}

type ReplenishmentFilterFieldProps = ReplenishmentFieldLabelProps & {
children: ReactNode;
};

export function ReplenishmentFilterField({
icon,
title,
children,
required = false,
}: ReplenishmentFilterFieldProps) {
return (
<Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
<ReplenishmentFieldLabel icon={icon} title={title} required={required} />
{children}
</Stack>
);
}

type ReplenishmentTextFieldProps = Omit<TextFieldProps, "variant" | "size">;

export function ReplenishmentTextField(props: ReplenishmentTextFieldProps) {
const { sx, InputProps, ...rest } = props;
return (
<TextField
size="small"
fullWidth
variant="filled"
sx={(theme) => ({
...REPLENISHMENT_TEXTFIELD_SX(theme),
...(typeof sx === "function" ? sx(theme) : sx),
})}
InputProps={{ disableUnderline: true, ...InputProps }}
{...rest}
/>
);
}

/** Read-only item row value — blank until a line is selected. */
export function ReplenishmentItemEntryPlainText({
value,
reserveSpace = false,
sx,
}: {
value: string;
/** Keep column width/height when empty (e.g. original shipment qty). */
reserveSpace?: boolean;
sx?: SxProps<Theme>;
}) {
const isEmpty = value.trim() === "";
if (isEmpty && !reserveSpace) return null;

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),
})}
>
{isEmpty ? "\u00A0" : value}
</Box>
);
}

type ReplenishmentQtyWithUomFieldProps = {
placeholder?: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
uom?: string;
sx?: SxProps<Theme>;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
};

/** Replenish qty textbox (search-field style) with uom label attached on the right outside. */
export function ReplenishmentQtyWithUomField({
placeholder,
value,
onChange,
uom = "",
sx,
inputProps,
}: ReplenishmentQtyWithUomFieldProps) {
return (
<Stack
direction="row"
alignItems="center"
spacing={0.75}
sx={{ flexShrink: 0, ...(typeof sx === "function" ? undefined : sx) }}
>
<ReplenishmentTextField
type="number"
hiddenLabel
placeholder={placeholder ?? ""}
value={value}
onChange={onChange}
inputProps={inputProps}
sx={{ width: 100 }}
/>
<Box component="span" sx={REPLENISHMENT_TABLE_UOM_SLOT_SX}>
{uom || "\u00A0"}
</Box>
</Stack>
);
}

export const REPLENISHMENT_TABLE_SX = {
tableLayout: { md: "fixed" },
width: "100%",
"& .MuiTableCell-root": {
typography: "body2",
borderColor: "divider",
py: 1.25,
px: 2,
},
"& .MuiTableCell-root:first-of-type": {
pl: 3.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_TABLE_HEAD_CELL_SX = {
typography: "body2",
fontWeight: 600,
} as const;

export const REPLENISHMENT_TABLE_BODY_CELL_SX = {
typography: "body2",
verticalAlign: "middle",
} as const;

/** Extra inset for the first column (item code). */
export const REPLENISHMENT_TABLE_FIRST_CELL_SX = {
pl: 3,
} as const;

/** Entry row: no bottom border on last row. */
export const REPLENISHMENT_TABLE_ENTRY_ROW_SX = {
"& .MuiTableCell-root": {
borderBottom: 0,
},
} as const;

/** In-table inputs — always show filled border; compact padding for row alignment. */
export const REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX = (theme: Theme) =>
({
...REPLENISHMENT_TEXTFIELD_SX(theme),
"& .MuiFilledInput-root": {
alignItems: "center",
borderRadius: 1.5,
bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white",
border: `1px solid ${theme.palette.divider}`,
"&:hover": {
bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white",
},
"&.Mui-focused": {
bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white",
borderColor: theme.palette.primary.main,
},
},
"& .MuiFilledInput-input": {
paddingTop: "6px",
paddingBottom: "6px",
paddingLeft: 0,
paddingRight: 0,
},
"& input[type=number]": {
MozAppearance: "textfield",
},
"& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button":
{
WebkitAppearance: "none",
margin: 0,
},
}) as const;

/** Table autocomplete — same inset as plain text in first column. */
export const REPLENISHMENT_TABLE_AUTOCOMPLETE_SX = (theme: Theme) =>
({
width: "100%",
...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme),
"& .MuiFormControl-root": {
width: "100%",
},
"& .MuiAutocomplete-inputRoot": {
paddingTop: "6px !important",
paddingBottom: "6px !important",
paddingLeft: `${theme.spacing(1)} !important`,
paddingRight: "56px !important",
bgcolor: `${theme.palette.mode === "dark" ? theme.palette.grey[800] : theme.palette.common.white} !important`,
border: `1px solid ${theme.palette.divider} !important`,
borderRadius: `${theme.shape.borderRadius * 1.5}px !important`,
"&.Mui-focused": {
borderColor: `${theme.palette.primary.main} !important`,
},
},
"& .MuiAutocomplete-input": {
padding: "0 !important",
minWidth: 48,
},
"& .MuiAutocomplete-endAdornment": {
right: theme.spacing(0.75),
gap: theme.spacing(0.25),
"& .MuiAutocomplete-clearIndicator": {
visibility: "visible",
},
},
}) as const;

/** Right-aligned qty + uom slot shared by data rows and entry row. */
export const REPLENISHMENT_TABLE_QTY_CELL_INNER_SX = {
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
width: "100%",
gap: 0.75,
} as const;

export const REPLENISHMENT_TABLE_UOM_SLOT_SX = {
minWidth: 28,
whiteSpace: "nowrap",
display: "inline-block",
flexShrink: 0,
} as const;

export const REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX = {
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
} as const;

export const replenishmentSearchGridLabelSx = (col: number) => ({
gridColumn: { xs: 1, lg: col },
gridRow: { xs: "auto", lg: 1 },
minWidth: 0,
});

export const replenishmentSearchGridInputSx = (col: number) => ({
gridColumn: { xs: 1, lg: col },
gridRow: { xs: "auto", lg: 2 },
minWidth: 0,
display: "flex",
alignItems: "stretch",
"& .MuiFormControl-root": {
width: "100%",
},
});

/** Shop input + lookup button share one row; button height follows the textbox. */
export const replenishmentSearchGridShopRowSx = {
gridColumn: { xs: 1, lg: 3 },
gridRow: { xs: "auto", lg: 2 },
minWidth: 0,
display: "flex",
alignItems: "stretch",
gap: 1,
"& .MuiTextField-root": {
flex: 1,
minWidth: 0,
},
"& .MuiFormControl-root": {
height: "100%",
},
"& .MuiFilledInput-root": {
height: "100%",
boxSizing: "border-box",
},
};

/** Match {@link ReplenishmentFieldLabel} typography on contained 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,
},
},
});


+ 9
- 2
src/components/NavigationContent/NavigationContent.tsx 파일 보기

@@ -226,7 +226,7 @@ const NavigationContent: React.FC = () => {
icon: <Assessment />,
labelKey: "nav.report",
path: "/report",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
requiredAbility: [AUTH.REPORT_MGMT, AUTH.ADMIN],
isHidden: false,
},
{
@@ -363,7 +363,7 @@ const NavigationContent: React.FC = () => {
id: "nav.settings.shopAndTruck",
icon: <Storefront />,
labelKey: "nav.settings.shopAndTruck",
path: "/settings/shop",
path: "/settings/shop/board",
},
{
id: "nav.settings.deliveryOrderFloor",
@@ -495,6 +495,13 @@ const NavigationContent: React.FC = () => {
if (!pathname.startsWith(p + "/")) return false;
// `/doworkbench` must not claim `/doworkbenchsearch` (prefix without trailing slash)
if (p === "/doworkbench" && pathname.startsWith("/doworkbenchsearch")) return false;
// Shop sub-routes (detail, truckdetail, legacy tab page) share one nav entry → board
if (
p === "/settings/shop/board" &&
(pathname === "/settings/shop" || pathname.startsWith("/settings/shop/"))
) {
return true;
}
return true;
});
matches.sort((a, b) => b.length - a.length);


+ 36
- 21
src/components/Shop/RouteBoard.tsx 파일 보기

@@ -2425,6 +2425,9 @@ const RouteBoard: React.FC = () => {
};

const handleDeleteTruckRow = async (truckRowId: number) => {
if (truckRowId > 0 && scheduledShopIdSet.has(truckRowId)) {
return;
}
if (truckRowId < 0) {
if (!window.confirm(t("confirm_discardDraftShop"))) return;
setError(null);
@@ -2488,6 +2491,11 @@ const RouteBoard: React.FC = () => {
/** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */
const handleClearLaneShops = (lane: Lane) => {
if (lane.shops.length === 0) return;
if (
lane.shops.some((s) => s.id > 0 && scheduledShopIdSet.has(s.id))
) {
return;
}
if (
!window.confirm(
t("confirm_clearLane", {
@@ -5845,13 +5853,6 @@ const RouteBoard: React.FC = () => {
setDistrictEditError(null);
}}
error={Boolean(districtEditError)}
helperText={
districtEditError ||
(districtEditCtx?.mode === "rename" &&
districtEditCtx.oldDisplay === "未分類"
? t("district_help_null")
: t("district_help_mapped"))
}
InputLabelProps={{ shrink: true }}
/>
</DialogContent>
@@ -5915,13 +5916,6 @@ const RouteBoard: React.FC = () => {
inputProps={{ step: 1 }}
sx={{ mt: 1 }}
/>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1, display: "block" }}
>
{t("seqDialog_hint")}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closeSeqEdit}>{t("cancel")}</Button>
@@ -5982,9 +5976,6 @@ const RouteBoard: React.FC = () => {
{addRouteError}
</Alert>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 2 }}>
{t("addRoute_hint")}
</Typography>
<Box
sx={{
display: "grid",
@@ -7846,7 +7837,13 @@ const RouteBoard: React.FC = () => {
spacing={0.5}
alignItems="center"
>
<Tooltip title={t("tooltip_removeFromLane")}>
<Tooltip
title={
isScheduledMove
? t("schedule_shop_locked")
: t("tooltip_removeFromLane")
}
>
<span>
<IconButton
size="small"
@@ -7861,7 +7858,8 @@ const RouteBoard: React.FC = () => {
}}
disabled={
loading ||
dirtyDeletes.has(shop.id)
dirtyDeletes.has(shop.id) ||
isScheduledMove
}
>
<Trash2 size={16} />
@@ -7947,12 +7945,29 @@ const RouteBoard: React.FC = () => {
>
{t("btn_addShopToLane")}
</Button>
<Tooltip title={t("tooltip_clearLaneShops")}>
<Tooltip
title={
lane.shops.some(
(s) =>
s.id > 0 && scheduledShopIdSet.has(s.id),
)
? t("schedule_shop_locked")
: t("tooltip_clearLaneShops")
}
>
<span>
<IconButton
size="small"
onClick={() => handleClearLaneShops(lane)}
disabled={loading || lane.shops.length === 0}
disabled={
loading ||
lane.shops.length === 0 ||
lane.shops.some(
(s) =>
s.id > 0 &&
scheduledShopIdSet.has(s.id),
)
}
>
<Trash2 size={16} />
</IconButton>


+ 226
- 33
src/components/Shop/ScheduleTaskHistoryModal.tsx 파일 보기

@@ -14,6 +14,7 @@ import {
DialogTitle,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
@@ -38,16 +39,23 @@ import {
applyNowTruckLaneScheduleClient,
cancelTruckLaneScheduleClient,
createTruckLaneScheduleClient,
reactivateCancelledTruckLaneScheduleClient,
getTruckLaneScheduleClient,
ignoreTruckLaneScheduleClient,
listTruckLaneSchedulesClient,
retryFailedTruckLaneScheduleClient,
type TruckLaneScheduleLineRequest,
type TruckLaneScheduleLineResponse,
type TruckLaneScheduleResponse,
} from "@/app/api/shop/client";
import type { ScheduleLaneOption } from "@/components/Shop/ScheduleChangeModal";
import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers";
import { resolveRescheduleExecuteAt, formatScheduleDisplayDateTime } from "@/components/Shop/scheduleExecuteAt";
import {
buildExecuteAtIso,
isExecuteAtTooEarly,
resolveRescheduleExecuteAt,
formatScheduleDisplayDateTime,
} from "@/components/Shop/scheduleExecuteAt";
import {
buildScheduleLineDescription,
} from "@/components/Shop/scheduleLineDisplay";
@@ -84,7 +92,7 @@ function uiStatusFromLines(
return null;
}

/** Prefer line outcomes over header status when they disagree (e.g. PARTIAL still stored as PENDING). */
/** Prefer fresh list aggregates over cached detail lines (detail can be stale after mutations). */
function resolveUiStatus(
task: TruckLaneScheduleResponse,
detail?: TruckLaneScheduleResponse | null,
@@ -93,11 +101,6 @@ function resolveUiStatus(
if (st === "CANCELLED") return "cancelled";
if (st === "IGNORED") return "ignored";

const fromDetailLines = detail?.lines?.length
? uiStatusFromLines(detail.lines)
: null;
if (fromDetailLines) return fromDetailLines;

if (task.lineCounts) {
const fromCounts = uiStatusFromLineCounts(task.lineCounts);
if (fromCounts) return fromCounts;
@@ -108,6 +111,12 @@ function resolveUiStatus(
if (st === "PARTIAL") {
return (task.lineCounts?.failed ?? 0) > 0 ? "failed" : "success";
}

const fromDetailLines = detail?.lines?.length
? uiStatusFromLines(detail.lines)
: null;
if (fromDetailLines) return fromDetailLines;

if (st === "PENDING" || st === "APPLYING") return "pending";
return "pending";
}
@@ -118,13 +127,34 @@ function canManagePendingSchedule(
): boolean {
const st = String(task.status ?? "").toUpperCase();
if (st !== "PENDING" && st !== "APPLYING") return false;
const c = task.lineCounts;
if (c) {
return c.pending > 0 && c.applied === 0 && c.failed === 0;
}
const lines = detail?.lines ?? [];
if (lines.length > 0) {
return lines.every((l) => l.lineStatus === "PENDING");
}
const c = task.lineCounts;
if (!c) return true;
return c.pending > 0 && c.applied === 0 && c.failed === 0;
return true;
}

function scheduleLinesToRequests(
lines: TruckLaneScheduleLineResponse[],
): TruckLaneScheduleLineRequest[] {
return lines
.filter((l) => l.lineStatus !== "APPLIED")
.map((l) => ({
action: l.action,
truckRowId: l.truckRowId,
toTruckLanceCode: l.toTruckLanceCode,
toRemark: l.toRemark,
toStoreId: l.toStoreId,
toLoadingSequence: l.toLoadingSequence ?? 0,
toDistrictReference: l.toDistrictReference ?? null,
shopCode: l.shopCode,
shopName: l.shopName,
departureTime: l.departureTime,
}));
}

function splitExecuteAt(executeAt: string): { date: string; time: string } {
@@ -163,6 +193,12 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
const [actionId, setActionId] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [redoTarget, setRedoTarget] = useState<TruckLaneScheduleResponse | null>(
null,
);
const [redoDate, setRedoDate] = useState("");
const [redoTime, setRedoTime] = useState("");
const [redoSubmitting, setRedoSubmitting] = useState(false);

const laneById = useMemo(
() => new Map(lanes.map((l) => [l.id, l])),
@@ -218,6 +254,9 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
setDetailById({});
setActionError(null);
setActionNotice(null);
setRedoTarget(null);
setRedoDate("");
setRedoTime("");
void loadTasks();
}, [open, loadTasks]);

@@ -238,15 +277,22 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
return tasks.filter((task) => {
const detail = detailById[task.id];
const ui = resolveUiStatus(task, detail);
if (filter === "all") return ui !== "cancelled";
if (filter === "all") return true;
if (filter === "pending") return ui === "pending";
if (filter === "success") return ui === "success";
return ui === "failed";
});
}, [tasks, filter, detailById]);

const ensureDetail = async (id: number) => {
if (detailById[id]) return detailById[id];
const invalidateTaskDetail = useCallback((id: number) => {
setDetailById((prev) => {
if (!prev[id]) return prev;
const { [id]: _removed, ...rest } = prev;
return rest;
});
}, []);

const refreshTaskDetail = useCallback(async (id: number) => {
setDetailLoadingId(id);
try {
const detail = await getTruckLaneScheduleClient(id);
@@ -255,8 +301,31 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
} finally {
setDetailLoadingId(null);
}
}, []);

const ensureDetail = async (id: number) => {
if (detailById[id]) return detailById[id];
return refreshTaskDetail(id);
};

const reloadAfterMutation = useCallback(
async (id: number) => {
invalidateTaskDetail(id);
await onAfterChange?.();
await loadTasks();
if (expandedId === id) {
await refreshTaskDetail(id);
}
},
[
expandedId,
invalidateTaskDetail,
loadTasks,
onAfterChange,
refreshTaskDetail,
],
);

const toggleExpand = async (id: number) => {
if (expandedId === id) {
setExpandedId(null);
@@ -271,8 +340,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
setActionError(null);
try {
await cancelTruckLaneScheduleClient(id);
await onAfterChange?.();
await loadTasks();
await reloadAfterMutation(id);
} catch (err: unknown) {
setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic"));
} finally {
@@ -285,8 +353,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
setActionError(null);
try {
await applyNowTruckLaneScheduleClient(id);
await onAfterChange?.();
await loadTasks();
await reloadAfterMutation(id);
} catch (err: unknown) {
setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic"));
} finally {
@@ -294,6 +361,59 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
}
};

const openRedoDialog = (task: TruckLaneScheduleResponse) => {
const { executeAt } = resolveRescheduleExecuteAt(task.executeAt);
const { date, time } = splitExecuteAt(executeAt);
setRedoTarget(task);
setRedoDate(date !== "-" ? date : "");
setRedoTime(time && time !== "-" ? time : "");
setActionError(null);
};

const closeRedoDialog = () => {
if (redoSubmitting) return;
setRedoTarget(null);
setRedoDate("");
setRedoTime("");
};

const handleConfirmRedo = async () => {
if (!redoTarget) return;
const executeAt = buildExecuteAtIso(redoDate, redoTime);
if (!executeAt) {
setActionError(t("schedule_err_execute_at_past"));
return;
}
if (isExecuteAtTooEarly(executeAt)) {
setActionError(t("schedule_err_execute_at_past"));
return;
}

setRedoSubmitting(true);
setActionId(redoTarget.id);
setActionError(null);
setActionNotice(null);
try {
const updated = await reactivateCancelledTruckLaneScheduleClient(
redoTarget.id,
{ executeAt },
);
setActionNotice(
t("schedule_reschedule_created", {
id: updated.id,
at: formatScheduleDisplayDateTime(executeAt),
}),
);
closeRedoDialog();
await reloadAfterMutation(redoTarget.id);
} catch (err: unknown) {
setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic"));
} finally {
setRedoSubmitting(false);
setActionId(null);
}
};

const handleReschedule = async (task: TruckLaneScheduleResponse) => {
if (String(task.status ?? "").toUpperCase() === "PARTIAL") {
setActionError(t("schedule_retry_rejects_partial"));
@@ -315,18 +435,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
(l) => l.lineStatus === "FAILED" || l.lineStatus === "PENDING",
);
if (retryLines.length === 0) throw retryErr;
const lines = retryLines.map((l) => ({
action: l.action,
truckRowId: l.truckRowId,
toTruckLanceCode: l.toTruckLanceCode,
toRemark: l.toRemark,
toStoreId: l.toStoreId,
toLoadingSequence: l.toLoadingSequence ?? 0,
toDistrictReference: l.toDistrictReference ?? null,
shopCode: l.shopCode,
shopName: l.shopName,
departureTime: l.departureTime,
}));
const lines = scheduleLinesToRequests(retryLines);
await createTruckLaneScheduleClient({ executeAt, lines });
}
if (adjusted) {
@@ -334,8 +443,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
t("schedule_reschedule_time_adjusted", { at: executeAt }),
);
}
await onAfterChange?.();
await loadTasks();
await reloadAfterMutation(task.id);
} catch (err: unknown) {
setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic"));
} finally {
@@ -348,8 +456,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
setActionError(null);
try {
await ignoreTruckLaneScheduleClient(id);
await onAfterChange?.();
await loadTasks();
await reloadAfterMutation(id);
} catch (err: unknown) {
setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic"));
} finally {
@@ -452,6 +559,22 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
/>
);
}
if (ui === "cancelled") {
return (
<Chip
size="small"
icon={<Ban size={12} />}
label={t("schedule_history_status_cancelled")}
sx={{
fontWeight: 700,
bgcolor: "grey.100",
color: "text.secondary",
border: 1,
borderColor: "grey.400",
}}
/>
);
}
return (
<Chip
size="small"
@@ -966,6 +1089,24 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
{t("schedule_history_status_ignored")}
</Typography>
)}
{ui === "cancelled" && (
<Button
size="small"
variant="contained"
disabled={busy}
startIcon={
busy ? (
<CircularProgress size={14} color="inherit" />
) : (
<RotateCcw size={14} />
)
}
onClick={() => openRedoDialog(task)}
sx={{ fontWeight: 700 }}
>
{t("schedule_history_reschedule")}
</Button>
)}
</Stack>
</Stack>
</Box>
@@ -988,6 +1129,58 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({
{t("schedule_history_close")}
</Button>
</DialogActions>

<Dialog
open={redoTarget != null}
onClose={closeRedoDialog}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ fontWeight: 800 }}>
{t("schedule_reschedule_dialog_title")}
</DialogTitle>
<DialogContent>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label={t("schedule_exec_date")}
type="date"
size="small"
fullWidth
value={redoDate}
onChange={(e) => setRedoDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label={t("schedule_exec_time")}
type="time"
size="small"
fullWidth
value={redoTime}
onChange={(e) => setRedoTime(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2 }}>
<Button onClick={closeRedoDialog} disabled={redoSubmitting}>
{t("cancel")}
</Button>
<Button
variant="contained"
disabled={
redoSubmitting || !redoDate.trim() || !redoTime.trim()
}
onClick={() => void handleConfirmRedo()}
sx={{ fontWeight: 700 }}
>
{redoSubmitting ? (
<CircularProgress size={18} color="inherit" />
) : (
t("schedule_reschedule_dialog_confirm")
)}
</Button>
</DialogActions>
</Dialog>
</Dialog>
);
};


+ 3
- 3
src/components/Shop/ShopDetail.tsx 파일 보기

@@ -325,7 +325,7 @@ const ShopDetail: React.FC = () => {
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
<Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button>
</Box>
);
}
@@ -336,7 +336,7 @@ const ShopDetail: React.FC = () => {
<Alert severity="warning" sx={{ mb: 2 }}>
{t("Shop not found")}
</Alert>
<Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
<Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button>
</Box>
);
}
@@ -347,7 +347,7 @@ const ShopDetail: React.FC = () => {
<CardContent>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">{t("Shop Information")}</Typography>
<Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
<Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>


+ 1
- 1
src/components/Shop/TruckLaneDetail.tsx 파일 보기

@@ -493,7 +493,7 @@ const TruckLaneDetail: React.FC = () => {
};

const handleBack = () => {
router.push("/settings/shop?tab=1");
router.push("/settings/shop/board");
};

const handleOpenAddShopDialog = () => {


+ 46
- 1
src/i18n/en/do.json 파일 보기

@@ -63,6 +63,51 @@
"Problem DO(s): ": "Problem DO(s): ",
"Progress": "Progress",
"Quantity": "Quantity",
"All": "All",
"Add Row": "Add",
"Created": "Created",
"DO code suffix must be exactly 4 characters": "DO code suffix must be exactly 4 characters",
"DO Code Last 4": "DO No. (last 4)",
"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",
"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",
"Enter item code to search": "Enter item code to search",
"Failed to lookup source DO": "Failed to lookup source DO",
"Item": "Item",
"Lookup": "Lookup",
"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.",
"Original Shipment Qty": "Original Shipment Qty",
"Please lookup source DO first": "Please lookup source 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 must be greater than zero": "Replenish qty must be greater than zero",
"Replenishment": "Replenishment",
"Replenishment API not ready": "Replenishment API not ready",
"Replenishment Entry": "Replenishment Entry",
"Replenishment item code": "Item Code",
"Replenishment Tracking": "Replenishment Tracking",
"Required field": "Required field",
"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",
"Submit": "Submit",
"Target DO": "Target DO",
"This item is already in the draft list": "This item is already in the draft list",
"processing": "Processing",
"Receiving": "Receiving",
"Release": "Release",
"Release 2/F": "Release 2/F",
@@ -95,5 +140,5 @@
"code": "code",
"do workbench": "do workbench",
"row selected": "row selected",
"uom": "uom"
"uom": "Unit"
}

+ 2
- 0
src/i18n/en/pickOrder.json 파일 보기

@@ -29,6 +29,8 @@
"if need just edit number, please scan the lot again": "if need just edit number, please scan the lot again",
"Total qty (actual pick + miss + bad) cannot exceed available qty: {available}": "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}",
"Confirm Assignment": "Confirm Assignment",
"Assigned successfully": "Assigned successfully",
"Assignment failed": "Assignment failed",
"Required Date": "Required Date",
"Store": "Store",
"Available Orders": "Available Orders",


+ 0
- 4
src/i18n/en/routeboard.json 파일 보기

@@ -169,15 +169,12 @@
"diff_loadingEllipsis": "…",
"addShop_dialogTitle": "Add shop to lane",
"addRoute_dialogTitle": "Add delivery lane",
"addRoute_hint": "After confirm, the lane is staged on the board; press \"Save changes\" in the header to create it on the server (no dummy shop rows).",
"addRoute_confirm": "Confirm add lane",
"addRoute_submitting": "Adding…",
"district_dialog_add": "Add district",
"district_dialog_edit": "Edit district",
"district_name_label": "District display name",
"district_name_ph": "Blank means \"Unclassified\"",
"district_help_null": "Unclassified maps to districtReference = null on server",
"district_help_mapped": "Display name is written via toDistrictRawValue to each shop's districtReference; API runs on \"Save changes\"",
"seq_edit_departureLabel": "Departure time",
"seq_edit_seqLabel": "Load sequence (Seq)",
"route_new_code_label": "Lane code",
@@ -207,7 +204,6 @@
"departureDialog_title": "Edit departure time",
"departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.",
"seqDialog_title": "Edit load sequence",
"seqDialog_hint": "Press \"Save changes\" to persist to truck rows.",
"logistics_colLaneCount": "{{count}} lane(s)",
"logistics_masterNoLanes": "Master record exists but no lanes are bound yet; pick this company when adding/editing lanes on the route board.",
"tooltip_openLaneBoard": "Open this lane on the route board",


+ 5
- 3
src/i18n/en/shop.json 파일 보기

@@ -196,8 +196,6 @@
"district_err_exists": "This district already exists",
"district_err_name": "Enter a district name",
"district_err_reserved": "\"Unclassified\" is built-in; do not add it again",
"district_help_mapped": "Display name is written via toDistrictRawValue to each shop's districtReference; API runs on \"Save changes\"",
"district_help_null": "Unclassified maps to districtReference = null on server",
"district_name_label": "District display name",
"district_name_ph": "Blank means \"Unclassified\"",
"drag_blockDraftShop": "Unsaved \"new shop\" rows must be saved with \"Save changes\" or removed from the card before dragging.",
@@ -324,6 +322,11 @@
"schedule_history_reschedule": "Reschedule",
"schedule_history_status_failed": "Failed",
"schedule_history_status_ignored": "Ignored",
"schedule_history_status_cancelled": "Cancelled",
"schedule_reschedule_dialog_title": "Reschedule",
"schedule_reschedule_dialog_subtitle": "Pick a new run date and time. This cancelled task will be updated in place.",
"schedule_reschedule_dialog_confirm": "Update schedule",
"schedule_reschedule_created": "Schedule #{{id}} updated to run at {{at}}.",
"schedule_history_status_pending": "Scheduled",
"schedule_history_status_success": "Succeeded",
"schedule_history_subtitle": "Monitor, run, or troubleshoot scheduled shop-lane changes",
@@ -406,7 +409,6 @@
"schedule_tab_import": "Import route Excel",
"schedule_tab_manual": "Drag scheduling",
"schedule_target_unset": "Not set",
"seqDialog_hint": "Press \"Save changes\" to persist to truck rows.",
"seqDialog_title": "Edit load sequence",
"seq_edit_departureLabel": "Departure time",
"seq_edit_seqLabel": "Load sequence (Seq)",


+ 46
- 2
src/i18n/zh/do.json 파일 보기

@@ -10,6 +10,51 @@
"Estimated Arrival From": "預計送貨日期",
"Estimated Arrival To": "預計送貨日期至",
"Status": "來貨狀態",
"All": "全部",
"Add Row": "新增",
"Created": "建立時間",
"DO code suffix must be exactly 4 characters": "送貨訂單號末四位必須為四個字元",
"DO Code Last 4": "送貨單號末四位",
"Delivery Date": "送貨日期",
"Enter last 4 characters of DO code": "請輸入送貨單號末四位",
"Enter item code to search": "輸入貨品編號搜尋",
"Shop code, or first characters of shop name": "店鋪代碼(部分符合),或店鋪名稱開頭字元",
"Multiple source DOs matched": "找到多張符合的來源送貨單",
"Please verify DO code suffix, delivery date and shop.": "請核對送貨單號末四位、送貨日及店鋪資料。",
"Shop code or name is required": "請輸入店鋪代碼或名稱",
"Draft List": "待提交列表",
"Failed to lookup source DO": "查詢來源送貨單失敗",
"Item": "物品",
"Lookup": "查詢",
"No draft rows to submit": "沒有待提交的行",
"Only completed delivery orders can be used as replenishment source.": "只有已送貨(completed)的送貨單可作為補貨來源。",
"Original Shipment Qty": "原出貨數",
"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 must be greater than zero": "補貨數量必須大於零",
"Replenishment": "補貨",
"Replenishment API not ready": "補貨 API 尚未就緒",
"Replenishment Entry": "補貨填表",
"Replenishment item code": "貨品編號",
"Replenishment Tracking": "補貨進度追蹤",
"Required field": "為必填項",
"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": "找不到來源送貨單",
"Submit": "提交",
"Target DO": "目標送貨單",
"This item is already in the draft list": "此物品已在待提交列表中",
"processing": "處理中",
"Etra": "加單",
"Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)",
"Confirm merge release": "確認合併放單",
@@ -71,7 +116,7 @@
"Supplier Code": "供應商編號",
"Estimated Arrival Date": "預計送貨日期",
"Item No.": "商品編號",
"Item Name": "品名稱",
"Item Name": "品名稱",
"Quantity": "數量",
"uom": "單位",
"Lot No.": "批號",
@@ -120,7 +165,6 @@
"Yes": "是",
"No": "否",
"Replenishment input section": "補貨資料",
"Replenishment item code": "貨品編號",
"Replenishment search candidates": "搜尋候選送貨單",
"Replenishment reset": "重設",
"Replenishment candidate section": "候選送貨單(待放單)",


+ 2
- 0
src/i18n/zh/pickOrder.json 파일 보기

@@ -29,6 +29,8 @@
"if need just edit number, please scan the lot again": "如果需要只修改數量,請重新掃描批次。",
"Total qty (actual pick + miss + bad) cannot exceed available qty: {available}": "總數量(實際提料 + 遺失 + 不良)不能超過可用數量:{{available}}",
"Confirm Assignment": "確認分配",
"Assigned successfully": "分派成功",
"Assignment failed": "分配失敗",
"Required Date": "所需日期",
"Store": "位置",
"Available Orders": "可用訂單",


+ 17
- 21
src/i18n/zh/routeboard.json 파일 보기

@@ -34,7 +34,7 @@
"exportRoutes": "匯出車線",
"routeReport": "車線報告",
"departureTooltipNeedShops": "先新增店鋪才能設定出車時間",
"departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)",
"departureTooltipEditSave": "編輯出車時間",
"departureEditAria": "編輯出車時間",
"saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存",
"cancel": "取消",
@@ -80,9 +80,9 @@
"confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?",
"diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。",
"diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。",
"restore_applied": "已從 snapshot 還原並重新載入看板。",
"restore_appliedDroppedStaging": "已套用 snapshot 還原;本次儲存略過其他暫存變更(請重新編輯)。",
"confirm_restoreSaveWillDropStaging": "儲存時將先套用 snapshot 還原,本次其他暫存變更會被略過。確定繼續?",
"restore_applied": "已從 版本還原並重新載入看板。",
"restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。",
"confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?",
"diff_noOlderCompare": "沒有上一筆版本可比較(請選擇較新的版本)",
"logistic_needMasterTpl": "「{{name}}」尚無對應物流公司,請先用「新增物流商」建立。",
"diffField_logisticsCompany": "物流公司",
@@ -140,11 +140,11 @@
"diff_shopList_title": "店鋪異動清單",
"diff_staged_serverCountsOnly": "上列四格為「後端相鄰兩版快照」統計,不含看板上尚未儲存的編輯。",
"diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。",
"diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)",
"diff_staged_section_title": "看板未儲存/已排程",
"diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端快照)混淆。",
"diff_staged_tag_unsaved": "未儲存",
"diff_staged_tag_scheduled": "已排程",
"diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。",
"diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。",
"diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)",
"diff_staged_newLane": "新增車線(未儲存):{{lane}}",
"diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}",
@@ -154,9 +154,9 @@
"diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)",
"diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入",
"diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})",
"diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)",
"diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列",
"confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?",
"import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。",
"import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。",
"err_importEmpty": "匯入檔案無有效車線資料列",
"diff_logisticMaster_section": "物流公司異動",
"diff_logisticMaster_added": "新增",
@@ -169,17 +169,14 @@
"diff_loadingEllipsis": "…",
"addShop_dialogTitle": "新增店鋪到車線",
"addRoute_dialogTitle": "新增配送車線",
"addRoute_hint": "確認後先加入看板暫存;須按頂部「儲存更改」才會在後端建立車線(不建立假店鋪列)。",
"addRoute_confirm": "確認新增車線",
"addRoute_submitting": "新增中…",
"district_dialog_add": "新增地區",
"district_dialog_edit": "編輯地區",
"district_name_label": "地區顯示名稱",
"district_name_ph": "空白表示「未分類」",
"district_help_null": "未分類對應後端 districtReference 為 null",
"district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API",
"seq_edit_departureLabel": "出車時間",
"seq_edit_seqLabel": "裝車順序 (Seq)",
"seq_edit_seqLabel": "裝車順序",
"route_new_code_label": "車線編號",
"route_new_time_label": "出車時間",
"route_new_logistic_label": "物流公司",
@@ -203,34 +200,33 @@
"dialog_editLogisticsTitle": "編輯物流公司",
"btn_apply": "套用",
"addShop_confirm": "確認",
"addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳;與其他編輯相同,需按「儲存更改」才會寫入後端 truck。",
"addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳。",
"departureDialog_title": "編輯出車時間",
"departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。",
"departureDialog_hint": "套用至此車線所有店鋪列。",
"seqDialog_title": "編輯裝車順序",
"seqDialog_hint": "按「儲存更改」後寫入 truck 列。",
"logistics_colLaneCount": "{{count}} 條車線",
"logistics_masterNoLanes": "主檔已建立,尚無綁定車線;至「車線看板」新增/編輯車線時可填此公司名稱。",
"tooltip_openLaneBoard": "在車線看板開此車線",
"aria_openLaneBoard": "開啟車線看板",
"tooltip_removeFromLane": "從此車線移除",
"tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)",
"tooltip_clearLaneShops": "清空此車線所有店鋪",
"tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)",
"aria_pickLane": "選擇車線",
"aria_searchLanes": "搜索車線",
"logistics_colShopCount": "{{count}} 家店鋪",
"tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)",
"tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)",
"tooltip_editLogisticsDb": "編輯物流公司",
"tooltip_deleteLogistics": "刪除物流公司",
"aria_editLogistics": "編輯物流公司",
"aria_deleteLogistics": "刪除物流公司",
"confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。",
"confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?",
"err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。",
"diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}",
"logistic_btn_apply": "套用",
"tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)",
"tooltip_editDistrict": "編輯地區名稱",
"aria_editDistrict": "編輯地區",
"tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)",
"aria_removeEmptyDistrict": "移除空區",
"tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)",
"tooltip_editSeq": "編輯裝車順序",
"aria_editSeq": "編輯裝車順序",
"diff_moveFrom": "從 {{lane}}",
"logistics_dirtyColumnBadge": "有未儲存物流更改",


+ 20
- 18
src/i18n/zh/shop.json 파일 보기

@@ -34,7 +34,7 @@
"exportRoutes": "匯出車線",
"routeReport": "車線報告",
"departureTooltipNeedShops": "先新增店鋪才能設定出車時間",
"departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)",
"departureTooltipEditSave": "編輯出車時間",
"departureEditAria": "編輯出車時間",
"saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存",
"cancel": "取消",
@@ -139,11 +139,11 @@
"diff_summary_fieldChange": "欄位變更",
"diff_shopList_title": "店鋪變更清單",
"diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。",
"diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)",
"diff_staged_section_title": "看板未儲存/已排程",
"diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端版本)混淆。",
"diff_staged_tag_unsaved": "未儲存",
"diff_staged_tag_scheduled": "已排程",
"diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。",
"diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。",
"diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)",
"diff_staged_newLane": "新增車線(未儲存):{{lane}}",
"diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}",
@@ -153,9 +153,9 @@
"diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)",
"diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入",
"diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})",
"diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)",
"diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列",
"confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?",
"import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。",
"import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。",
"err_importEmpty": "匯入檔案無有效車線資料列",
"diff_logisticMaster_section": "物流公司變更",
"diff_logisticMaster_added": "新增",
@@ -175,10 +175,8 @@
"district_dialog_edit": "編輯地區",
"district_name_label": "地區顯示名稱",
"district_name_ph": "空白表示「未分類」",
"district_help_null": "未分類對應後端 districtReference 為 null",
"district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API",
"seq_edit_departureLabel": "出車時間",
"seq_edit_seqLabel": "裝車順序 (Seq)",
"seq_edit_seqLabel": "裝車順序",
"route_new_code_label": "車線編號",
"route_new_time_label": "出車時間",
"route_new_logistic_label": "物流公司",
@@ -206,7 +204,6 @@
"departureDialog_title": "編輯出車時間",
"departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。",
"seqDialog_title": "編輯裝車順序",
"seqDialog_hint": "按「儲存更改」後寫入 truck 列。",
"logistics_colLaneCount": "{{count}} 條車線",
"tooltip_openLaneBoard": "在車線看板開此車線",
"aria_openLaneBoard": "開啟車線看板",
@@ -220,15 +217,15 @@
"tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)",
"aria_editLogistics": "編輯物流公司",
"aria_deleteLogistics": "刪除物流公司",
"confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。",
"confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?",
"err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。",
"diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}",
"logistic_btn_apply": "套用",
"tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)",
"tooltip_editDistrict": "編輯地區名稱",
"aria_editDistrict": "編輯地區",
"tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)",
"aria_removeEmptyDistrict": "移除空區",
"tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)",
"tooltip_editSeq": "編輯裝車順序",
"aria_editSeq": "編輯裝車順序",
"diff_moveFrom": "從 {{lane}}",
"logistics_dirtyColumnBadge": "有未儲存物流更改",
@@ -312,7 +309,7 @@
"schedule_review_seq": "裝載順序:",
"schedule_drop_hint": "請拖曳店鋪卡片至此車線",
"schedule_moved_badge": "移入",
"schedule_drag_seq": "裝載順序 Seq: {{seq}}",
"schedule_drag_seq": "裝載順序: {{seq}}",
"schedule_seq_edit_btn": "編輯裝載順序",
"schedule_seq_dialog_hint": "變更會加入右側預覽佇列,確認排程後才套用。",
"schedule_planned_label": "執行預定",
@@ -320,7 +317,7 @@
"schedule_applied_snackbar": "已登記 {{count}} 筆預約變更並更新看板,請按「儲存更改」寫入後端。",
"schedule_err_conflict": "部分店舖無法移至目標車線(重複店鋪或草稿列)。",
"schedule_err_execute_at_past": "排程執行時間已過去,請選擇未來的日期與時間。",
"schedule_err_open_pending": "店舖列 #{{id}} 已有待執行的排程。",
"schedule_err_open_pending": "店舖已有待執行的排程。",
"schedule_err_duplicate_shop": "目標車線上已有店舖 {{shop}}。",
"schedule_err_target_lane_missing": "找不到目標車線 {{lane}}。",
"schedule_err_target_lane_empty": "目標車線 {{lane}} 尚無店舖,請先加入店舖。",
@@ -329,7 +326,7 @@
"schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。",
"schedule_shop_badge": "已排程變更",
"schedule_shop_locked": "排程執行中,此店鋪暫不可手改",
"schedule_retry_rejects_partial": "PARTIAL 排程不可重試,請先還原看板後重新建立排程",
"schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程",
"schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。",
"schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆",
"schedule_import_confirm_btn": "確認匯入並建立排程",
@@ -349,8 +346,8 @@
"schedule_history_applied_at": "成功執行時間:{{at}}",
"schedule_history_view_lines": "檢視店舖明細",
"schedule_history_no_lines": "尚無明細資料",
"schedule_history_failed_lines": "{{count}} 筆店舖移失敗",
"schedule_history_success_lines": "{{count}} 筆店舖已成功移",
"schedule_history_failed_lines": "{{count}} 筆店舖移失敗",
"schedule_history_success_lines": "{{count}} 筆店舖已成功移",
"schedule_history_apply_now": "立即執行",
"schedule_history_cancel": "取消排程",
"schedule_history_archived": "已執行",
@@ -358,8 +355,13 @@
"schedule_history_reschedule": "重新排程",
"schedule_history_ignore": "忽略",
"schedule_history_status_ignored": "已忽略",
"schedule_history_status_cancelled": "已取消",
"schedule_reschedule_dialog_title": "重新排程",
"schedule_reschedule_dialog_subtitle": "選擇新的執行日期與時間,將更新此筆已取消的預約任務。",
"schedule_reschedule_dialog_confirm": "更新排程",
"schedule_reschedule_created": "排程 #{{id}} 已更新,將於 {{at}} 執行。",
"schedule_history_close": "關閉視窗",
"PENDING": "待處理",
"PENDING": "待執行",
"APPLYING": "執行中",
"APPLIED": "已套用",
"PARTIAL": "部分完成",


불러오는 중...
취소
저장