Browse Source

Add lot issue list and its own Excel, containing phone numbers for contact

Reformat Excel
item_tracing
Harry Groves 1 week ago
parent
commit
a22b387dd5
21 changed files with 2159 additions and 585 deletions
  1. +12
    -4
      src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md
  2. +124
    -22
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  3. +41
    -0
      src/app/api/itemTracing/index.ts
  4. +8
    -0
      src/app/api/itemTracing/schema.ts
  5. +18
    -13
      src/components/ItemTracing/ItemTracing.tsx
  6. +46
    -41
      src/components/ItemTracing/ItemTracingFlowGraph.tsx
  7. +276
    -0
      src/components/ItemTracing/ItemTracingIssuePanel.tsx
  8. +0
    -21
      src/components/ItemTracing/ItemTracingLocations.tsx
  9. +9
    -0
      src/components/ItemTracing/ItemTracingScanBar.tsx
  10. +32
    -8
      src/components/ItemTracing/ItemTracingSections.tsx
  11. +13
    -57
      src/components/ItemTracing/ItemTracingSummary.tsx
  12. +269
    -226
      src/components/ItemTracing/TraceFlowNodes.tsx
  13. +444
    -0
      src/components/ItemTracing/buildIssueTraceRows.ts
  14. +67
    -16
      src/components/ItemTracing/buildReactFlowGraph.ts
  15. +352
    -39
      src/components/ItemTracing/exportItemLotTraceXlsx.ts
  16. +6
    -1
      src/components/ItemTracing/itemTracingTableFilters.tsx
  17. +21
    -14
      src/components/ItemTracing/traceFlowConstants.ts
  18. +12
    -102
      src/components/ItemTracing/traceFlowLayout.ts
  19. +23
    -7
      src/components/ItemTracing/traceGraphLayout.ts
  20. +193
    -7
      src/i18n/en/itemTracing.json
  21. +193
    -7
      src/i18n/zh/itemTracing.json

+ 12
- 4
src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md View File

@@ -35,17 +35,25 @@ This document defines how **client-side** `.xlsx` exports should look and behave

After `json_to_sheet(rows)`:

1. **`!cols`** — column width heuristic (see §4).
1. **`!cols`** — column width from header and cell lines (see §4).
2. **`applyHeaderRowStyle`** — header row styling (see §5).
3. **`applyMoneyColumnNumberFormats`** — money columns only, data rows (see §6).
4. **`applyNumericColumnRightAlign`** — money + quantity columns, **all rows including header** (see §7).
5. **`applyMultilineRowLayout`** — wrap and row height when a cell needs more than one line (see §4b).

---

## 4. Column width (`!cols`)

- For each column: `wch = max(12, headerText.length + 4)`.
- Adjust if a report needs fixed widths; default keeps bilingual headers readable.
- Width follows the **widest header or cell line**, not the header alone.
- CJK / fullwidth characters count as **2**. Embedded newlines are measured per line.
- `wch = clamp(12, widest + 3, 48)`. Text longer than the cap wraps (see §4b).

## 4b. Wrapped rows

- A row taller than one line (explicit `\n`, or text wider than the column) gets `wrapText` and a custom row height (`18pt` per line).
- Data cells on that row are **top-aligned** so the first line lines up across columns.
- The header row keeps its centered style; only its height grows when the title itself wraps.

---

@@ -158,4 +166,4 @@ Most other reports on `/report` download Excel/PDF **generated on the server** (

---

*Last aligned with implementation in `exportChartToXlsx.ts` (header fill `#D9D9D9`, money format `#,##0.00`, right-align numeric columns).*
*Last aligned with implementation in `exportChartToXlsx.ts` (header fill `#D9D9D9`, money format `#,##0.00`, right-align numeric columns, content-based column widths, wrapped row heights).*

+ 124
- 22
src/app/(main)/chart/_components/exportChartToXlsx.ts View File

@@ -75,6 +75,128 @@ function applyMoneyColumnNumberFormats(ws: XLSX.WorkSheet, headerLabels: string[
}
}

/**
* Excel column width is in default-font characters. CJK and fullwidth
* glyphs take about two of those, so a header-only width clips shop names.
*/
function textWidth(value: string): number {
let width = 0;
for (const ch of value) {
const code = ch.codePointAt(0) ?? 0;
const wide =
(code >= 0x1100 && code <= 0x115f) ||
(code >= 0x2e80 && code <= 0xa4cf) ||
(code >= 0xac00 && code <= 0xd7a3) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe10 && code <= 0xfe19) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff01 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6);
width += wide ? 2 : 1;
}
return width;
}

const MIN_COL_WCH = 12;
const MAX_COL_WCH = 48;
const COL_PAD = 3;
/** Points per wrapped line, plus a little padding so the last line is not clipped. */
const ROW_LINE_HPT = 18;

function columnWidths(
header: string[],
rows: Record<string, unknown>[],
): number[] {
return header.map((key) => {
let widest = textWidth(key);
rows.forEach((row) => {
const raw = row[key];
if (raw == null || raw === "") return;
String(raw)
.split(/\r?\n/)
.forEach((line) => {
widest = Math.max(widest, textWidth(line));
});
});
return Math.min(MAX_COL_WCH, Math.max(MIN_COL_WCH, widest + COL_PAD));
});
}

/** How many visible lines a cell needs once the column width is fixed. */
function wrappedLineCount(text: string, colWch: number): number {
const capacity = Math.max(4, colWch - 2);
let lines = 0;
text.split(/\r?\n/).forEach((part) => {
const width = textWidth(part);
lines += Math.max(1, Math.ceil(width / capacity));
});
return Math.max(1, lines);
}

/**
* Honour embedded newlines and wrap text that is wider than its column.
* Excel only shows those lines when wrapText is set and the row is tall enough.
*/
function applyMultilineRowLayout(
ws: XLSX.WorkSheet,
header: string[],
widths: number[],
): void {
if (!ws["!ref"] || header.length === 0) return;
const range = XLSX.utils.decode_range(ws["!ref"]);
const rowMeta: XLSX.RowInfo[] = [];
let anyTall = false;

for (let r = range.s.r; r <= range.e.r; r++) {
let lines = 1;
for (let c = 0; c < header.length; c++) {
const cell = ws[XLSX.utils.encode_cell({ r, c })];
const text = cell && cell.v != null ? String(cell.v) : "";
if (!text) continue;
lines = Math.max(lines, wrappedLineCount(text, widths[c] ?? MIN_COL_WCH));
}
if (lines <= 1) continue;

anyTall = true;
rowMeta[r] = { hpt: lines * ROW_LINE_HPT };
// Header already wraps and stays vertically centered. Data rows align to
// the first line so a shop code sits beside the first address line.
if (r === range.s.r) continue;

for (let c = 0; c < header.length; c++) {
const cellRef = XLSX.utils.encode_cell({ r, c });
const cell = ws[cellRef];
if (!cell) continue;
const prev = (cell.s || {}) as XLSX.CellStyle;
cell.s = {
...prev,
alignment: {
...prev.alignment,
horizontal: prev.alignment?.horizontal,
vertical: "top",
wrapText: true,
},
};
}
}

if (anyTall) ws["!rows"] = rowMeta;
}

function styleDataSheet(
ws: XLSX.WorkSheet,
rows: Record<string, unknown>[],
): void {
const header = Object.keys(rows[0] ?? {});
if (header.length === 0) return;
const widths = columnWidths(header, rows);
ws["!cols"] = widths.map((wch) => ({ wch }));
applyHeaderRowStyle(ws, header.length);
applyMoneyColumnNumberFormats(ws, header);
applyNumericColumnRightAlign(ws, header);
applyMultilineRowLayout(ws, header, widths);
}

/** Right-align amount / quantity column headers and data (merge with existing header fill). */
function applyNumericColumnRightAlign(
ws: XLSX.WorkSheet,
@@ -129,19 +251,7 @@ export function exportChartToXlsx(
return;
}
const ws = XLSX.utils.json_to_sheet(rows);

// Auto-set column widths based on header length (simple heuristic).
const header = Object.keys(rows[0] ?? {});
if (header.length > 0) {
ws["!cols"] = header.map((h) => ({
// Basic width: header length + padding, minimum 12
wch: Math.max(12, h.length + 4),
}));

applyHeaderRowStyle(ws, header.length);
applyMoneyColumnNumberFormats(ws, header);
applyNumericColumnRightAlign(ws, header);
}
styleDataSheet(ws, rows);

const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
@@ -166,15 +276,7 @@ export function exportMultiSheetToXlsx(
ws = XLSX.utils.aoa_to_sheet([[]]);
} else {
ws = XLSX.utils.json_to_sheet(rows);
const header = Object.keys(rows[0] ?? {});
if (header.length > 0) {
ws["!cols"] = header.map((h) => ({
wch: Math.max(12, h.length + 4),
}));
applyHeaderRowStyle(ws, header.length);
applyMoneyColumnNumberFormats(ws, header);
applyNumericColumnRightAlign(ws, header);
}
styleDataSheet(ws, rows);
}
XLSX.utils.book_append_sheet(wb, ws, safeName);
}


+ 41
- 0
src/app/api/itemTracing/index.ts View File

@@ -77,6 +77,13 @@ export interface ItemLotTraceReplenishmentEvent {
timestamp: string | null;
pickOrderLineId: number | null;
stockOutLineId: number | null;
lotNo?: string;
inventoryLotId?: number;
shopContactName?: string;
shopContactNo?: string;
shopContactEmail?: string;
shopAddress?: string;
shopDistrict?: string;
}

export interface ItemLotTraceOrigin {
@@ -421,6 +428,15 @@ export interface ItemLotTraceDoDelivery {
isReplenish?: boolean;
/** True when pick_order_line.qty ≠ SUM(stock_out_line.qty) for that line (改數). */
qtyChanged?: boolean;
shopContactName?: string;
shopContactNo?: string;
shopContactEmail?: string;
shopAddress?: string;
shopDistrict?: string;
itemCode?: string;
itemName?: string;
lotNo?: string;
inventoryLotId?: number;
}

export interface ItemLotTraceReturnEvent {
@@ -467,6 +483,29 @@ export interface ItemLotTraceGraph {
edges: ItemLotTraceGraphEdge[];
}

export interface ItemLotTraceWhereUsed {
depth: number;
consumedItemCode: string;
consumedItemName: string;
consumedLotNo: string;
materialQtyUsed: number;
jobOrderCode: string;
jobOrderId: number | null;
producedItemCode: string;
producedItemName: string;
producedLotNo: string;
producedQty: number;
producedUom: string;
producedInventoryLotId: number | null;
producedStockInLineId: number | null;
}

export interface ItemLotTraceIssue {
whereUsed: ItemLotTraceWhereUsed[];
downstreamDeliveries: ItemLotTraceDoDelivery[];
downstreamReplenishments: ItemLotTraceReplenishmentEvent[];
}

export interface ItemLotTraceResponse {
lot: ItemLotTraceLotInfo;
warehouseLines: ItemLotTraceWarehouseLine[];
@@ -494,6 +533,8 @@ export interface ItemLotTraceResponse {
* Empty when there is only one location for this lot. */
locationBlocks?: ItemLotTraceLocationBlock[];
traceGraph?: ItemLotTraceGraph | null;
/** Products made with this lot, and deliveries of those products. */
issueTrace?: ItemLotTraceIssue;
}

export interface ItemLotTraceParams {


+ 8
- 0
src/app/api/itemTracing/schema.ts View File

@@ -181,6 +181,14 @@ export const ItemLotTraceResponseSchema = z
alternateLocations: z.array(z.record(z.unknown())),
locationBlocks: z.array(z.record(z.unknown())).optional(),
traceGraph: ItemLotTraceGraphSchema,
issueTrace: z
.object({
whereUsed: z.array(z.record(z.unknown())).optional(),
downstreamDeliveries: z.array(z.record(z.unknown())).optional(),
downstreamReplenishments: z.array(z.record(z.unknown())).optional(),
})
.passthrough()
.optional(),
})
.passthrough();



+ 18
- 13
src/components/ItemTracing/ItemTracing.tsx View File

@@ -9,6 +9,7 @@ import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import { isNotFoundServerFetchError } from "@/app/utils/serverFetchError";
import ItemTracingScanBar from "./ItemTracingScanBar";
import ItemTracingSummary from "./ItemTracingSummary";
import ItemTracingIssuePanel from "./ItemTracingIssuePanel";
import ItemTracingFlowGraph from "./ItemTracingFlowGraph";
import ItemTracingSections from "./ItemTracingSections";
import {
@@ -18,20 +19,14 @@ import {
import { buildTraceGraphLabels } from "./traceGraphLabels";
import { exportItemLotTraceXlsx } from "./exportItemLotTraceXlsx";

export type WarehouseFocusRequest = {
inventoryLotId: number;
warehouseCode: string;
};

const ItemTracing: React.FC = () => {
const { t } = useTranslation("itemTracing");
const searchParams = useSearchParams();
const initialTraceRan = useRef(false);
const inFlightRef = useRef(false);
const [data, setData] = useState<ItemLotTraceResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [focusWarehouse, setFocusWarehouse] =
useState<WarehouseFocusRequest | null>(null);

const runTrace = useCallback(
async (params: {
@@ -40,12 +35,13 @@ const ItemTracing: React.FC = () => {
lotNo?: string;
itemCode?: string;
}) => {
if (inFlightRef.current) return;
inFlightRef.current = true;
setLoading(true);
setError(null);
try {
const res = await fetchItemLotTrace(params);
setData(res);
setFocusWarehouse(null);
} catch (e) {
console.error(e);
if (isNotFoundServerFetchError(e)) {
@@ -56,6 +52,7 @@ const ItemTracing: React.FC = () => {
setData(null);
} finally {
setLoading(false);
inFlightRef.current = false;
}
},
[t],
@@ -91,9 +88,14 @@ const ItemTracing: React.FC = () => {
return compileTraceGraph(data, labels);
}, [data, t]);

const handleExportExcel = useCallback(() => {
const handleExportHistoryExcel = useCallback(() => {
if (!data || !compiledGraph) return;
exportItemLotTraceXlsx(data, compiledGraph, t);
exportItemLotTraceXlsx(data, compiledGraph, t, "history");
}, [data, compiledGraph, t]);

const handleExportIssueExcel = useCallback(() => {
if (!data || !compiledGraph) return;
exportItemLotTraceXlsx(data, compiledGraph, t, "issue");
}, [data, compiledGraph, t]);

return (
@@ -124,8 +126,12 @@ const ItemTracing: React.FC = () => {
<>
<ItemTracingSummary
data={data}
onFocusWarehouse={(request) => setFocusWarehouse(request)}
onExportExcel={compiledGraph ? handleExportExcel : undefined}
onExportExcel={compiledGraph ? handleExportHistoryExcel : undefined}
/>
<ItemTracingIssuePanel
key={data.lot.inventoryLotId}
data={data}
onExportExcel={compiledGraph ? handleExportIssueExcel : undefined}
/>
{compiledGraph && (
<>
@@ -133,7 +139,6 @@ const ItemTracing: React.FC = () => {
data={data}
compiledGraph={compiledGraph}
onTrace={runTrace}
focusWarehouse={focusWarehouse}
/>
<ItemTracingSections
data={data}


+ 46
- 41
src/components/ItemTracing/ItemTracingFlowGraph.tsx View File

@@ -56,7 +56,6 @@ import {
import { searchTraceGraphNodes } from "./traceGraphSearch";
import type { CompiledTraceGraph } from "./compileTraceGraph";
import { TraceGraphNode } from "./buildTraceGraphNodes";
import type { WarehouseFocusRequest } from "./ItemTracing";
import type { TraceGraphPhase } from "./traceGraphLayout";

type TraceParams = {
@@ -69,7 +68,6 @@ type Props = {
data: ItemLotTraceResponse;
compiledGraph: CompiledTraceGraph;
onTrace?: (params: TraceParams) => void;
focusWarehouse?: WarehouseFocusRequest | null;
};

const DETAIL_PANEL_WIDTH = 280;
@@ -86,10 +84,9 @@ const ItemTracingFlowGraphInner: React.FC<Props> = ({
data,
compiledGraph,
onTrace,
focusWarehouse,
}) => {
const { t } = useTranslation("itemTracing");
const { fitView } = useReactFlow();
const { fitView, getViewport, setViewport } = useReactFlow();
const [selectedNode, setSelectedNode] = useState<TraceGraphNode | null>(null);
const [showMinimap, setShowMinimap] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
@@ -420,19 +417,29 @@ const ItemTracingFlowGraphInner: React.FC<Props> = ({
displayedNodes,
]);

const showReadableFlow = useCallback(async () => {
await fitView({
padding: 0.08,
minZoom: FIT_VIEW_MIN_ZOOM,
maxZoom: FIT_VIEW_MAX_ZOOM,
duration: 0,
});
const vp = getViewport();
// A graph wider than the pane clamps to min zoom and centers, which crops
// the date bar and the phase names. Pin that case to the start of the flow.
if (vp.zoom <= FIT_VIEW_MIN_ZOOM + 0.02) {
setViewport({ x: 20, y: 12, zoom: vp.zoom }, { duration: 0 });
}
}, [fitView, getViewport, setViewport]);

useEffect(() => {
setNodes(nodesWithCallbacks);
setEdges(graphElements.edges);
const timer = window.setTimeout(() => {
fitView({
padding: 0.04,
minZoom: FIT_VIEW_MIN_ZOOM,
maxZoom: FIT_VIEW_MAX_ZOOM,
duration: 200,
});
void showReadableFlow();
}, 50);
return () => window.clearTimeout(timer);
}, [nodesWithCallbacks, graphElements.edges, setNodes, setEdges, fitView]);
}, [nodesWithCallbacks, graphElements.edges, setNodes, setEdges, showReadableFlow]);

useEffect(() => {
if (!activeNodeId || !searchQuery.trim()) return;
@@ -447,34 +454,6 @@ const ItemTracingFlowGraphInner: React.FC<Props> = ({
return () => window.clearTimeout(timer);
}, [activeNodeId, searchQuery, focusFitNodes, fitView]);

useEffect(() => {
if (!focusWarehouse) return;
const { inventoryLotId, warehouseCode } = focusWarehouse;
const locPrefix = `loc-${inventoryLotId}-`;
const wh = warehouseCode.trim().toUpperCase();
const focusIds = layout.nodes
.filter((n) => {
if (n.id.startsWith(locPrefix)) return true;
if (inventoryLotId !== data.lot.inventoryLotId) return false;
if (n.id.startsWith("loc-")) return false;
if (!wh) return true;
return (n.warehouseCode ?? "").trim().toUpperCase() === wh;
})
.map((n) => n.id);
if (focusIds.length === 0) return;
setSearchQuery(warehouseCode.trim() || String(inventoryLotId));
setActiveMatchIndex(0);
const timer = window.setTimeout(() => {
fitView({
nodes: focusIds.map((id) => ({ id })),
padding: 0.2,
duration: 320,
maxZoom: 1.1,
});
}, 80);
return () => window.clearTimeout(timer);
}, [focusWarehouse, layout.nodes, data.lot.inventoryLotId, fitView]);

const handleSearchPrev = useCallback(() => {
if (matchIds.length === 0) return;
setActiveMatchIndex(
@@ -690,8 +669,13 @@ const ItemTracingFlowGraphInner: React.FC<Props> = ({
elementsSelectable
panOnScroll
zoomOnScroll
minZoom={0.15}
minZoom={FIT_VIEW_MIN_ZOOM}
maxZoom={2.5}
fitViewOptions={{
padding: 0.08,
minZoom: FIT_VIEW_MIN_ZOOM,
maxZoom: FIT_VIEW_MAX_ZOOM,
}}
translateExtent={translateExtent}
proOptions={{ hideAttribution: true }}
style={{ width: "100%", height: "100%" }}
@@ -754,13 +738,26 @@ const ItemTracingFlowGraphInner: React.FC<Props> = ({
},
}}
>
<Controls showInteractive={false} />
<Controls
showInteractive={false}
onFitView={() => {
void showReadableFlow();
}}
/>
</Box>
</Stack>
</Panel>
{showMinimap && (
<MiniMap
nodeColor={(node) => {
if (
node.type === "dayColumn" ||
node.type === "laneBand" ||
node.type === "dateHeader" ||
node.type === "phaseLabel"
) {
return "rgba(0,0,0,0)";
}
if (
node.type !== "traceEvent" &&
node.type !== "doGroup" &&
@@ -775,6 +772,14 @@ const ItemTracingFlowGraphInner: React.FC<Props> = ({
? minimapNodeColor(layoutNode.kind)
: "#e0e0e0";
}}
nodeStrokeColor={(node) =>
node.type === "dayColumn" ||
node.type === "laneBand" ||
node.type === "dateHeader" ||
node.type === "phaseLabel"
? "rgba(0,0,0,0)"
: "#fff"
}
pannable
zoomable
style={{ border: "1px solid #e0e0e0", borderRadius: 4 }}


+ 276
- 0
src/components/ItemTracing/ItemTracingIssuePanel.tsx View File

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

import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Chip,
Paper,
Stack,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { ItemLotTraceResponse } from "@/app/api/itemTracing";
import {
FilterableDataTable,
type FilterableColumnDef,
} from "./itemTracingTableFilters";
import {
buildIssueIngredientRows,
buildIssueStoreRows,
buildIssueWhereUsedRows,
formatShopPhone,
issueStoreItemsLabel,
issueStoreLabel,
issueStoreQtyLabel,
issueStoreReason,
type IssueIngredientRow,
type IssueStoreRow,
} from "./buildIssueTraceRows";
import { formatQty } from "./traceQtyUtils";

type Props = {
data: ItemLotTraceResponse;
onExportExcel?: () => void;
};

const itemLabel = (code: string, name: string, lot?: string): string => {
const item = [code, name].filter(Boolean).join(" · ");
return [item, lot].filter(Boolean).join(" / ") || "—";
};

const multiline = (text: string) => (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
{text.split("\n").map((line, index) => (
<Box key={`${index}-${line}`} component="span" sx={{ whiteSpace: "nowrap" }}>
{line}
</Box>
))}
</Box>
);

const ItemTracingIssuePanel: React.FC<Props> = ({ data, onExportExcel }) => {
const { t } = useTranslation("itemTracing");
const stores = useMemo(() => buildIssueStoreRows(data), [data]);
const whereUsed = useMemo(() => buildIssueWhereUsedRows(data), [data]);
const ingredients = useMemo(() => buildIssueIngredientRows(data), [data]);

const storeCols = useMemo((): FilterableColumnDef<IssueStoreRow>[] => [
{
key: "shop",
label: t("issueShop"),
verticalAlign: "top",
value: (row) => issueStoreLabel(row),
},
{
key: "phone",
label: t("issuePhone"),
verticalAlign: "top",
value: (row) => formatShopPhone(row.contactNo),
cell: (row) => (
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
{formatShopPhone(row.contactNo)}
</Box>
),
},
{
key: "times",
label: t("issueTimesReceived"),
align: "right",
verticalAlign: "top",
value: (row) => row.receiptCount,
},
{
key: "qty",
label: t("qty"),
align: "right",
verticalAlign: "top",
value: (row) => issueStoreQtyLabel(row),
cell: (row) => multiline(issueStoreQtyLabel(row)),
},
{
key: "why",
label: t("issueWhy"),
verticalAlign: "top",
value: (row) => issueStoreReason(row, t),
cell: (row) => multiline(issueStoreReason(row, t)),
},
{
key: "item",
label: t("issueItemsReceived"),
verticalAlign: "top",
value: (row) => issueStoreItemsLabel(row),
cell: (row) => multiline(issueStoreItemsLabel(row)),
},
{
key: "when",
label: t("issueLatestReceipt"),
verticalAlign: "top",
value: (row) => row.latestTimestamp ?? "—",
},
], [t]);

const whereUsedCols = useMemo((): FilterableColumnDef<(typeof whereUsed)[number]>[] => [
{
key: "level",
label: t("issueLevelLabel"),
value: (row) => t("issueLevel", { depth: row.depth }),
},
{ key: "jo", label: t("jobOrder"), value: (row) => row.jobOrderCode || "—" },
{
key: "used",
label: t("issueConsumed"),
value: (row) => itemLabel(row.consumedItemCode, row.consumedItemName, row.consumedLotNo),
},
{
key: "usedQty",
label: t("issueQtyUsed"),
align: "right",
value: (row) => formatQty(Number(row.materialQtyUsed), ""),
},
{
key: "produced",
label: t("issueProduced"),
value: (row) => itemLabel(row.producedItemCode, row.producedItemName, row.producedLotNo),
},
{
key: "producedQty",
label: t("issueProducedQty"),
align: "right",
value: (row) => formatQty(Number(row.producedQty), row.producedUom),
},
], [t]);

const ingredientCols = useMemo((): FilterableColumnDef<IssueIngredientRow>[] => [
{
key: "kind",
label: t("type"),
value: (row) =>
row.kind === "RECIPE" ? t("issueIngredientRecipe") : t("issueIngredientActual"),
},
{
key: "item",
label: t("Item"),
value: (row) => itemLabel(row.itemCode, row.itemName),
},
{ key: "lot", label: t("lotNo"), value: (row) => row.lotNo || "—" },
{
key: "qty",
label: t("qty"),
align: "right",
value: (row) => (row.qty == null ? "—" : formatQty(row.qty, row.uom)),
},
{
key: "per",
label: t("issueQtyPerUnit"),
align: "right",
value: (row) => (row.qtyPerUnit == null ? "—" : formatQty(row.qtyPerUnit, row.uom)),
},
{ key: "jo", label: t("jobOrder"), value: (row) => row.jobOrderCode || "—" },
{
key: "step",
label: t("detailAssignedStep"),
value: (row) => row.assignedStepName || "—",
},
], [t]);

return (
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
alignItems={{ sm: "center" }}
justifyContent="space-between"
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h6">{t("issueTitle")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{t("issueHint")}
</Typography>
</Box>
{onExportExcel && (
<Button
variant="contained"
startIcon={<FileDownloadOutlinedIcon />}
onClick={onExportExcel}
title={t("exportIssueExcelTooltip")}
sx={{ flexShrink: 0 }}
>
{t("exportIssueExcel")}
</Button>
)}
</Stack>

<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" sx={{ mt: 2 }}>
<Chip label={`${t("issueStores")} · ${stores.length}`} color={stores.length ? "warning" : "default"} />
<Chip label={`${t("issueWhereUsed")} · ${whereUsed.length}`} color={whereUsed.length ? "warning" : "default"} />
<Chip label={`${t("issueIngredients")} · ${ingredients.length}`} variant="outlined" />
</Stack>

<Box sx={{ mt: 1 }}>
<Accordion defaultExpanded={stores.length > 0} disableGutters elevation={0}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>{t("issueStores")}</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("issueContactHint")}
</Typography>
<Box sx={{ overflowX: "auto" }}>
<FilterableDataTable
rows={stores}
columns={storeCols}
getRowKey={(row) => row.key}
emptyLabel={t("issueNoStores")}
/>
</Box>
</AccordionDetails>
</Accordion>

<Accordion defaultExpanded={whereUsed.length > 0 && stores.length === 0} disableGutters elevation={0}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>{t("issueWhereUsed")}</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("issueWhereUsedHint")}
</Typography>
<Box sx={{ overflowX: "auto" }}>
<FilterableDataTable
rows={whereUsed}
columns={whereUsedCols}
getRowKey={(row, index) =>
`${row.depth}-${row.jobOrderCode}-${row.producedItemCode}-${row.producedLotNo}-${index}`
}
emptyLabel={t("issueNoWhereUsed")}
/>
</Box>
</AccordionDetails>
</Accordion>

<Accordion disableGutters elevation={0}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>{t("issueIngredients")}</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ overflowX: "auto" }}>
<FilterableDataTable
rows={ingredients}
columns={ingredientCols}
getRowKey={(row) => row.key}
emptyLabel={t("issueNoIngredients")}
/>
</Box>
</AccordionDetails>
</Accordion>
</Box>
</Paper>
);
};

export default ItemTracingIssuePanel;

+ 0
- 21
src/components/ItemTracing/ItemTracingLocations.tsx View File

@@ -15,8 +15,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import Inventory2Icon from "@mui/icons-material/Inventory2";
import { useTranslation } from "react-i18next";
import { ItemLotTraceLocationBlock } from "@/app/api/itemTracing";
import type { WarehouseFocusRequest } from "./ItemTracing";
import { blockWarehouseCode } from "./buildLocationBlockGraphNodes";
import { createTraceLabelTranslator } from "./traceLabelUtils";
import {
resolveStockTakeAcceptedQty,
@@ -29,7 +27,6 @@ import {

interface Props {
locationBlocks: ItemLotTraceLocationBlock[];
onFocusWarehouse?: (request: WarehouseFocusRequest) => void;
}

/** Compact sub-table for sections that may have many rows. */
@@ -73,7 +70,6 @@ function SectionTable<T>({
/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */
const ItemTracingLocations: React.FC<Props> = ({
locationBlocks,
onFocusWarehouse,
}) => {
const { t } = useTranslation("itemTracing");
const tr = createTraceLabelTranslator(t);
@@ -164,23 +160,6 @@ const ItemTracingLocations: React.FC<Props> = ({
variant="outlined"
/>
)}
{onFocusWarehouse ? (
<Chip
label={t("focusWarehouseInGraph")}
size="small"
color="primary"
variant="filled"
clickable
onClick={(e) => {
e.stopPropagation();
onFocusWarehouse({
inventoryLotId: block.inventoryLotId,
warehouseCode: blockWarehouseCode(block),
});
}}
sx={{ ml: "auto" }}
/>
) : null}
</Stack>
</AccordionSummary>



+ 9
- 0
src/components/ItemTracing/ItemTracingScanBar.tsx View File

@@ -107,6 +107,9 @@ const ItemTracingScanBar: React.FC<ScanBarProps> = ({
)}

<Typography variant="subtitle2">{t("manualSearch")}</Typography>
<Typography variant="caption" color="text.secondary">
{t("manualSearchHint", { action: t("search") })}
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
size="small"
@@ -115,6 +118,9 @@ const ItemTracingScanBar: React.FC<ScanBarProps> = ({
onChange={(e) => setItemCode(e.target.value)}
disabled={loading}
fullWidth
onKeyDown={(event) => {
if (event.key === "Enter") handleManualSearch();
}}
/>
<TextField
size="small"
@@ -123,6 +129,9 @@ const ItemTracingScanBar: React.FC<ScanBarProps> = ({
onChange={(e) => setLotNo(e.target.value)}
disabled={loading}
fullWidth
onKeyDown={(event) => {
if (event.key === "Enter") handleManualSearch();
}}
/>
<Button
variant="contained"


+ 32
- 8
src/components/ItemTracing/ItemTracingSections.tsx View File

@@ -85,6 +85,19 @@ const ItemTracingSections: React.FC<Props> = ({ data, compiledGraph, onTrace })
}, [joPrelude]);

const activeTab = tabKeys[tab] ?? tabKeys[0];
const tabCount: Record<string, number> = {
origins: mergedOrigins.length,
qc: mergedQc.length,
outbound: mergedOutbound.length,
stockTake: mergedStockTake.length,
adjustments: mergedAdjustments.length,
transfers: mergedTransfers.length,
bom:
bomTrace.downstream.length +
bomTrace.upstream.length +
bomTrace.bomRecipe.length,
joPick: joPrelude?.pickOrders.length ?? 0,
};

const bomDirectionLabel =
bomTrace.direction === "FINISHED_GOOD"
@@ -724,15 +737,26 @@ const ItemTracingSections: React.FC<Props> = ({ data, compiledGraph, onTrace })
onChange={(_, v) => setTab(v)}
variant="scrollable"
scrollButtons="auto"
sx={{
"& .MuiTabs-flexContainer": {
minWidth: "100%",
justifyContent: "space-evenly",
columnGap: "24px",
px: 3,
},
"& .MuiTab-root + .MuiTab-root": {
marginLeft: 0,
},
}}
>
<Tab label={t("tabOrigins")} />
<Tab label={t("tabQc")} />
<Tab label={t("tabOutbound")} />
<Tab label={t("tabStockTake")} />
<Tab label={t("tabAdjustments")} />
<Tab label={t("tabTransfers")} />
<Tab label={t("tabBom")} />
{joPrelude && <Tab label={t("tabJoPick")} />}
<Tab label={`${t("tabOrigins")} (${tabCount.origins})`} />
<Tab label={`${t("tabQc")} (${tabCount.qc})`} />
<Tab label={`${t("tabOutbound")} (${tabCount.outbound})`} />
<Tab label={`${t("tabStockTake")} (${tabCount.stockTake})`} />
<Tab label={`${t("tabAdjustments")} (${tabCount.adjustments})`} />
<Tab label={`${t("tabTransfers")} (${tabCount.transfers})`} />
<Tab label={`${t("tabBom")} (${tabCount.bom})`} />
{joPrelude && <Tab label={`${t("tabJoPick")} (${tabCount.joPick})`} />}
</Tabs>
<Box sx={{ p: 2, overflowX: "auto" }}>
{multiLocation && (


+ 13
- 57
src/components/ItemTracing/ItemTracingSummary.tsx View File

@@ -14,17 +14,16 @@ import {
TableRow,
Typography,
} from "@mui/material";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import ItemTracingDocLink from "./ItemTracingDocLink";
import { createTraceLabelTranslator } from "./traceLabelUtils";
import { formatQty } from "./traceQtyUtils";
import type { WarehouseFocusRequest } from "./ItemTracing";

type Props = {
data: ItemLotTraceResponse;
onFocusWarehouse?: (request: WarehouseFocusRequest) => void;
onExportExcel?: () => void;
};

@@ -41,7 +40,6 @@ type SummaryWarehouseRow = {
/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */
const ItemTracingSummary: React.FC<Props> = ({
data,
onFocusWarehouse,
onExportExcel,
}) => {
const { t } = useTranslation("itemTracing");
@@ -94,18 +92,13 @@ const ItemTracingSummary: React.FC<Props> = ({
lot.inventoryLotId,
]);

const totalAvailable = combinedWarehouseRows.reduce(
(s, w) => s + (w.availableQty ?? 0),
0,
);

return (
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
spacing={2}
>
<Typography variant="overline" color="text.secondary">
{t("summary")}
@@ -114,36 +107,22 @@ const ItemTracingSummary: React.FC<Props> = ({
<Button
size="small"
variant="outlined"
startIcon={<FileDownloadOutlinedIcon />}
onClick={onExportExcel}
title={t("exportExcelTooltip")}
title={t("exportHistoryExcelTooltip")}
>
{t("exportExcel")}
{t("exportHistoryExcel")}
</Button>
)}
</Stack>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ md: "center" }}
sx={{ mt: 1 }}
>
<Box sx={{ flex: 1 }}>
<Typography variant="h5" fontWeight={700}>
{lot.lotNo || "—"}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{lot.itemCode} · {lot.itemName}
</Typography>
</Box>
<Chip
label={`${t("totalAvailable")}: ${formatQty(
totalAvailable,
lot.uom,
)}`}
color="primary"
variant="outlined"
/>
</Stack>
<Box sx={{ mt: 1 }}>
<Typography variant="h5" fontWeight={700}>
{lot.lotNo || "—"}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{lot.itemCode} · {lot.itemName}
</Typography>
</Box>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
@@ -229,9 +208,6 @@ const ItemTracingSummary: React.FC<Props> = ({
<TableCell align="right">{t("outQty")}</TableCell>
<TableCell align="right">{t("available")}</TableCell>
<TableCell>{t("status")}</TableCell>
{onFocusWarehouse && (
<TableCell align="right">{t("action")}</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
@@ -258,26 +234,6 @@ const ItemTracingSummary: React.FC<Props> = ({
"—"
)}
</TableCell>
{onFocusWarehouse && (
<TableCell align="right">
{w.warehouseCode?.trim() ? (
<Button
size="small"
variant="outlined"
onClick={() =>
onFocusWarehouse({
inventoryLotId: w.inventoryLotId,
warehouseCode: w.warehouseCode,
})
}
>
{t("focusWarehouseInGraph")}
</Button>
) : (
"—"
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>


+ 269
- 226
src/components/ItemTracing/TraceFlowNodes.tsx View File

@@ -21,6 +21,7 @@ import {
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import ItemTracingDocLink from "./ItemTracingDocLink";
import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink";
@@ -33,7 +34,6 @@ import { formatQty, formatSignedQty } from "./traceQtyUtils";
import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils";
import { pickStatusValueColor, resolveDoOutboundQtyColor } from "./traceLabelUtils";

const PHASE_LABEL_INNER = 96;
/** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */
const STOCK_TAKE_LIFECYCLE_PANEL_EXTRA = 320;

@@ -73,6 +73,14 @@ const GroupCollapseButton = ({
</IconButton>
);

const cardClockLabel = (timestamp: string | null | undefined, fallback: string): string => {
const raw = timestamp?.trim();
if (!raw) return fallback;
const d = dayjs(raw);
if (!d.isValid()) return raw;
return raw.length <= 10 ? d.format("MM-DD") : d.format("HH:mm");
};

/** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */
export const TraceFlowEventNode = memo(function TraceFlowEventNode({
id,
@@ -129,6 +137,7 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({
? node.qcTypeLabel
: t(kindLabelKey(node.kind));
const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp");
const clockLabel = cardClockLabel(node.timestamp, t("noTimestamp"));
const isDoOut = node.kind === "DO_OUT";
const doOutKindChipSx = {
height: data.compact ? 18 : 20,
@@ -214,6 +223,56 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({
const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };

const activeCount = lifecycleStages.filter((s) => s.isActive).length;
const qtyValueText =
node.kind === "ADJUSTMENT"
? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
: formatQty(node.qty, node.uom);
const showQty = node.qty != null || node.kind === "STOCK_TAKE";
const qtyCaption = showQty
? `${
node.kind === "PURCHASE"
? t("detailOrderQty")
: node.kind === "STOCK_TAKE"
? t("after")
: node.kind === "ADJUSTMENT"
? t("variance")
: node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL"
? t("unqualifiedQty")
: node.kind === "PRODUCTION_STEP"
? t("processOutputQty")
: t("qty")
}: ${qtyValueText}`
: null;
const qtyColor =
node.kind === "DO_OUT" || node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK"
? resolveDoOutboundQtyColor(
node.qty,
node.kind === "DO_OUT" ? node.doOutboundQtyChanged : false,
)
: undefined;
const varianceCaption =
node.kind === "STOCK_TAKE"
? `${t("variance")}: ${formatQty(node.stockTakeVarianceQty, node.uom)}`
: null;
const varianceColor =
node.stockTakeVarianceQty != null && node.stockTakeVarianceQty > 0
? "success.main"
: node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0
? "error.main"
: "text.primary";
const itemText =
node.kind === "MATERIAL_PICK" && (node.meta?.trim() || node.traceItemCode?.trim())
? `${t("Item")}: ${node.meta?.trim() || node.traceItemCode}`
: "";
const contextText = [node.subtitle?.trim(), itemText].filter(Boolean).join(" · ");
const warehouseText = node.warehouseCode?.trim()
? `${t("warehouse")}: ${node.warehouseCode.trim()}`
: "";
const showPickStatus = node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK";
const statusColor = (code: string | undefined) => {
const c = pickStatusValueColor(code);
return c === "default" ? "text.primary" : `${c}.main`;
};

const card = (
<Paper
@@ -227,6 +286,7 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({
flexDirection: "column",
overflow: "hidden",
position: "relative",
bgcolor: "background.paper",
borderColor: searchFocused
? "primary.main"
: nodeSelected
@@ -258,168 +318,137 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({
}}
/>
))}
<Box sx={{ height: 4, bgcolor: `${color}.main`, flexShrink: 0 }} />
<Box sx={{ height: 3, bgcolor: `${color}.main`, flexShrink: 0 }} />
<Box
sx={{
p: data.compact ? 1 : 1.25,
pb: hasLifecycle ? (data.compact ? 0.5 : 0.75) : undefined,
px: 1,
pt: 0.75,
pb: 0.5,
flex: 1,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
display: "flex",
flexDirection: "column",
gap: 0.25,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 0.5,
mb: 0.75,
}}
>
{isQc && (
<Box
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, minWidth: 0 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
minWidth: 0,
flex: 1,
overflow: "hidden",
}}
>
{isQc && (
<Box
sx={{
width: 8,
height: 8,
bgcolor: `${color}.main`,
transform: "rotate(45deg)",
flexShrink: 0,
}}
/>
)}
<Chip
size="small"
label={chipLabel}
color={color}
sx={{
width: 8,
height: 8,
bgcolor: `${color}.main`,
transform: "rotate(45deg)",
height: 20,
flexShrink: 0,
maxWidth: "100%",
"& .MuiChip-label": { px: 0.75, fontSize: "0.7rem", fontWeight: 700 },
}}
/>
)}
<Chip size="small" label={chipLabel} color={color} />
{doOutboundKindChips}
{doOutboundKindChips}
</Box>
<Typography
component="span"
title={dateLabel}
sx={{ flexShrink: 0, fontSize: 12, fontWeight: 700, lineHeight: 1, color: "text.secondary" }}
>
{clockLabel}
</Typography>
</Box>
<Box
sx={{
lineHeight: 1.3,
fontSize: data.compact ? "0.8rem" : undefined,
fontSize: "0.8125rem",
fontWeight: 700,
lineHeight: 1.3,
minWidth: 0,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
...(isDoOut
? { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.5 }
? { whiteSpace: "nowrap", textOverflow: "ellipsis" }
: {
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflowWrap: "anywhere",
}),
}}
title={typeof node.refCode === "string" && node.refCode.trim() ? node.refCode : node.title}
>
{titleContent}
</Box>
{node.subtitle?.trim() ? (
{contextText ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.subtitle}
sx={{
mt: 0.5,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{node.subtitle}
</Typography>
) : null}
{node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? (
<Typography
variant="body2"
fontWeight={600}
title={node.meta?.trim() || node.traceItemCode}
sx={{
mt: 0.5,
fontSize: data.compact ? "0.8rem" : undefined,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
noWrap
title={contextText}
sx={{ display: "block", fontWeight: 600, lineHeight: 1.3 }}
>
{t("Item")}: {node.meta?.trim() || node.traceItemCode}
{contextText}
</Typography>
) : null}
{(node.qty != null || node.kind === "STOCK_TAKE") && (
<Typography
variant="body2"
fontWeight={600}
title={
node.kind === "ADJUSTMENT"
? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
: formatQty(node.qty, node.uom)
}
sx={{
mt: 0.5,
fontSize: data.compact ? "0.8rem" : undefined,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
color:
node.kind === "DO_OUT" ||
node.kind === "JO_OUT" ||
node.kind === "MATERIAL_PICK"
? resolveDoOutboundQtyColor(
node.qty,
node.kind === "DO_OUT" ? node.doOutboundQtyChanged : false,
)
: undefined,
}}
{(qtyCaption || varianceCaption || warehouseText) && (
<Box
title={[qtyCaption, varianceCaption, warehouseText].filter(Boolean).join(" · ")}
sx={{ display: "flex", alignItems: "baseline", gap: 0.75, minWidth: 0, overflow: "hidden" }}
>
{node.kind === "PURCHASE"
? t("detailOrderQty")
: node.kind === "STOCK_TAKE"
? t("after")
: node.kind === "ADJUSTMENT"
? t("variance")
: node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL"
? t("unqualifiedQty")
: node.kind === "PRODUCTION_STEP"
? t("processOutputQty")
: t("qty")}
:{" "}
{node.kind === "ADJUSTMENT"
? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
: formatQty(node.qty, node.uom)}
</Typography>
{qtyCaption ? (
<Typography
component="span"
noWrap
sx={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3, color: qtyColor, flexShrink: 0 }}
>
{qtyCaption}
</Typography>
) : null}
{varianceCaption ? (
<Typography
component="span"
noWrap
sx={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3, color: varianceColor, flexShrink: 0 }}
>
{varianceCaption}
</Typography>
) : null}
{warehouseText ? (
<Typography
component="span"
noWrap
sx={{
fontSize: 11,
fontWeight: 600,
lineHeight: 1.3,
color: "text.secondary",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{warehouseText}
</Typography>
) : null}
</Box>
)}
{node.kind === "STOCK_TAKE" ? (
<Typography
variant="body2"
fontWeight={600}
title={formatQty(node.stockTakeVarianceQty, node.uom)}
sx={{
mt: 0.25,
fontSize: data.compact ? "0.8rem" : undefined,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
color:
node.stockTakeVarianceQty != null && node.stockTakeVarianceQty > 0
? "success.main"
: node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0
? "error.main"
: undefined,
}}
>
{t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)}
</Typography>
) : null}
{node.traceLotNo ? (
<ItemTracingLotTraceLink
label={
@@ -432,98 +461,40 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({
lotNo={node.traceLotNo}
itemCode={node.traceItemCode}
stopPropagation
sx={{ mt: 0.5, display: "inline-block" }}
/>
) : null}
{node.warehouseCode?.trim() ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.warehouseCode}
sx={{
mt: 0.5,
fontWeight: 600,
display: "block",
maxWidth: "100%",
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{t("warehouse")}: {node.warehouseCode}
</Typography>
) : null}
{(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
node.processingStatusLabel?.trim() ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.processingStatusLabel}
sx={{
mt: 0.5,
fontWeight: 600,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
}}
>
{t("processingStatus")}:{" "}
<Box
component="span"
sx={{
color: (() => {
const c = pickStatusValueColor(node.processingStatus);
return c === "default" ? "text.primary" : `${c}.main`;
})(),
fontWeight: 700,
}}
>
{node.processingStatusLabel}
</Box>
</Typography>
/>
) : null}
{(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
node.matchStatusLabel?.trim() ? (
{showPickStatus && (node.processingStatusLabel?.trim() || node.matchStatusLabel?.trim()) ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.matchStatusLabel}
sx={{
mt: 0.5,
fontWeight: 600,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
}}
noWrap
title={[node.processingStatusLabel, node.matchStatusLabel].filter(Boolean).join(" · ")}
sx={{ fontSize: 11, fontWeight: 600, color: "text.secondary", lineHeight: 1.3 }}
>
{t("matchStatus")}:{" "}
<Box
component="span"
sx={{
color: (() => {
const c = pickStatusValueColor(node.matchStatus);
return c === "default" ? "text.primary" : `${c}.main`;
})(),
fontWeight: 700,
}}
>
{node.matchStatusLabel}
</Box>
{node.processingStatusLabel?.trim() ? (
<>
{t("processingStatus")}:{" "}
<Box component="span" sx={{ color: statusColor(node.processingStatus), fontWeight: 700 }}>
{node.processingStatusLabel}
</Box>
</>
) : null}
{node.processingStatusLabel?.trim() && node.matchStatusLabel?.trim() ? " · " : null}
{node.matchStatusLabel?.trim() ? (
<>
{t("matchStatus")}:{" "}
<Box component="span" sx={{ color: statusColor(node.matchStatus), fontWeight: 700 }}>
{node.matchStatusLabel}
</Box>
</>
) : null}
</Typography>
) : null}
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}>
{dateLabel}
</Typography>
</Box>
{hasLifecycle ? (
<Box
@@ -652,29 +623,39 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({

export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({
data,
width,
height,
}: NodeProps<Node<TraceFlowNodeData>>) {
return (
<Box
sx={{
width: PHASE_LABEL_INNER,
width: width ?? "100%",
height: height ?? "100%",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
alignItems: "flex-start",
justifyContent: "center",
px: 0.75,
py: 1,
px: 1,
pt: 1.25,
bgcolor: "background.paper",
borderLeft: `4px solid ${data.phaseColor ?? "#757575"}`,
backgroundImage: `linear-gradient(${data.phaseColor ?? "#757575"}14, ${data.phaseColor ?? "#757575"}14)`,
borderRadius: 1,
borderRight: 1,
borderBottom: 1,
borderColor: "divider",
pointerEvents: "none",
}}
>
<Typography
variant="caption"
fontWeight={700}
sx={{
writingMode: "vertical-rl",
textOrientation: "mixed",
fontSize: 12,
fontWeight: 800,
lineHeight: 1.25,
textAlign: "center",
color: data.phaseColor ?? "#757575",
letterSpacing: 0.5,
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{data.phaseLabel}
@@ -685,11 +666,71 @@ export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({

export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({
data,
width,
height,
}: NodeProps<Node<TraceFlowNodeData>>) {
return (
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ width: 80, textAlign: "center" }}>
{data.dateLabel}
</Typography>
<Box
sx={{
width: width ?? "100%",
height: height ?? "100%",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 1,
bgcolor: "background.paper",
borderBottom: "2px solid",
borderRight: "1px solid",
borderColor: "divider",
pointerEvents: "none",
}}
>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ letterSpacing: 0.2 }}>
{data.dateLabel}
</Typography>
</Box>
);
});

export const TraceFlowDayColumnNode = memo(function TraceFlowDayColumnNode({
data,
width,
height,
}: NodeProps<Node<TraceFlowNodeData>>) {
const alt = (data.bandIndex ?? 0) % 2 === 1;
return (
<Box
sx={{
width: width ?? "100%",
height: height ?? "100%",
boxSizing: "border-box",
bgcolor: alt ? "rgba(25, 118, 210, 0.05)" : "rgba(0, 0, 0, 0.03)",
borderRight: "1px solid",
borderColor: "divider",
pointerEvents: "none",
}}
/>
);
});

export const TraceFlowLaneBandNode = memo(function TraceFlowLaneBandNode({
data,
width,
height,
}: NodeProps<Node<TraceFlowNodeData>>) {
const color = data.phaseColor ?? "#757575";
return (
<Box
sx={{
width: width ?? "100%",
height: height ?? "100%",
boxSizing: "border-box",
bgcolor: `${color}14`,
borderBottom: `1px solid ${color}55`,
pointerEvents: "none",
}}
/>
);
});

@@ -875,4 +916,6 @@ export const traceFlowNodeTypes = {
pickGroup: TraceFlowPickGroupNode,
phaseLabel: TraceFlowPhaseLabelNode,
dateHeader: TraceFlowDateHeaderNode,
dayColumn: TraceFlowDayColumnNode,
laneBand: TraceFlowLaneBandNode,
};

+ 444
- 0
src/components/ItemTracing/buildIssueTraceRows.ts View File

@@ -0,0 +1,444 @@
import type { TFunction } from "i18next";
import type {
ItemLotTraceDoDelivery,
ItemLotTraceReplenishmentEvent,
ItemLotTraceResponse,
ItemLotTraceWhereUsed,
} from "@/app/api/itemTracing";
import {
mergeScopedDoDeliveries,
mergeScopedReplenishmentEvents,
} from "./mergeLocationScopedData";
import { formatQty } from "./traceQtyUtils";

const REASON_ORDER = ["DIRECT", "MADE_FROM", "REPLENISH", "REPLENISH_MADE"] as const;

export type IssueStoreReason = (typeof REASON_ORDER)[number];

export type IssueStoreItem = {
itemCode: string;
itemName: string;
lotNos: string[];
};

export type IssueStoreQty = {
uom: string;
qty: number;
};

/** One row per store. Receipt lines are counted, not repeated. */
export type IssueStoreRow = {
key: string;
reasons: IssueStoreReason[];
receiptCount: number;
items: IssueStoreItem[];
qtyByUom: IssueStoreQty[];
shopCode: string;
shopName: string;
contactNo: string;
address: string;
district: string;
deliveryOrderCodes: string[];
latestTimestamp: string | null;
};

export type IssueIngredientKind = "ACTUAL" | "RECIPE";

export type IssueIngredientRow = {
key: string;
kind: IssueIngredientKind;
itemCode: string;
itemName: string;
lotNo: string;
qty: number | null;
uom: string;
qtyPerUnit: number | null;
jobOrderCode: string;
pickOrderCode: string;
assignedStepName: string;
pickedAt: string | null;
};

const asNumber = (value: unknown): number => {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : 0;
};

const asNumberOrNull = (value: unknown): number | null => {
if (value == null || value === "") return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
};

const reasonLabel = (reason: IssueStoreReason, t: TFunction): string => {
if (reason === "REPLENISH_MADE") return t("issueRelationReplenishMade");
if (reason === "REPLENISH") return t("issueRelationReplenish");
if (reason === "MADE_FROM") return t("issueRelationMadeFrom");
return t("issueRelationDirect");
};

export const issueStoreReasonLines = (row: IssueStoreRow, t: TFunction): string[] => {
const labels = row.reasons.map((reason) => reasonLabel(reason, t));
return labels.length > 0 ? labels : [t("issueRelationDirect")];
};

export const issueStoreReason = (row: IssueStoreRow, t: TFunction): string =>
issueStoreReasonLines(row, t).join("\n");

/** One line per lot. A store that received several lots does not collapse them. */
export const issueStoreItemLines = (row: IssueStoreRow): string[] => {
const lines: string[] = [];
row.items.forEach((item) => {
const name = [item.itemCode, item.itemName].filter(Boolean).join(" · ");
if (item.lotNos.length === 0) {
if (name) lines.push(name);
return;
}
item.lotNos.forEach((lot) => {
const line = [name, lot].filter(Boolean).join(" / ");
if (line) lines.push(line);
});
});
return lines.length > 0 ? lines : ["—"];
};

export const issueStoreItemsLabel = (row: IssueStoreRow): string =>
issueStoreItemLines(row).join("\n");

/** CF05 and CF005 are the same store code; the name repeats the padded form. */
const sameShopCode = (left: string, right: string): boolean => {
const norm = (value: string): string => {
const match = value.trim().toUpperCase().match(/^([A-Z]+)(\d+)$/);
if (!match) return value.trim().toUpperCase();
return `${match[1]}${Number(match[2])}`;
};
const a = norm(left);
const b = norm(right);
return a !== "" && a === b;
};

/** Drop a leading "CT005 - " when it is the same code as the shop code. */
export const shopNameWithoutCode = (shopCode: string, shopName: string): string => {
const name = shopName.trim();
const prefixed = name.match(/^([A-Za-z0-9]+)\s*-\s*(.+)$/);
if (prefixed && sameShopCode(shopCode, prefixed[1])) return prefixed[2].trim();
if (sameShopCode(shopCode, name)) return "";
return name;
};

export const issueStoreLabel = (row: Pick<IssueStoreRow, "shopCode" | "shopName">): string => {
const code = row.shopCode.trim();
const name = shopNameWithoutCode(code, row.shopName);
return [code, name].filter(Boolean).join(" · ") || "—";
};

/** Hong Kong shop numbers are 8 digits, stored as 25517587, 2551-7587, or 2551 7587. */
export const formatShopPhone = (phone: string): string => {
const raw = phone.trim();
if (!raw) return "—";
const digits = raw.replace(/\D/g, "");
if (digits.length === 8) return `${digits.slice(0, 4)} ${digits.slice(4)}`;
return raw.replace(/\s+/g, " ");
};

export const issueStoreQtyLines = (row: IssueStoreRow): string[] => {
if (row.qtyByUom.length === 0) return ["—"];
return row.qtyByUom.map((part) => formatQty(part.qty, part.uom));
};

export const issueStoreQtyLabel = (row: IssueStoreRow): string =>
issueStoreQtyLines(row).join("\n");

export const buildIssueWhereUsedRows = (
data: ItemLotTraceResponse,
): ItemLotTraceWhereUsed[] => {
if (data.issueTrace) return data.issueTrace.whereUsed ?? [];
return data.bomTrace.downstream.map((row) => ({
depth: 1,
consumedItemCode: data.lot.itemCode,
consumedItemName: data.lot.itemName,
consumedLotNo: data.lot.lotNo,
materialQtyUsed: asNumber(row.materialQtyUsed),
jobOrderCode: row.jobOrderCode,
jobOrderId: row.jobOrderId,
producedItemCode: row.finishedItemCode,
producedItemName: "",
producedLotNo: row.finishedLotNo,
producedQty: asNumber(row.fgQty),
producedUom: row.fgUom ?? "",
producedInventoryLotId: null,
producedStockInLineId: row.finishedStockInLineId,
}));
};

const depthByProducedLot = (rows: ItemLotTraceWhereUsed[]): Map<number, number> => {
const depths = new Map<number, number>();
rows.forEach((row) => {
const lotId = row.producedInventoryLotId;
if (lotId == null) return;
const prev = depths.get(lotId);
if (prev == null || row.depth < prev) depths.set(lotId, row.depth);
});
return depths;
};

const uomByProducedLot = (rows: ItemLotTraceWhereUsed[]): Map<number, string> => {
const uoms = new Map<number, string>();
rows.forEach((row) => {
if (row.producedInventoryLotId == null || !row.producedUom) return;
uoms.set(row.producedInventoryLotId, row.producedUom);
});
return uoms;
};

type StoreReceipt = {
reason: IssueStoreReason;
itemCode: string;
itemName: string;
lotNo: string;
shopCode: string;
shopName: string;
contactNo: string;
address: string;
district: string;
qty: number;
uom: string;
deliveryOrderCode: string;
timestamp: string | null;
};

const shopGroupKey = (shopCode: string, shopName: string): string => {
const code = shopCode.trim().toLowerCase();
if (code) return `code:${code}`;
const name = shopName.trim().toLowerCase();
if (name) return `name:${name}`;
return "unknown";
};

const preferText = (current: string, next: string): string => current.trim() || next.trim();

const groupStoreReceipts = (receipts: StoreReceipt[]): IssueStoreRow[] => {
const groups = new Map<string, {
reasons: Set<IssueStoreReason>;
items: Map<string, IssueStoreItem>;
qtyByUom: Map<string, number>;
row: IssueStoreRow;
}>();

receipts.forEach((receipt) => {
const key = shopGroupKey(receipt.shopCode, receipt.shopName);
let group = groups.get(key);
if (!group) {
group = {
reasons: new Set(),
items: new Map(),
qtyByUom: new Map(),
row: {
key,
reasons: [],
receiptCount: 0,
items: [],
qtyByUom: [],
shopCode: receipt.shopCode.trim(),
shopName: receipt.shopName.trim(),
contactNo: receipt.contactNo.trim(),
address: receipt.address.trim(),
district: receipt.district.trim(),
deliveryOrderCodes: [],
latestTimestamp: receipt.timestamp,
},
};
groups.set(key, group);
}

const row = group.row;
group.reasons.add(receipt.reason);
row.receiptCount += 1;
row.shopCode = preferText(row.shopCode, receipt.shopCode);
row.shopName = preferText(row.shopName, receipt.shopName);
row.contactNo = preferText(row.contactNo, receipt.contactNo);
row.address = preferText(row.address, receipt.address);
row.district = preferText(row.district, receipt.district);
if (
receipt.timestamp &&
(!row.latestTimestamp || receipt.timestamp > row.latestTimestamp)
) {
row.latestTimestamp = receipt.timestamp;
}
const orderCode = receipt.deliveryOrderCode.trim();
if (orderCode && !row.deliveryOrderCodes.includes(orderCode)) {
row.deliveryOrderCodes.push(orderCode);
}

const itemKey = `${receipt.itemCode.trim().toLowerCase()}|${receipt.itemName.trim().toLowerCase()}`;
let item = group.items.get(itemKey);
if (!item) {
item = {
itemCode: receipt.itemCode.trim(),
itemName: receipt.itemName.trim(),
lotNos: [],
};
group.items.set(itemKey, item);
}
const lotNo = receipt.lotNo.trim();
if (lotNo && !item.lotNos.includes(lotNo)) item.lotNos.push(lotNo);

const uom = receipt.uom.trim();
group.qtyByUom.set(uom, (group.qtyByUom.get(uom) ?? 0) + receipt.qty);
});

return Array.from(groups.values()).map((group) => {
const row = group.row;
row.reasons = REASON_ORDER.filter((reason) => group.reasons.has(reason));
row.items = Array.from(group.items.values()).sort((a, b) =>
(a.itemCode || a.itemName).localeCompare(b.itemCode || b.itemName),
);
row.qtyByUom = Array.from(group.qtyByUom.entries())
.map(([uom, qty]) => ({ uom, qty }))
.sort((a, b) => a.uom.localeCompare(b.uom));
row.deliveryOrderCodes.sort((a, b) => a.localeCompare(b));
return row;
}).sort((a, b) => {
if (a.receiptCount !== b.receiptCount) return b.receiptCount - a.receiptCount;
return (a.shopCode || a.shopName).localeCompare(b.shopCode || b.shopName);
});
};

export const buildIssueStoreRows = (data: ItemLotTraceResponse): IssueStoreRow[] => {
const whereUsed = buildIssueWhereUsedRows(data);
const depths = depthByProducedLot(whereUsed);
const uoms = uomByProducedLot(whereUsed);
const seenStockOut = new Set<number>();
const receipts: StoreReceipt[] = [];

const pushDelivery = (
delivery: ItemLotTraceDoDelivery,
relation: "DIRECT" | "MADE_FROM",
) => {
if (delivery.stockOutLineId && seenStockOut.has(delivery.stockOutLineId)) return;
if (delivery.stockOutLineId) seenStockOut.add(delivery.stockOutLineId);
const lotId = delivery.inventoryLotId ?? 0;
receipts.push({
reason: relation,
itemCode: delivery.itemCode || data.lot.itemCode,
itemName: delivery.itemName || data.lot.itemName,
lotNo: delivery.lotNo || data.lot.lotNo,
shopCode: delivery.shopCode ?? "",
shopName: delivery.shopName ?? "",
contactNo: delivery.shopContactNo ?? "",
address: delivery.shopAddress ?? "",
district: delivery.shopDistrict ?? "",
qty: asNumber(delivery.qty),
uom: relation === "DIRECT" ? data.lot.uom : (uoms.get(lotId) || data.lot.uom),
deliveryOrderCode: delivery.deliveryOrderCode,
timestamp: delivery.timestamp,
});
};

const pushReplenishment = (
event: ItemLotTraceReplenishmentEvent,
depth: number,
fallbackLotNo: string,
) => {
if (event.stockOutLineId && seenStockOut.has(event.stockOutLineId)) return;
if (event.stockOutLineId) seenStockOut.add(event.stockOutLineId);
receipts.push({
reason: depth > 0 ? "REPLENISH_MADE" : "REPLENISH",
itemCode: event.itemNo || data.lot.itemCode,
itemName: event.itemName || data.lot.itemName,
lotNo: event.lotNo || fallbackLotNo,
shopCode: event.shopCode,
shopName: event.shopName,
contactNo: event.shopContactNo ?? "",
address: event.shopAddress ?? "",
district: event.shopDistrict ?? "",
qty: asNumber(event.replenishQty),
uom: depth > 0 ? (uoms.get(event.inventoryLotId ?? 0) || "") : data.lot.uom,
deliveryOrderCode: event.sourceDoCode,
timestamp: event.timestamp,
});
};

mergeScopedDoDeliveries(data).forEach((delivery) => {
pushDelivery(delivery, "DIRECT");
});
mergeScopedReplenishmentEvents(data).forEach((event) => {
pushReplenishment(event, 0, data.lot.lotNo);
});
(data.issueTrace?.downstreamDeliveries ?? []).forEach((delivery) => {
pushDelivery(delivery, "MADE_FROM");
});
(data.issueTrace?.downstreamReplenishments ?? []).forEach((event) => {
const lotId = event.inventoryLotId;
const depth = lotId != null ? (depths.get(lotId) ?? 1) : 1;
pushReplenishment(event, depth, event.lotNo || "");
});

return groupStoreReceipts(receipts);
};

export const buildIssueIngredientRows = (
data: ItemLotTraceResponse,
): IssueIngredientRow[] => {
const rows: IssueIngredientRow[] = [];
const inputs = data.joPrelude?.materialInputs ?? [];

if (inputs.length > 0) {
inputs.forEach((input, index) => {
rows.push({
key: `actual-${index}-${input.materialItemCode}-${input.materialLotNo}`,
kind: "ACTUAL",
itemCode: input.materialItemCode,
itemName: input.materialItemName,
lotNo: input.materialLotNo,
qty: asNumberOrNull(input.materialQty),
uom: input.materialUom || "",
qtyPerUnit: asNumberOrNull(input.bomQtyPerUnit),
jobOrderCode: input.jobOrderCode,
pickOrderCode: input.pickOrderCode,
assignedStepName: input.assignedStepName ?? "",
pickedAt: input.pickedAt,
});
});
} else {
data.bomTrace.upstream.forEach((input, index) => {
rows.push({
key: `upstream-${index}-${input.materialItemCode}-${input.materialLotNo}`,
kind: "ACTUAL",
itemCode: input.materialItemCode,
itemName: "",
lotNo: input.materialLotNo,
qty: asNumberOrNull(input.materialQty),
uom: "",
qtyPerUnit: asNumberOrNull(input.bomQtyPerUnit),
jobOrderCode: input.jobOrderCode,
pickOrderCode: "",
assignedStepName: "",
pickedAt: null,
});
});
}

const recipeBelongsToThisItem =
data.bomTrace.direction === "FINISHED_GOOD" || data.joPrelude != null;
if (recipeBelongsToThisItem) {
data.bomTrace.bomRecipe.forEach((line, index) => {
rows.push({
key: `recipe-${index}-${line.materialItemCode}`,
kind: "RECIPE",
itemCode: line.materialItemCode,
itemName: line.materialItemName,
lotNo: "",
qty: null,
uom: line.uom,
qtyPerUnit: asNumberOrNull(line.qtyPerUnit),
jobOrderCode: data.joPrelude?.jobOrder.jobOrderCode ?? "",
pickOrderCode: "",
assignedStepName: line.assignedStepName ?? "",
pickedAt: null,
});
});
}

return rows;
};

+ 67
- 16
src/components/ItemTracing/buildReactFlowGraph.ts View File

@@ -44,6 +44,8 @@ export type TraceFlowNodeData = {
/** DO / pick group header collapse. */
groupCollapsed?: boolean;
onToggleGroupCollapse?: () => void;
/** Alternating day-column shade (0, 1, 2…). */
bandIndex?: number;
};

const cellKey = (laneIndex: number, column: number, stagger: number) =>
@@ -92,44 +94,93 @@ export const buildReactFlowGraph = (
});

const { laneTops, laneHeights, totalHeight } = computeLaneTops(layout.laneCount, cells);
const timelineWidth = layout.dayColumns.timelineWidth;
const chrome = {
draggable: false,
selectable: false,
connectable: false,
focusable: false,
} as const;

nodes.push({
id: "hdr-corner",
type: "dateHeader",
position: { x: 0, y: 0 },
data: { layoutNode: {} as TraceGraphLayoutNode, compact: false, dateLabel: "" },
style: { width: PHASE_LABEL_WIDTH, height: HEADER_HEIGHT, pointerEvents: "none" },
width: PHASE_LABEL_WIDTH,
height: HEADER_HEIGHT,
zIndex: 5,
...chrome,
});

layout.columnDates.forEach((date, col) => {
const dayW = columnWidths[col] ?? COLUMN_WIDTH_MIN;
const dayStart = columnStarts[col] ?? col * COLUMN_WIDTH_MIN;
const x = PHASE_LABEL_WIDTH + dayStart;
nodes.push({
id: `day-${col}`,
type: "dayColumn",
position: { x, y: 0 },
data: {
layoutNode: {} as TraceGraphLayoutNode,
compact: false,
dateLabel: date,
bandIndex: col,
},
style: { width: dayW, height: totalHeight, pointerEvents: "none" },
width: dayW,
height: totalHeight,
zIndex: -3,
...chrome,
});
nodes.push({
id: `hdr-${col}`,
type: "dateHeader",
position: {
x: PHASE_LABEL_WIDTH + dayStart + dayW / 2 - 40,
y: 6,
},
position: { x, y: 0 },
data: { layoutNode: {} as TraceGraphLayoutNode, compact: false, dateLabel: date },
draggable: false,
selectable: false,
connectable: false,
focusable: false,
style: { width: dayW, height: HEADER_HEIGHT, pointerEvents: "none" },
width: dayW,
height: HEADER_HEIGHT,
zIndex: 5,
...chrome,
});
});

layout.phaseOrder.forEach((phase, laneIdx) => {
const laneH = laneHeights[laneIdx] ?? LANE_MIN_HEIGHT;
const y = laneTops[laneIdx] ?? HEADER_HEIGHT;
nodes.push({
id: `lane-${phase}`,
type: "laneBand",
position: { x: PHASE_LABEL_WIDTH, y },
data: {
layoutNode: {} as TraceGraphLayoutNode,
compact: false,
phaseColor: phaseAccent(phase),
phaseLabel: phaseLabels[phase] ?? phase,
},
style: { width: timelineWidth, height: laneH, pointerEvents: "none" },
width: timelineWidth,
height: laneH,
zIndex: -2,
...chrome,
});
nodes.push({
id: `phase-${phase}`,
type: "phaseLabel",
position: {
x: 8,
y: laneTops[laneIdx] + Math.max(8, (laneH - 80) / 2),
},
position: { x: 0, y },
data: {
layoutNode: {} as TraceGraphLayoutNode,
compact: false,
phaseLabel: phaseLabels[phase] ?? phase,
phaseColor: phaseAccent(phase),
},
draggable: false,
selectable: false,
connectable: false,
focusable: false,
style: { width: PHASE_LABEL_WIDTH, height: laneH, pointerEvents: "none" },
width: PHASE_LABEL_WIDTH,
height: laneH,
zIndex: 4,
...chrome,
});
});



+ 352
- 39
src/components/ItemTracing/exportItemLotTraceXlsx.ts View File

@@ -31,19 +31,181 @@ import {
import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils";
import { buildTracePresentationRows } from "./tracePresentationAdapter";
import { isDoGroupChild } from "./traceDoGroupLayout";
import {
buildIssueIngredientRows,
buildIssueStoreRows,
buildIssueWhereUsedRows,
formatShopPhone,
issueStoreItemsLabel,
issueStoreReason,
shopNameWithoutCode,
} from "./buildIssueTraceRows";

const NO_DATA = "(無資料 / No records)";

const SHEET = {
summary: "摘要 Summary",
timeline: "生命週期時間軸 Timeline",
origins: "來源與採購 Origins",
qc: "品質檢驗 QC",
warehouse: "倉儲作業 Warehouse",
outbound: "出庫 Outbound",
stockTake: "盤點 Stock Take",
production: "生產與BOM Production",
} as const;
const HISTORY_SHEET_KEYS = [
"summary",
"timeline",
"origins",
"qc",
"warehouse",
"outbound",
"stockTake",
"production",
] as const;

const ISSUE_SHEET_KEYS = ["stores", "whereUsed", "ingredients"] as const;

const SHEET_KEYS = [...HISTORY_SHEET_KEYS, ...ISSUE_SHEET_KEYS] as const;

export type ItemLotTraceWorkbook = "history" | "issue";

/** Bilingual workbook text, rewritten to the active site language at export time. */
const EXCEL_PHRASE: Record<string, string> = {
[NO_DATA]: "excel.noData",
"Field / 欄位": "excel.col.field",
"Value / 值": "excel.col.value",
"Lot No. / 批號": "excel.summary.lotNo",
"Item Code / 貨品編號": "excel.col.itemCode",
"Item Name / 品名": "excel.col.itemName",
"UOM / 單位": "excel.col.uom",
"Expiry / 效期": "excel.summary.expiry",
"Production Date / 生產日期": "excel.summary.productionDate",
"Stock In Date / 入庫日期": "excel.summary.stockInDate",
"Total Available / 總可用量": "excel.summary.totalAvailable",
"BOM Direction / BOM 方向": "excel.summary.bomDirection",
"Issue Excel / 問題清單": "excel.summary.issueExcel",
"Job Order / 工單": "excel.col.jobOrder",
"Plan Start / 計劃開工": "excel.summary.planStart",
"Planned Qty / 計劃產量": "excel.summary.plannedQty",
"JO Status / 工單狀態": "excel.summary.joStatus",
"Export Time / 匯出時間": "excel.summary.exportTime",
"— Warehouse Breakdown / 各倉庫存量 —": "excel.summary.warehouseBreakdown",
"— Alternate Locations / 其他倉位 —": "excel.summary.alternateLocations",
"Why / 原因": "excel.col.why",
"Times Received / 收貨次數": "excel.col.timesReceived",
"Items / 收到的貨": "excel.col.itemsReceived",
"Latest / 最近一次": "excel.col.latest",
"Level / 層級": "excel.col.level",
"Shop Code / 門市編號": "excel.col.shopCode",
"Shop Name / 門市名稱": "excel.col.shopName",
"Contact / 聯絡人": "excel.col.contact",
"Phone / 電話": "excel.col.phone",
"Email / 電郵": "excel.col.email",
"Address / 地址": "excel.col.address",
"District / 地區": "excel.col.district",
"Lot / 批號": "excel.col.lot",
"Qty / 數量": "excel.col.qty",
"Delivery Order / 送貨單": "excel.col.deliveryOrder",
"Delivery Note / DN": "excel.col.deliveryNote",
"Ticket / 票號": "excel.col.ticket",
"Date/Time / 時間": "excel.col.dateTime",
"Handler / 經手人": "excel.col.handler",
"Consumed Item Code / 被用貨品編號": "excel.col.consumedItemCode",
"Consumed Item / 被用品名": "excel.col.consumedItem",
"Consumed Lot / 被用批號": "excel.col.consumedLot",
"Qty Used / 用量": "excel.col.qtyUsed",
"Produced Item Code / 製成品編號": "excel.col.producedItemCode",
"Produced Item / 製成品名": "excel.col.producedItem",
"Produced Lot / 製成批號": "excel.col.producedLot",
"Produced Qty / 製成數量": "excel.col.producedQty",
"Kind / 類型": "excel.col.kind",
"Qty Per Unit / 每單位用量": "excel.col.qtyPerUnit",
"Pick Order / 提料單": "excel.col.pickOrder",
"Assigned Step / 對應製程": "excel.col.assignedStep",
"Picked At / 提料時間": "excel.col.pickedAt",
"Phase / 階段": "excel.col.phase",
"Event Type / 事件類型": "excel.col.eventType",
"Doc No. / 單號": "excel.col.docNo",
"Ref Type / 來源類型": "excel.col.refType",
"Ref Type Code": "excel.col.refTypeCode",
"Warehouse / 倉位": "excel.col.warehouse",
"Remarks / 備註": "excel.col.remarks",
"Section / 區塊": "excel.col.section",
"Inventory Lot Id": "excel.col.inventoryLotId",
"Type / 類型": "excel.col.type",
"Type Code": "excel.col.typeCode",
"Supplier Code / 供應商編號": "excel.col.supplierCode",
"Supplier / 供應商": "excel.col.supplier",
"DN No. / 送貨單號": "excel.col.dnNo",
"Status / 狀態": "excel.col.status",
"Date / 日期": "excel.col.date",
"QC Result / 結果": "excel.col.qcResult",
"QC Type / 品檢類型": "excel.col.qcType",
"QC Type Code": "excel.col.qcTypeCode",
"QC Item Code / 品檢項編號": "excel.col.qcItemCode",
"QC Item / 品檢項": "excel.col.qcItem",
"Accepted Qty / 抽樣數量": "excel.col.sampleQty",
"Fail Qty / 不良數量": "excel.col.failQty",
"Stock In Line Id": "excel.col.stockInLineId",
"Direction / 方向": "excel.col.direction",
"From / 來源倉": "excel.col.from",
"To / 目標倉": "excel.col.to",
"Usage Type / 類型": "excel.col.usageType",
"Usage Type Code": "excel.col.usageTypeCode",
"Conso / 併單": "excel.col.conso",
"Shop / 門市": "excel.col.shop",
"Extra / 加單": "excel.col.extra",
"Replenish / 補貨": "excel.col.replenish",
"Stock Take Code / 盤點單號": "excel.col.stockTakeCode",
"Round / 輪次": "excel.col.round",
"Section / 區域": "excel.col.area",
"Book Qty / 帳面數量": "excel.col.bookQty",
"Accepted Qty / 核准數量": "excel.col.approvedQty",
"Variance Qty / 差異數量": "excel.col.varianceQty",
"Approver / 核准人": "excel.col.approver",
"Stock Taker / 初盤人": "excel.col.stockTaker",
"First Count Qty / 初盤數量": "excel.col.firstCountQty",
"Second Count Qty / 複盤數量": "excel.col.secondCountQty",
"Approver Count Qty / 覆盤數量": "excel.col.approverCountQty",
"Record Status / 紀錄狀態": "excel.col.recordStatus",
"Step / 步驟": "excel.col.step",
"Scrap Qty / 損耗數量": "excel.col.scrapQty",
"Defect Qty / 不良數量": "excel.col.defectQty",
"Operator / 操作員": "excel.col.operator",
"Equipment / 設備": "excel.col.equipment",
"Origin / 來源": "excel.section.origin",
"Purchase / 採購": "excel.section.purchase",
"Putaway / 上架": "excel.section.putaway",
"Transfer / 轉倉": "excel.section.transfer",
"Adjustment / 庫存調整": "excel.section.adjustment",
"Opening Stock / 開倉入庫": "excel.section.openingStock",
"Outbound Usage / 出庫使用": "excel.section.outboundUsage",
"DO Delivery / 成品出倉": "excel.section.doDelivery",
"Replenishment / 補貨": "excel.section.replenishment",
Replenishment: "excel.section.replenishment",
"Return / 退貨": "excel.section.return",
"Pick Failure / 揀貨異常": "excel.section.pickFailure",
"BOM Upstream / BOM 上游": "excel.section.bomUpstream",
"BOM Downstream / BOM 下游": "excel.section.bomDownstream",
"BOM Recipe / BOM 配方": "excel.section.bomRecipe",
"JO Pick / 工單提料": "excel.section.joPick",
"Production Step / 生產步驟": "excel.section.productionStep",
"Byproduct / 副產品": "excel.section.byproduct",
"Lot Relation / 拆批關聯": "excel.section.lotRelation",
};

const sheetTitle = (t: TFunction, key: (typeof SHEET_KEYS)[number]): string =>
t(`excel.sheet.${key}`).replace(/[\\/*?:[\]]/g, "").trim().slice(0, 31);

const localizeCell = (value: unknown, t: TFunction): unknown => {
if (typeof value !== "string") return value;
const phrase = EXCEL_PHRASE[value];
return phrase ? t(phrase) : value;
};

const localizeRows = (
rows: Record<string, unknown>[],
t: TFunction,
): Record<string, unknown>[] =>
rows.map((row) => {
const next: Record<string, unknown> = {};
Object.entries(row).forEach(([key, value]) => {
const headerKey = EXCEL_PHRASE[key];
next[headerKey ? t(headerKey) : key] = localizeCell(value, t);
});
return next;
});

export type ItemLotTraceExportLabels = {
bomDirectionFg: string;
@@ -89,10 +251,15 @@ const safeFilenamePart = (s: string): string =>

export const buildItemLotTraceFilename = (
data: ItemLotTraceResponse,
t: TFunction,
workbook: ItemLotTraceWorkbook = "history",
): string => {
const item = safeFilenamePart(data.lot.itemCode || "item");
const lot = safeFilenamePart(data.lot.lotNo || "lot");
return `批號追溯_${item}_${lot}_${todayYmd()}`;
const prefix = safeFilenamePart(
t(workbook === "issue" ? "excel.issueFilePrefix" : "excel.filePrefix"),
);
return `${prefix}_${item}_${lot}_${todayYmd()}`;
};

const summaryEmpty = (): Record<string, unknown> => ({
@@ -104,7 +271,9 @@ const buildSummarySheet = (
data: ItemLotTraceResponse,
labels: ItemLotTraceExportLabels,
exportAt: string,
t: TFunction,
): Record<string, unknown>[] => {
const tr = createTraceLabelTranslator(t);
const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data;
const totalAvailable = warehouseLines.reduce(
(s, w) => s + (w.availableQty ?? 0),
@@ -132,6 +301,10 @@ const buildSummarySheet = (
"Field / 欄位": "BOM Direction / BOM 方向",
"Value / 值": bomDirectionLabel(bomTrace.direction, labels),
},
{
"Field / 欄位": "Issue Excel / 問題清單",
"Value / 值": t("excel.summary.issueExcelNote"),
},
];

if (joPrelude) {
@@ -148,7 +321,7 @@ const buildSummarySheet = (
{ "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty },
{
"Field / 欄位": "JO Status / 工單狀態",
"Value / 值": orDash(jo.status),
"Value / 值": orDash(tr.joStatus(jo.status)),
},
);
}
@@ -165,8 +338,13 @@ const buildSummarySheet = (

warehouseLines.forEach((w) => {
rows.push({
"Field / 欄位": `Warehouse / 倉位: ${w.warehouseCode}`,
"Value / 值": `In ${w.inQty} · Out ${w.outQty} · Available ${w.availableQty} · ${w.status}`,
"Field / 欄位": t("excel.summary.warehouseLine", { code: w.warehouseCode }),
"Value / 值": t("excel.summary.warehouseQty", {
inQty: w.inQty,
outQty: w.outQty,
available: w.availableQty,
status: w.status ? tr.lotLineStatus(w.status) : "—",
}),
});
});

@@ -180,8 +358,11 @@ const buildSummarySheet = (
);
alternateLocations.forEach((loc) => {
rows.push({
"Field / 欄位": `Inventory Lot ${loc.inventoryLotId}`,
"Value / 值": `${loc.warehouseCode} · Available ${loc.availableQty}`,
"Field / 欄位": t("excel.summary.inventoryLot", { id: loc.inventoryLotId }),
"Value / 值": t("excel.summary.locationQty", {
code: loc.warehouseCode,
qty: loc.availableQty,
}),
});
});
}
@@ -189,6 +370,118 @@ const buildSummarySheet = (
return rows.length > 0 ? rows : [summaryEmpty()];
};

const storesEmpty = (): Record<string, unknown> => ({
"Shop Code / 門市編號": "",
"Shop Name / 門市名稱": "",
"Phone / 電話": "",
"Address / 地址": "",
"District / 地區": "",
"Times Received / 收貨次數": "",
"Qty / 數量": "",
"UOM / 單位": "",
"Why / 原因": "",
"Items / 收到的貨": "",
"Delivery Order / 送貨單": "",
"Latest / 最近一次": "",
});

const buildStoresSheet = (
data: ItemLotTraceResponse,
t: TFunction,
): Record<string, unknown>[] => {
const rows = buildIssueStoreRows(data).map((row) => {
const singleQty = row.qtyByUom.length === 1 ? row.qtyByUom[0] : null;
return {
"Shop Code / 門市編號": orDash(row.shopCode),
"Shop Name / 門市名稱": orDash(shopNameWithoutCode(row.shopCode, row.shopName)),
"Phone / 電話": formatShopPhone(row.contactNo),
"Address / 地址": orDash(row.address),
"District / 地區": orDash(row.district),
"Times Received / 收貨次數": row.receiptCount,
// One line per unit so "11" and "1包x2磅" stay paired instead of colliding.
"Qty / 數量": singleQty
? singleQty.qty
: row.qtyByUom.map((part) => part.qty).join("\n") || "—",
"UOM / 單位": singleQty
? orDash(singleQty.uom)
: row.qtyByUom.map((part) => part.uom || "—").join("\n") || "—",
"Why / 原因": issueStoreReason(row, t),
"Items / 收到的貨": issueStoreItemsLabel(row),
"Delivery Order / 送貨單": orDash(row.deliveryOrderCodes.join("\n")),
"Latest / 最近一次": orDash(row.latestTimestamp),
};
});
return ensureRows(rows, storesEmpty());
};

const whereUsedEmpty = (): Record<string, unknown> => ({
"Level / 層級": "",
"Consumed Item Code / 被用貨品編號": "",
"Consumed Item / 被用品名": "",
"Consumed Lot / 被用批號": "",
"Qty Used / 用量": "",
"Job Order / 工單": "",
"Produced Item Code / 製成品編號": "",
"Produced Item / 製成品名": "",
"Produced Lot / 製成批號": "",
"Produced Qty / 製成數量": "",
"UOM / 單位": "",
});

const buildWhereUsedSheet = (
data: ItemLotTraceResponse,
): Record<string, unknown>[] => {
const rows = buildIssueWhereUsedRows(data).map((row) => ({
"Level / 層級": row.depth,
"Consumed Item Code / 被用貨品編號": orDash(row.consumedItemCode),
"Consumed Item / 被用品名": orDash(row.consumedItemName),
"Consumed Lot / 被用批號": orDash(row.consumedLotNo),
"Qty Used / 用量": row.materialQtyUsed,
"Job Order / 工單": orDash(row.jobOrderCode),
"Produced Item Code / 製成品編號": orDash(row.producedItemCode),
"Produced Item / 製成品名": orDash(row.producedItemName),
"Produced Lot / 製成批號": orDash(row.producedLotNo),
"Produced Qty / 製成數量": row.producedQty,
"UOM / 單位": orDash(row.producedUom),
}));
return ensureRows(rows, whereUsedEmpty());
};

const ingredientsEmpty = (): Record<string, unknown> => ({
"Kind / 類型": "",
"Item Code / 貨品編號": "",
"Item Name / 品名": "",
"Lot / 批號": "",
"Qty / 數量": "",
"UOM / 單位": "",
"Qty Per Unit / 每單位用量": "",
"Job Order / 工單": "",
"Pick Order / 提料單": "",
"Assigned Step / 對應製程": "",
"Picked At / 提料時間": "",
});

const buildIngredientsSheet = (
data: ItemLotTraceResponse,
t: TFunction,
): Record<string, unknown>[] => {
const rows = buildIssueIngredientRows(data).map((row) => ({
"Kind / 類型":
row.kind === "RECIPE" ? t("issueIngredientRecipe") : t("issueIngredientActual"),
"Item Code / 貨品編號": orDash(row.itemCode),
"Item Name / 品名": orDash(row.itemName),
"Lot / 批號": orDash(row.lotNo),
"Qty / 數量": row.qty ?? "",
"UOM / 單位": orDash(row.uom),
"Qty Per Unit / 每單位用量": row.qtyPerUnit ?? "",
"Job Order / 工單": orDash(row.jobOrderCode),
"Pick Order / 提料單": orDash(row.pickOrderCode),
"Assigned Step / 對應製程": orDash(row.assignedStepName),
"Picked At / 提料時間": orDash(row.pickedAt),
}));
return ensureRows(rows, ingredientsEmpty());
};

const timelineEmpty = (): Record<string, unknown> => ({
"Date/Time / 時間": "",
"Phase / 階段": "",
@@ -460,6 +753,7 @@ const outboundEmpty = (): Record<string, unknown> => ({
const buildOutboundSheet = (
data: ItemLotTraceResponse,
tr: TraceLabelTranslator,
t: TFunction,
): Record<string, unknown>[] => {
const usage = mergeScopedOutboundUsage(data).map((u) => ({
"Section / 區塊": "Outbound Usage / 出庫使用",
@@ -499,8 +793,8 @@ const buildOutboundSheet = (
"Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—",
"Qty / 數量": d.qty,
"Handler / 經手人": orDash(d.handler),
"Extra / 加單": d.isExtra ? "Y" : "",
"Replenish / 補貨": d.isReplenish ? "Y" : "",
"Extra / 加單": d.isExtra ? t("excel.yes") : "",
"Replenish / 補貨": d.isReplenish ? t("excel.yes") : "",
"Remarks / 備註": "",
"Date/Time / 時間": orDash(d.timestamp),
}));
@@ -522,7 +816,7 @@ const buildOutboundSheet = (
"Qty / 數量": r.replenishQty,
"Handler / 經手人": orDash(r.handler),
"Extra / 加單": "",
"Replenish / 補貨": "Y",
"Replenish / 補貨": t("excel.yes"),
"Remarks / 備註": orDash(r.reason),
"Date/Time / 時間": orDash(r.timestamp),
}));
@@ -860,46 +1154,65 @@ const buildProductionSheet = (
return ensureRows(rows, productionEmpty());
};

/** Build the 8 classified sheets without triggering download (for tests). */
/** Build one workbook without triggering download (for tests). */
export const buildItemLotTraceSheets = (
data: ItemLotTraceResponse,
compiledGraph: CompiledTraceGraph,
t: TFunction,
exportLabels: ItemLotTraceExportLabels,
workbook: ItemLotTraceWorkbook = "history",
exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19),
): MultiSheetSpec[] => {
const tr = createTraceLabelTranslator(t);
return [
{
name: SHEET.summary,
rows: buildSummarySheet(data, exportLabels, exportAt),
},
{ name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) },
{ name: SHEET.origins, rows: buildOriginsSheet(data, tr) },
{ name: SHEET.qc, rows: buildQcSheet(data, tr) },
{ name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) },
{ name: SHEET.outbound, rows: buildOutboundSheet(data, tr) },
{ name: SHEET.stockTake, rows: buildStockTakeSheet(data) },
{
name: SHEET.production,
rows: buildProductionSheet(data, compiledGraph, tr),
},
];
const built: Record<(typeof SHEET_KEYS)[number], Record<string, unknown>[]> = {
summary: buildSummarySheet(data, exportLabels, exportAt, t),
stores: buildStoresSheet(data, t),
whereUsed: buildWhereUsedSheet(data),
ingredients: buildIngredientsSheet(data, t),
timeline: buildTimelineSheet(compiledGraph, t, tr),
origins: buildOriginsSheet(data, tr),
qc: buildQcSheet(data, tr),
warehouse: buildWarehouseSheet(data, tr),
outbound: buildOutboundSheet(data, tr, t),
stockTake: buildStockTakeSheet(data),
production: buildProductionSheet(data, compiledGraph, tr),
};
const keys = workbook === "issue" ? ISSUE_SHEET_KEYS : HISTORY_SHEET_KEYS;
return keys.map((key) => ({
name: sheetTitle(t, key),
rows: localizeRows(built[key], t),
}));
};

export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET);
export const itemLotTraceSheetNames = (
t: TFunction,
workbook: ItemLotTraceWorkbook = "history",
): string[] =>
(workbook === "issue" ? ISSUE_SHEET_KEYS : HISTORY_SHEET_KEYS).map((key) =>
sheetTitle(t, key),
);

/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
export const exportItemLotTraceXlsx = (
data: ItemLotTraceResponse,
compiledGraph: CompiledTraceGraph,
t: TFunction,
workbook: ItemLotTraceWorkbook = "history",
): void => {
const exportLabels: ItemLotTraceExportLabels = {
bomDirectionFg: t("bomDirectionFG"),
bomDirectionMaterial: t("bomDirectionMaterial"),
bomDirectionUnknown: t("bomDirectionUnknown"),
};
const sheets = buildItemLotTraceSheets(data, compiledGraph, t, exportLabels);
exportMultiSheetToXlsx(sheets, buildItemLotTraceFilename(data));
const sheets = buildItemLotTraceSheets(
data,
compiledGraph,
t,
exportLabels,
workbook,
);
exportMultiSheetToXlsx(
sheets,
buildItemLotTraceFilename(data, t, workbook),
);
};

+ 6
- 1
src/components/ItemTracing/itemTracingTableFilters.tsx View File

@@ -14,6 +14,7 @@ export type FilterableColumnDef<T> = {
key: string;
label: string;
align?: "left" | "right" | "center";
verticalAlign?: "top" | "middle" | "bottom";
value: (row: T) => string | number | null | undefined;
cell?: (row: T) => ReactNode;
/** @deprecated Unused — filters removed. */
@@ -85,7 +86,11 @@ export function FilterableDataTable<T>({
visibleRows.map((row, index) => (
<TableRow key={getRowKey(row, index)}>
{columns.map((col) => (
<TableCell key={col.key} align={col.align}>
<TableCell
key={col.key}
align={col.align}
sx={col.verticalAlign ? { verticalAlign: col.verticalAlign } : undefined}
>
{col.cell ? col.cell(row) : (col.value(row) ?? "—")}
</TableCell>
))}


+ 21
- 14
src/components/ItemTracing/traceFlowConstants.ts View File

@@ -1,23 +1,30 @@
export const LANE_MIN_HEIGHT = 150;
export const LANE_MIN_HEIGHT = 200;
export const LANE_GAP = 32;
/** Minimum width of one phase slot within a calendar day column. */
export const COLUMN_WIDTH_MIN = 320;
/**
* Minimum width of one time slot inside a calendar day.
* Matches one event card plus cell padding — extra events in the same phase stack,
* they do not each open a new column.
*/
export const COLUMN_WIDTH_MIN = 280;
/** @deprecated use COLUMN_WIDTH_MIN — kept for imports that expect COLUMN_WIDTH */
export const COLUMN_WIDTH = COLUMN_WIDTH_MIN;
export const PHASE_LABEL_WIDTH = 112;
export const HEADER_HEIGHT = 40;
/** Trace event card width — sized for PO codes and supplier names. */
export const NODE_WIDTH = 268;
/** Left rail for horizontal phase names (wraps to 2–3 lines). */
export const PHASE_LABEL_WIDTH = 160;
export const HEADER_HEIGHT = 36;
/** Trace event card width — sized for doc codes without forcing a huge column. */
export const NODE_WIDTH = 248;
export const NODE_WIDTH_BRANCH = 220;
/** Fixed trace event card height — layout spacing must match rendered Paper height.
* Sized for densest cards (MATERIAL_PICK / JO_OUT with item, statuses, timestamp). */
export const NODE_HEIGHT = 280;
export const NODE_GAP = 16;
/**
* Fixed trace event card height — layout spacing must match the rendered Paper.
* Cards show kind, time, title, and one context line; the detail panel has the rest.
*/
export const NODE_HEIGHT = 172;
export const NODE_GAP = 12;
/** React Flow canvas height in the life-cycle graph panel. */
export const VIEWPORT_HEIGHT = 720;
/** Initial fitView will not zoom out below this (keeps nodes readable). */
export const FIT_VIEW_MIN_ZOOM = 0.55;
export const FIT_VIEW_MAX_ZOOM = 1.35;
/** fitView / zoom-out floor so card text stays readable. Overview is the minimap. */
export const FIT_VIEW_MIN_ZOOM = 0.7;
export const FIT_VIEW_MAX_ZOOM = 1.25;

/**
* Min DO_OUT nodes in one day column before collapsing into a group box.


+ 12
- 102
src/components/ItemTracing/traceFlowLayout.ts View File

@@ -16,9 +16,6 @@ export const CELL_PAD_Y = 10;

export type CellPlacement = { x: number; y: number; compact: boolean };

const horizWidth = (count: number, nodeW: number) =>
count * nodeW + Math.max(0, count - 1) * NODE_GAP;

const defaultInnerCellWidth = () => COLUMN_WIDTH_MIN - CELL_PAD_X * 2;

const nodeLayoutSize = (
@@ -43,10 +40,6 @@ const stackedCellHeight = (members: TraceGraphLayoutNode[]): number =>
return sum + height + (index > 0 ? NODE_GAP : 0);
}, 0);

/** Group boxes have variable height — never tile them horizontally with other cards. */
const requiresStackedLayout = (members: TraceGraphLayoutNode[]): boolean =>
members.length > 1 && members.some(isFlowGroupContainer);

const layoutStackedMembers = (
sorted: TraceGraphLayoutNode[],
innerW: number,
@@ -66,114 +59,31 @@ const layoutStackedMembers = (
return result;
};

/** Minimum phase-slot width for a cell's top-level members (excludes DO group children). */
/**
* Slot width for a cell. Members stack vertically, so width follows the widest
* card or group box — not the count of events.
*/
export const cellWidthRequirement = (members: TraceGraphLayoutNode[]): number => {
const sorted = [...members].sort(sortNodesInPhase);
const n = sorted.length;
if (n === 0) return COLUMN_WIDTH_MIN;

if (n === 1 && isFlowGroupContainer(sorted[0]!)) {
return Math.max(COLUMN_WIDTH_MIN, (sorted[0]!.groupBoxWidth ?? NODE_WIDTH) + CELL_PAD_X * 2);
}

if (n === 1) return COLUMN_WIDTH_MIN;

if (requiresStackedLayout(sorted)) {
const maxW = Math.max(...sorted.map((node) => nodeLayoutSize(node).width));
return Math.max(COLUMN_WIDTH_MIN, maxW + CELL_PAD_X * 2);
}

const innerMin = COLUMN_WIDTH_MIN - CELL_PAD_X * 2;
const branchInner = horizWidth(n, NODE_WIDTH_BRANCH);
const fullInner = horizWidth(n, NODE_WIDTH);
const contentInner = Math.max(branchInner, fullInner, innerMin);
return contentInner + CELL_PAD_X * 2;
if (sorted.length === 0) return COLUMN_WIDTH_MIN;
const maxW = Math.max(...sorted.map((node) => nodeLayoutSize(node).width));
return Math.max(COLUMN_WIDTH_MIN, maxW + CELL_PAD_X * 2);
};

/** Lay out nodes sharing the same date column + phase lane (side-by-side or vertical stack). */
/** Lay out nodes sharing the same date column + phase lane as a vertical stack. */
export const layoutCellMembers = (
members: TraceGraphLayoutNode[],
innerCellWidth = defaultInnerCellWidth(),
): Map<string, CellPlacement> => {
const sorted = [...members].sort(sortNodesInPhase);
const result = new Map<string, CellPlacement>();
const n = sorted.length;
if (n === 0) return result;

const innerW = innerCellWidth;

if (n === 1 && isFlowGroupContainer(sorted[0]!)) {
const w = sorted[0]!.groupBoxWidth ?? NODE_WIDTH;
result.set(sorted[0]!.id, {
x: Math.max(0, (innerW - w) / 2),
y: CELL_PAD_Y,
compact: false,
});
return result;
}

if (n === 1) {
result.set(sorted[0].id, {
x: (innerW - NODE_WIDTH) / 2,
y: CELL_PAD_Y,
compact: false,
});
return result;
}

if (requiresStackedLayout(sorted)) {
return layoutStackedMembers(sorted, innerW, false);
}

let horizontal = false;
let nodeW = NODE_WIDTH_BRANCH;
if (horizWidth(n, NODE_WIDTH_BRANCH) <= innerW) {
horizontal = true;
nodeW = NODE_WIDTH_BRANCH;
} else if (horizWidth(n, NODE_WIDTH) <= innerW) {
horizontal = true;
nodeW = NODE_WIDTH;
}

if (horizontal) {
const totalW = horizWidth(n, nodeW);
const startX = (innerW - totalW) / 2;
sorted.forEach((node, i) => {
result.set(node.id, {
x: startX + i * (nodeW + NODE_GAP),
y: CELL_PAD_Y,
compact: true,
});
});
return result;
}

return layoutStackedMembers(sorted, innerW, true);
if (sorted.length === 0) return new Map();
return layoutStackedMembers(sorted, innerCellWidth, false);
};

export const cellContentHeight = (members: TraceGraphLayoutNode[]): number => {
const sorted = [...members].sort(sortNodesInPhase);
const memberCount = sorted.length;
if (memberCount <= 0) return LANE_MIN_HEIGHT;
if (memberCount === 1) {
const sole = sorted[0]!;
if (isFlowGroupContainer(sole) && sole.groupBoxHeight) {
return CELL_PAD_Y * 2 + sole.groupBoxHeight;
}
return CELL_PAD_Y * 2 + NODE_HEIGHT;
}

if (requiresStackedLayout(sorted)) {
return CELL_PAD_Y * 2 + stackedCellHeight(sorted);
}

const innerW = defaultInnerCellWidth();
const fitsHoriz =
horizWidth(memberCount, NODE_WIDTH_BRANCH) <= innerW ||
horizWidth(memberCount, NODE_WIDTH) <= innerW;

if (fitsHoriz) return CELL_PAD_Y * 2 + NODE_HEIGHT;
return CELL_PAD_Y * 2 + stackedCellHeight(sorted);
if (sorted.length <= 0) return LANE_MIN_HEIGHT;
return Math.max(LANE_MIN_HEIGHT, CELL_PAD_Y * 2 + stackedCellHeight(sorted));
};

export const computeLaneTops = (


+ 23
- 7
src/components/ItemTracing/traceGraphLayout.ts View File

@@ -194,9 +194,10 @@ export const computeDayColumnLayout = (
};

/**
* Within each calendar day, assign left→right slots by event time (sortKey).
* Avoids packing an entire phase to the left of another phase when later events
* in the early phase happen after earlier events in a later phase.
* Within each calendar day, place events left→right by time, but keep a run of
* the same phase in one column (stacked top→bottom). A new column starts only
* when the phase changes, so a burst of stock takes or receipts stays one lane
* instead of a long row of near-empty columns.
*/
export const assignChronologicalDaySlots = (nodes: TraceGraphLayoutNode[]): void => {
const byDay = new Map<string, TraceGraphLayoutNode[]>();
@@ -229,10 +230,25 @@ export const assignChronologicalDaySlots = (nodes: TraceGraphLayoutNode[]): void
if (a.sequenceIndex !== b.sequenceIndex) return a.sequenceIndex - b.sequenceIndex;
return a.id.localeCompare(b.id);
});
dayNodes.forEach((n, i) => {
n.dayPhaseStaggerIndex = i;
n.branchIndex = 0;
n.branchSize = 1;
let slot = 0;
let runPhase: TraceGraphLayoutNode["phase"] | null = null;
let runIndex = 0;
dayNodes.forEach((n) => {
if (runPhase != null && n.phase !== runPhase) {
slot += 1;
runIndex = 0;
}
n.dayPhaseStaggerIndex = slot;
n.branchIndex = runIndex;
runIndex += 1;
runPhase = n.phase;
});
const runCounts = new Map<number, number>();
dayNodes.forEach((n) => {
runCounts.set(n.dayPhaseStaggerIndex, (runCounts.get(n.dayPhaseStaggerIndex) ?? 0) + 1);
});
dayNodes.forEach((n) => {
n.branchSize = runCounts.get(n.dayPhaseStaggerIndex) ?? 1;
});
});



+ 193
- 7
src/i18n/en/itemTracing.json View File

@@ -1,10 +1,11 @@
{
"title": "Item Tracing",
"subtitle": "Scan a lot QR code to trace the full lifecycle — stock in/out, QC, pick orders, job orders, stock take, transfers, and BOM links.",
"subtitle": "Scan a lot to see its lifecycle. Lot history Excel is the full record. If the item is bad, the issue Excel lists the stores to contact, every product that used it, and the ingredients that made it.",
"scanReady": "Scanner ready — scan lot label QR",
"scanning": "Scanning…",
"scanAgain": "Scan again",
"manualSearch": "Manual search",
"manualSearchHint": "Type the item code and lot number, then click {{action}}.",
"itemCode": "Item code",
"itemLot": "Item lot",
"lotNo": "Lot no.",
@@ -30,12 +31,12 @@
"flowGraph": "Lifecycle flow",
"flowLegendTime": "→ Time (later to the right)",
"flowLegendPhase": "↓ Phase (later stages below)",
"flowLegendBranch": "↔ Same day = left to right by time",
"flowLegendBranch": "↕ Same phase stacks by time",
"flowLegendArrow": "Arrows only between phases",
"flowLegendPrelude": "↑ Pre-production at top",
"flowLegendTimeTip": "Horizontal axis = calendar dates, left to right",
"flowLegendPhaseTip": "Vertical axis = process phases, top to bottom in order",
"flowLegendBranchTip": "Events on the same calendar day are ordered left to right by timestamp; vertical lane still shows the process phase",
"flowLegendBranchTip": "Events in the same phase on one day stack top to bottom by time. A new column starts when the phase changes, so later phases stay to the right.",
"flowLegendArrowTip": "Arrows link events in different phases to show flow",
"flowLegendPreludeTip": "FG lots: material inbound, QC (material + finished good), job pick at top",
"flowGraphPathJo": "Process order: material in → QC → putaway → pick → production / byproducts → FG in → warehouse → out → stock take",
@@ -61,7 +62,7 @@
"categoryProduction": "Production",
"categoryOpen": "Lot open",
"categoryTerminal": "Terminal state",
"flowGraphHint": "Phases on the left, dates left to right. Same-day events are ordered left to right by time; arrows indicate cross-phase flow.",
"flowGraphHint": "Phases down the left, dates across the top. Same-phase events on a day stack top to bottom. A new column starts when the phase changes. Click a card for details.",
"flowGraphHintJo": "Includes pre-production (material inbound, QC, job pick) and finished-good phases. One QC lane covers material and FG IQC.",
"phaseMaterialInbound": "Material inbound",
"phaseMaterialQc": "Material QC",
@@ -113,8 +114,40 @@
"flowPhaseFilterReset": "Reset",
"locationsShowAll": "▼ Show all {{count}} rows",
"locationsCollapse": "▲ Collapse (showing all {{count}})",
"exportExcel": "Export Excel",
"exportExcelTooltip": "Download a classified multi-sheet workbook for this lot genealogy",
"exportHistoryExcel": "Export lot history",
"exportHistoryExcelTooltip": "Lifecycle, QC, warehouse, outbound, stock take, and production. Stores and ingredients are in the issue Excel.",
"exportIssueExcel": "Export issue list",
"exportIssueExcelTooltip": "Stores to contact, products that used this lot, and ingredients. Lot history is the other Excel.",
"issueTitle": "If this item is bad",
"issueHint": "Call these stores, check every product made with this lot, and see what went into it. This Excel has only those three sheets.",
"issueStores": "Stores to contact",
"issueWhereUsed": "Products that used this item",
"issueWhereUsedHint": "Level 1 used this lot directly. Later levels are products made from those products.",
"issueIngredients": "Ingredients of this item",
"issueContactHint": "Each store is listed once. Times received counts deliveries and replenishments of this lot, or of a product made from it. Phone comes from the shop master.",
"issueNoStores": "No store received this lot, or a product made from it.",
"issueNoWhereUsed": "No job order used this lot to make another item.",
"issueNoIngredients": "This lot was not made from other items on a job order.",
"issueRelationDirect": "Received this lot",
"issueRelationMadeFrom": "Received a product made from this lot",
"issueRelationReplenish": "Replenishment of this lot",
"issueRelationReplenishMade": "Replenishment of a product made from this lot",
"issueLevelLabel": "Level",
"issueLevel": "Level {{depth}}",
"issueIngredientActual": "Actual lot used",
"issueIngredientRecipe": "Recipe per unit",
"issueShop": "Store",
"issuePhone": "Phone",
"issueTimesReceived": "Times received",
"issueItemsReceived": "Received",
"issueLatestReceipt": "Latest",
"issueAddress": "Address",
"issueWhy": "Why listed",
"issueConsumed": "Item used",
"issueProduced": "Product made",
"issueQtyUsed": "Qty used",
"issueProducedQty": "Qty produced",
"issueQtyPerUnit": "Per unit",
"putawayTransferInboundDetail": "Received at target warehouse after transfer (system auto-completed; not the PO pending put-away queue)",
"putawayStatusPending": "Pending put-away",
"putawayStatusCompleted": "Put away done",
@@ -332,5 +365,158 @@
"detailStockAvailable": "Stock available",
"detailStockStatus": "Stock status",
"stockStatusSufficient": "Sufficient",
"stockStatusInsufficient": "Insufficient"
"stockStatusInsufficient": "Insufficient",
"excel": {
"filePrefix": "Lot history",
"issueFilePrefix": "Lot issue",
"noData": "(No records)",
"yes": "Yes",
"sheet": {
"summary": "Summary",
"stores": "Stores",
"whereUsed": "Where used",
"ingredients": "Ingredients",
"timeline": "Timeline",
"origins": "Origins",
"qc": "QC",
"warehouse": "Warehouse",
"outbound": "Outbound",
"stockTake": "Stock take",
"production": "Production"
},
"section": {
"origin": "Origin",
"purchase": "Purchase",
"putaway": "Putaway",
"transfer": "Transfer",
"adjustment": "Adjustment",
"openingStock": "Opening stock",
"outboundUsage": "Outbound usage",
"doDelivery": "Delivery outbound",
"replenishment": "Replenishment",
"return": "Return",
"pickFailure": "Pick failure",
"bomUpstream": "BOM upstream",
"bomDownstream": "BOM downstream",
"bomRecipe": "BOM recipe",
"joPick": "Job pick",
"productionStep": "Production step",
"byproduct": "Byproduct",
"lotRelation": "Lot relation"
},
"summary": {
"lotNo": "Lot no.",
"itemCode": "Item code",
"itemName": "Item name",
"uom": "UOM",
"expiry": "Expiry",
"productionDate": "Production date",
"stockInDate": "Stock in date",
"totalAvailable": "Total available",
"bomDirection": "BOM direction",
"issueExcel": "Issue Excel",
"issueExcelNote": "Separate file: stores to contact, products that used this lot, and ingredients",
"jobOrder": "Job order",
"planStart": "Planned start",
"plannedQty": "Planned qty",
"joStatus": "Job order status",
"exportTime": "Export time",
"warehouseBreakdown": "— Warehouse breakdown —",
"warehouseLine": "Warehouse: {{code}}",
"warehouseQty": "In {{inQty}} · Out {{outQty}} · Available {{available}} · {{status}}",
"alternateLocations": "— Other locations —",
"inventoryLot": "Inventory lot {{id}}",
"locationQty": "{{code}} · Available {{qty}}"
},
"col": {
"field": "Field",
"value": "Value",
"section": "Section",
"warehouse": "Warehouse",
"inventoryLotId": "Inventory lot id",
"type": "Type",
"typeCode": "Type code",
"docNo": "Doc no.",
"supplierCode": "Supplier code",
"supplier": "Supplier",
"dnNo": "DN no.",
"qty": "Qty",
"uom": "UOM",
"status": "Status",
"date": "Date",
"dateTime": "Date/time",
"handler": "Handler",
"remarks": "Remarks",
"lot": "Lot",
"itemCode": "Item code",
"itemName": "Item name",
"jobOrder": "Job order",
"direction": "Direction",
"from": "From",
"to": "To",
"why": "Why listed",
"timesReceived": "Times received",
"itemsReceived": "Received",
"latest": "Latest",
"level": "Level",
"shopCode": "Shop code",
"shopName": "Shop name",
"contact": "Contact",
"phone": "Phone",
"email": "Email",
"address": "Address",
"district": "District",
"deliveryOrder": "Delivery order",
"deliveryNote": "Delivery note",
"ticket": "Ticket",
"consumedItemCode": "Consumed item code",
"consumedItem": "Consumed item",
"consumedLot": "Consumed lot",
"qtyUsed": "Qty used",
"producedItemCode": "Produced item code",
"producedItem": "Produced item",
"producedLot": "Produced lot",
"producedQty": "Produced qty",
"kind": "Kind",
"qtyPerUnit": "Qty per unit",
"pickOrder": "Pick order",
"assignedStep": "Assigned step",
"pickedAt": "Picked at",
"phase": "Phase",
"eventType": "Event type",
"refType": "Ref type",
"refTypeCode": "Ref type code",
"qcResult": "QC result",
"qcType": "QC type",
"qcTypeCode": "QC type code",
"qcItemCode": "QC item code",
"qcItem": "QC item",
"sampleQty": "Sample qty",
"failQty": "Fail qty",
"stockInLineId": "Stock in line id",
"usageType": "Usage type",
"usageTypeCode": "Usage type code",
"conso": "Conso",
"shop": "Shop",
"extra": "Add-on",
"replenish": "Replenishment",
"stockTakeCode": "Stock take no.",
"round": "Round",
"area": "Section",
"bookQty": "Book qty",
"approvedQty": "Approved qty",
"varianceQty": "Variance qty",
"approver": "Approver",
"stockTaker": "Stock taker",
"firstCountQty": "First count qty",
"secondCountQty": "Second count qty",
"approverCountQty": "Approver count qty",
"recordStatus": "Record status",
"step": "Step",
"scrapQty": "Scrap qty",
"defectQty": "Defect qty",
"operator": "Operator",
"equipment": "Equipment"
}
}
}

+ 193
- 7
src/i18n/zh/itemTracing.json View File

@@ -1,10 +1,11 @@
{
"title": "批號追溯",
"subtitle": "掃描批號 QR 即可追溯完整生命週期:入庫、出庫、品檢、提料單、工單、盤點、轉倉及 BOM 關聯。",
"subtitle": "掃描批號查看生命週期。批號紀錄 Excel 是完整紀錄。若貨品有問題,問題清單 Excel 會列出需聯絡的門市、用到此貨的產品,以及生產用料。",
"scanReady": "掃碼槍就緒 — 請掃描批號標籤 QR",
"scanning": "掃描中…",
"scanAgain": "重新掃描",
"manualSearch": "手動查詢",
"manualSearchHint": "輸入貨品編號與批號後按「{{action}}」。",
"itemCode": "貨品編號",
"itemLot": "貨品批號",
"lotNo": "批號",
@@ -30,12 +31,12 @@
"flowGraph": "生命週期流程",
"flowLegendTime": "→ 時間(愈右愈晚)",
"flowLegendPhase": "↓ 階段(愈下愈後續)",
"flowLegendBranch": "↔ 同日由左至右依時間",
"flowLegendBranch": "↕ 同階段由上而下依時間",
"flowLegendArrow": "箭頭僅跨階段",
"flowLegendPrelude": "↑ 上方為製造前",
"flowLegendTimeTip": "橫軸 = 日期,由左至右時間推進",
"flowLegendPhaseTip": "縱軸 = 製程階段,由上至下依作業順序",
"flowLegendBranchTip": "同一天的事件依時間戳由左至右排列;縱向列仍表示製程階段",
"flowLegendBranchTip": "同一天、同一階段的事件由上而下依時間排列。階段改變時改到右邊新欄,較晚的階段保持在右。",
"flowLegendArrowTip": "箭頭連接不同階段的事件,表示流程走向",
"flowLegendPreludeTip": "成品批上方為原料入庫、品檢(原料與成品共用)、工單提料",
"flowGraphPathJo": "製程順序:原料入庫 → 品檢 → 上架 → 提料 → 生產/副產品 → 成品入庫 → 倉儲 → 出庫 → 盤點",
@@ -61,7 +62,7 @@
"categoryProduction": "生產",
"categoryOpen": "開倉",
"categoryTerminal": "終態",
"flowGraphHint": "左側為生命週期階段,由左至右依日期排列;同一天的事件依時間由左至右排列,箭頭表示跨階段流程。",
"flowGraphHint": "左側為階段、上方為日期。同一階段的事件由上而下排列;階段改變時向右換欄。點卡片可看明細。",
"flowGraphHintJo": "成品批含製造前階段(原料入庫、品檢、工單提料)與成品階段;品檢列合併原料與成品 IQC。",
"phaseMaterialInbound": "原料入庫",
"phaseMaterialQc": "原料品檢",
@@ -113,8 +114,40 @@
"flowPhaseFilterReset": "重置",
"locationsShowAll": "▼ 顯示全部 {{count}} 列",
"locationsCollapse": "▲ 收合(目前顯示全部 {{count}})",
"exportExcel": "匯出 Excel",
"exportExcelTooltip": "下載此批號分類整理後的多工作表 Excel",
"exportHistoryExcel": "匯出批號紀錄",
"exportHistoryExcelTooltip": "生命週期、品檢、倉儲、出庫、盤點與生產。門市與用料在問題清單 Excel。",
"exportIssueExcel": "匯出問題清單",
"exportIssueExcelTooltip": "需聯絡的門市、用到此貨的產品,以及生產用料。批號紀錄是另一份 Excel。",
"issueTitle": "若此貨品有問題",
"issueHint": "聯絡這些門市、查看用到此批製成的產品,以及此貨的生產用料。此 Excel 只有這三張工作表。",
"issueStores": "需聯絡的門市",
"issueWhereUsed": "用到此貨的產品",
"issueWhereUsedHint": "第 1 層是直接用到此批。之後的層級是再用那些製成品做出的產品。",
"issueIngredients": "此貨的生產用料",
"issueContactHint": "每間門市只列一次。收貨次數計入此批、或用此批製成產品的送貨與補貨。電話來自門市主檔。",
"issueNoStores": "沒有門市收到此批,或收到用此批製成的產品。",
"issueNoWhereUsed": "沒有工單用此批製成其他貨品。",
"issueNoIngredients": "此批不是由工單用其他貨品製成。",
"issueRelationDirect": "收到此批",
"issueRelationMadeFrom": "收到用此批製成的產品",
"issueRelationReplenish": "此批的補貨",
"issueRelationReplenishMade": "用此批製成產品的補貨",
"issueLevelLabel": "層級",
"issueLevel": "第 {{depth}} 層",
"issueIngredientActual": "實際用料批",
"issueIngredientRecipe": "配方(每單位)",
"issueShop": "門市",
"issuePhone": "電話",
"issueTimesReceived": "收貨次數",
"issueItemsReceived": "收到的貨",
"issueLatestReceipt": "最近一次",
"issueAddress": "地址",
"issueWhy": "列出原因",
"issueConsumed": "被用貨品",
"issueProduced": "製成貨品",
"issueQtyUsed": "用量",
"issueProducedQty": "製成數量",
"issueQtyPerUnit": "每單位用量",
"putawayTransferInboundDetail": "轉倉至目標倉完成入庫(系統自動完成,非採購「待上架」流程)",
"putawayStatusPending": "待上架",
"putawayStatusCompleted": "已上架",
@@ -332,5 +365,158 @@
"detailStockAvailable": "庫存數",
"detailStockStatus": "庫存狀態",
"stockStatusSufficient": "足夠",
"stockStatusInsufficient": "不足"
"stockStatusInsufficient": "不足",
"excel": {
"filePrefix": "批號紀錄",
"issueFilePrefix": "問題清單",
"noData": "(無資料)",
"yes": "是",
"sheet": {
"summary": "摘要",
"stores": "門市聯絡",
"whereUsed": "用到此貨",
"ingredients": "生產用料",
"timeline": "生命週期",
"origins": "來源與採購",
"qc": "品質檢驗",
"warehouse": "倉儲作業",
"outbound": "出庫",
"stockTake": "盤點",
"production": "生產與BOM"
},
"section": {
"origin": "來源",
"purchase": "採購",
"putaway": "上架",
"transfer": "轉倉",
"adjustment": "庫存調整",
"openingStock": "開倉入庫",
"outboundUsage": "出庫使用",
"doDelivery": "成品出倉",
"replenishment": "補貨",
"return": "退貨",
"pickFailure": "揀貨異常",
"bomUpstream": "BOM 上游",
"bomDownstream": "BOM 下游",
"bomRecipe": "BOM 配方",
"joPick": "工單提料",
"productionStep": "生產步驟",
"byproduct": "副產品",
"lotRelation": "拆批關聯"
},
"summary": {
"lotNo": "批號",
"itemCode": "貨品編號",
"itemName": "品名",
"uom": "單位",
"expiry": "效期",
"productionDate": "生產日期",
"stockInDate": "入庫日期",
"totalAvailable": "總可用量",
"bomDirection": "BOM 方向",
"issueExcel": "問題清單",
"issueExcelNote": "另一份 Excel:需聯絡的門市、用到此貨的產品,以及生產用料",
"jobOrder": "工單",
"planStart": "計劃開工",
"plannedQty": "計劃產量",
"joStatus": "工單狀態",
"exportTime": "匯出時間",
"warehouseBreakdown": "— 各倉庫存量 —",
"warehouseLine": "倉位:{{code}}",
"warehouseQty": "入 {{inQty}} · 出 {{outQty}} · 可用 {{available}} · {{status}}",
"alternateLocations": "— 其他倉位 —",
"inventoryLot": "庫存批 {{id}}",
"locationQty": "{{code}} · 可用 {{qty}}"
},
"col": {
"field": "欄位",
"value": "值",
"section": "區塊",
"warehouse": "倉位",
"inventoryLotId": "庫存批編號",
"type": "類型",
"typeCode": "類型代碼",
"docNo": "單號",
"supplierCode": "供應商編號",
"supplier": "供應商",
"dnNo": "送貨單號",
"qty": "數量",
"uom": "單位",
"status": "狀態",
"date": "日期",
"dateTime": "時間",
"handler": "經手人",
"remarks": "備註",
"lot": "批號",
"itemCode": "貨品編號",
"itemName": "品名",
"jobOrder": "工單",
"direction": "方向",
"from": "來源倉",
"to": "目標倉",
"why": "列出原因",
"timesReceived": "收貨次數",
"itemsReceived": "收到的貨",
"latest": "最近一次",
"level": "層級",
"shopCode": "門市編號",
"shopName": "門市名稱",
"contact": "聯絡人",
"phone": "電話",
"email": "電郵",
"address": "地址",
"district": "地區",
"deliveryOrder": "送貨單",
"deliveryNote": "DN",
"ticket": "票號",
"consumedItemCode": "被用貨品編號",
"consumedItem": "被用品名",
"consumedLot": "被用批號",
"qtyUsed": "使用數量",
"producedItemCode": "製成品編號",
"producedItem": "製成品名",
"producedLot": "製成批號",
"producedQty": "製成數量",
"kind": "類型",
"qtyPerUnit": "每單位數量",
"pickOrder": "提料單",
"assignedStep": "對應製程",
"pickedAt": "提料時間",
"phase": "階段",
"eventType": "事件類型",
"refType": "來源類型",
"refTypeCode": "來源類型代碼",
"qcResult": "品檢結果",
"qcType": "品檢類型",
"qcTypeCode": "品檢類型代碼",
"qcItemCode": "品檢項編號",
"qcItem": "品檢項",
"sampleQty": "抽樣數量",
"failQty": "不良數量",
"stockInLineId": "入庫行編號",
"usageType": "使用類型",
"usageTypeCode": "使用類型代碼",
"conso": "併單",
"shop": "門市",
"extra": "加單",
"replenish": "補貨",
"stockTakeCode": "盤點單號",
"round": "輪次",
"area": "區域",
"bookQty": "帳面數量",
"approvedQty": "核准數量",
"varianceQty": "差異數量",
"approver": "核准人",
"stockTaker": "初盤人",
"firstCountQty": "初盤數量",
"secondCountQty": "複盤數量",
"approverCountQty": "覆盤數量",
"recordStatus": "紀錄狀態",
"step": "步驟",
"scrapQty": "損耗數量",
"defectQty": "不良數量",
"operator": "操作員",
"equipment": "設備"
}
}
}

Loading…
Cancel
Save