Ver código fonte

權限 + 未按規劃完成 tab + lookback 對齊 + 報表檔名

production
CANCERYS\kw093 16 horas atrás
pai
commit
cecd7cbc32
11 arquivos alterados com 583 adições e 224 exclusões
  1. +1
    -1
      src/app/api/jo/actions.ts
  2. +18
    -1
      src/authorities.ts
  3. +6
    -5
      src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx
  4. +275
    -134
      src/components/ProductionProcess/JobOrderOpsTable.tsx
  5. +10
    -3
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  6. +1
    -1
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  7. +203
    -43
      src/components/ProductionProcess/ProductionProcessList.tsx
  8. +4
    -1
      src/components/ProductionProcess/ProductionProcessPage.tsx
  9. +62
    -34
      src/hooks/useJobOrderFgStockInAlerts.ts
  10. +1
    -0
      src/i18n/en/productionProcess.json
  11. +2
    -1
      src/i18n/zh/productionProcess.json

+ 1
- 1
src/app/api/jo/actions.ts Ver arquivo

@@ -905,7 +905,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n
);
});

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.3 | 2026-08-05 */
export const fetchJoborderProductProcessesPage = cache(async (params: {
/** Job order / process date(YYYY-MM-DD) */
date?: string | null;


+ 18
- 1
src/authorities.ts Ver arquivo

@@ -19,5 +19,22 @@ export const AUTH = {
JOB_PICK: "JOB_PICK",
JOB_MAT: "JOB_MAT",
JOB_PROD: "JOB_PROD",
/**
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05
* 工單 生產流程 完成工單
*/
PRODUCT_PROCESS: "PRODUCT_PROCESS",
REPORT_MGMT: "REPORT_MGMT",
} as const;
} as const;

/**
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05
* Match session ability codes (exact, trimmed).
*/
export function hasAbility(
abilities: string[] | undefined | null,
required: string,
): boolean {
const code = required.trim();
return (abilities ?? []).some((a) => String(a).trim() === code);
}

+ 6
- 5
src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx Ver arquivo

@@ -1,6 +1,6 @@
"use client";

import { useJobOrderFgStockInAlerts } from "@/hooks/useJobOrderFgStockInAlerts";
import { useJobOrderFgStockInAlerts, PRODUCTION_LOOKBACK_DAYS } from "@/hooks/useJobOrderFgStockInAlerts";
import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts";
import CloseIcon from "@mui/icons-material/Close";
import FactCheckIcon from "@mui/icons-material/FactCheck";
@@ -139,6 +139,7 @@ type Props = {
enabled: boolean;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */
const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled);
const [open, setOpen] = useState(false);
@@ -152,8 +153,8 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<Tooltip
title={
count > 0
? `點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(今日/昨日產程、完成QC工單列表資格)`
: "今日/昨日無待 QC/待上架提醒"
? `點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(與「待 QC 上架」tab 相同:今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天)`
: `今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天內無待 QC/待上架提醒`
}
placement="right"
>
@@ -202,8 +203,8 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
)}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
僅含<strong>產程日期為今日或昨日</strong>的工單,且與「完成QC工單」相同條件(該工單<strong>所有工序行</strong>均為
Completed/Pass、有成品入庫且未完成/未拒絕)。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。
與產程頁<strong>「待 QC 上架」</strong>相同資料來源:搜索日為<strong>今日</strong>、往前
<strong> {PRODUCTION_LOOKBACK_DAYS} </strong>天產程;工序全完成、入庫未完成/未拒絕。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。
</Typography>
<IconButton
aria-label="關閉"


+ 275
- 134
src/components/ProductionProcess/JobOrderOpsTable.tsx Ver arquivo

@@ -24,6 +24,8 @@ import {
TablePagination,
TableRow,
Typography,
Avatar,
Tooltip,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSession } from "next-auth/react";
@@ -32,7 +34,7 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs, { Dayjs } from "dayjs";
import { SessionWithTokens } from "@/config/authConfig";
import { AUTH } from "@/authorities";
import { AUTH, hasAbility } from "@/authorities";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import {
AllJoborderProductProcessInfoResponse,
@@ -48,16 +50,28 @@ import { StockInLineInput } from "@/app/api/stockIn";
import type { PrinterCombo } from "@/app/api/settings/printer";

type PrimaryTab =
| "all"
| "needs_action"
| "pending"
| "processing"
| "carried_over"
| "pending_qc"
| "putawayed"
| "issue";
type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started";
type ProcessingSubTab = "all" | "picked_started" | "not_picked_started";
/** Shared small tabs for 需處理 / 待生產 / 生產中. */
type ProductionSubTab = "all" | "not_picked" | "picked";
type IssueSubTab = "stop" | "cancel";

const NOT_PICKED_BUCKETS = new Set([
"not_picked_not_started",
"not_picked_started",
]);
const PICKED_BUCKETS = new Set(["picked_not_started", "picked_started"]);
const PENDING_BUCKETS = new Set([
"not_picked_not_started",
"picked_not_started",
]);
const PROCESSING_BUCKETS = new Set(["picked_started", "not_picked_started"]);

type OpsRow = {
key: string;
jobOrderId: number;
@@ -78,9 +92,20 @@ type OpsRow = {
sourceProcess?: AllJoborderProductProcessInfoResponse;
};

/** Same as ProductionProcessList: unfinished from (searchDate - 4) .. searchDate. */
const PRODUCTION_LOOKBACK_DAYS = 4;
/** Cards / page size for ops table fetch. */
const FETCH_SIZE = 200;

function isCarriedOver(row: OpsRow, searchDay: Dayjs): boolean {
if (!row.productionDate || !dayjs(row.productionDate).isValid()) return false;
return dayjs(row.productionDate).startOf("day").isBefore(searchDay);
}

function tabLabel(text: string, count: number): string {
return count > 0 ? `${text} (${count})` : text;
}

function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean {
return (p.lines ?? []).some(
(l) => String(l.status ?? "").trim().toLowerCase() === "paused",
@@ -162,12 +187,40 @@ function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] {
return Array.from(map.values());
}

/** Prefer pending_qc over production when same JO appears in off-plan merge. */
function dedupeOffPlanRows(rows: OpsRow[]): OpsRow[] {
const map = new Map<number, OpsRow>();
for (const row of rows) {
const existing = map.get(row.jobOrderId);
if (!existing) {
map.set(row.jobOrderId, row);
continue;
}
if (existing.rowKind !== "pending_qc" && row.rowKind === "pending_qc") {
map.set(row.jobOrderId, row);
continue;
}
if (!existing.isPaused && row.isPaused) {
map.set(row.jobOrderId, row);
}
}
return Array.from(map.values());
}

function isRowPutawayCompleted(row: OpsRow): boolean {
return (
String(row.sourceProcess?.stockInLineStatus ?? "")
.trim()
.toLowerCase() === "completed"
);
}

interface JobOrderOpsTableProps {
onSelectProcess?: (jobOrderId: number) => void;
printerCombo?: PrinterCombo[];
}

/** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.1 | 2026-08-05 */
/** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.2 | 2026-08-05 */
const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
onSelectProcess,
printerCombo = [],
@@ -176,14 +229,18 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
const { data: session } = useSession() as { data: SessionWithTokens | null };
const sessionToken = session as SessionWithTokens | null;
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canManage = abilities.some((a) => a.trim() === AUTH.ADMIN);
/** 取消工單:僅 ADMIN */
const canCancel = hasAbility(abilities, AUTH.ADMIN);
/** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單) */
const canComplete = hasAbility(abilities, AUTH.PRODUCT_PROCESS);

const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs());
const [primaryTab, setPrimaryTab] = useState<PrimaryTab>("all");
const [pendingSub, setPendingSub] = useState<PendingSubTab>("all");
const [processingSub, setProcessingSub] = useState<ProcessingSubTab>("all");
const [primaryTab, setPrimaryTab] = useState<PrimaryTab>("needs_action");
const [productionSub, setProductionSub] = useState<ProductionSubTab>("all");
const [issueSub, setIssueSub] = useState<IssueSubTab>("stop");

const searchDay = useMemo(() => queryDate.startOf("day"), [queryDate]);

const [productionRows, setProductionRows] = useState<OpsRow[]>([]);
const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]);
const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]);
@@ -203,10 +260,9 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({

const loadProduction = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
// lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend)
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
lookbackDays: 0,
lookbackDays: PRODUCTION_LOOKBACK_DAYS,
bucket: "all",
qcReady: false,
page: 0,
@@ -214,8 +270,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "production"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
.filter((r): r is OpsRow => r != null);
setProductionRows(dedupeByJobOrder(rows));
}, [queryDate]);

@@ -224,7 +279,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
try {
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
lookbackDays: 0,
lookbackDays: PRODUCTION_LOOKBACK_DAYS,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
@@ -233,8 +288,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "pending_qc"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
.filter((r): r is OpsRow => r != null);
setPendingQcRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPendingQc failed", e);
@@ -255,8 +309,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "putawayed"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
.filter((r): r is OpsRow => r != null);
setPutawayedRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPutawayed failed", e);
@@ -303,65 +356,108 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({

useEffect(() => {
setPage(0);
}, [primaryTab, pendingSub, processingSub, issueSub, queryDate]);
}, [primaryTab, productionSub, issueSub, queryDate]);

useEffect(() => {
setProductionSub("all");
}, [primaryTab]);

const offPlanRows = useMemo(() => {
const fromProd = productionRows.filter(
(r) => isCarriedOver(r, searchDay) && !isRowPutawayCompleted(r),
);
const fromQc = pendingQcRows.filter(
(r) => isCarriedOver(r, searchDay) && !isRowPutawayCompleted(r),
);
return dedupeOffPlanRows([...fromProd, ...fromQc]);
}, [productionRows, pendingQcRows, searchDay]);

const applyProductionSub = useCallback((list: OpsRow[], sub: ProductionSubTab) => {
if (sub === "not_picked") {
return list.filter((r) => NOT_PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")));
}
if (sub === "picked") {
return list.filter((r) => PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")));
}
return list;
}, []);

const filteredRows = useMemo(() => {
if (primaryTab === "pending_qc") return pendingQcRows;
if (primaryTab === "putawayed") return putawayedRows;
if (primaryTab === "pending_qc") return pendingQcRows;
if (primaryTab === "carried_over") return offPlanRows;
if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows;

let list = productionRows;
if (primaryTab === "pending") {
list = list.filter((r) => {
const b = r.pickProcessBucket;
if (pendingSub === "picked_not_started") return b === "picked_not_started";
if (pendingSub === "not_picked_not_started") return b === "not_picked_not_started";
return b === "picked_not_started" || b === "not_picked_not_started";
});
list = list.filter((r) => PENDING_BUCKETS.has(String(r.pickProcessBucket ?? "")));
} else if (primaryTab === "processing") {
list = list.filter((r) => {
const b = r.pickProcessBucket;
if (processingSub === "picked_started") return b === "picked_started";
if (processingSub === "not_picked_started") return b === "not_picked_started";
return b === "picked_started" || b === "not_picked_started";
});
list = list.filter((r) => PROCESSING_BUCKETS.has(String(r.pickProcessBucket ?? "")));
} else if (primaryTab === "issue" && issueSub === "stop") {
list = list.filter((r) => r.isPaused);
return list.filter((r) => r.isPaused);
}

if (
primaryTab === "needs_action" ||
primaryTab === "pending" ||
primaryTab === "processing"
) {
list = applyProductionSub(list, productionSub);
}
return list;
}, [
primaryTab,
pendingSub,
processingSub,
productionSub,
issueSub,
productionRows,
pendingQcRows,
putawayedRows,
cancelledRows,
offPlanRows,
applyProductionSub,
]);

const counts = useMemo(() => {
const pending = productionRows.filter(
(r) =>
r.pickProcessBucket === "picked_not_started" ||
r.pickProcessBucket === "not_picked_not_started",
).length;
const processing = productionRows.filter(
(r) =>
r.pickProcessBucket === "picked_started" ||
r.pickProcessBucket === "not_picked_started",
).length;
const pendingRows = productionRows.filter((r) =>
PENDING_BUCKETS.has(String(r.pickProcessBucket ?? "")),
);
const processingRows = productionRows.filter((r) =>
PROCESSING_BUCKETS.has(String(r.pickProcessBucket ?? "")),
);
const stop = productionRows.filter((r) => r.isPaused).length;

const scopeForSub =
primaryTab === "pending"
? pendingRows
: primaryTab === "processing"
? processingRows
: productionRows;

return {
all: productionRows.length,
pending,
processing,
needsAction: productionRows.length,
pending: pendingRows.length,
processing: processingRows.length,
carriedOver: offPlanRows.length,
pendingQc: pendingQcRows.length,
putawayed: putawayedRows.length,
stop,
cancel: cancelledRows.length,
subAll: scopeForSub.length,
subNotPicked: scopeForSub.filter((r) =>
NOT_PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")),
).length,
subPicked: scopeForSub.filter((r) =>
PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")),
).length,
};
}, [productionRows, pendingQcRows, putawayedRows, cancelledRows]);
}, [
productionRows,
pendingQcRows,
putawayedRows,
cancelledRows,
offPlanRows,
primaryTab,
]);

const paginatedRows = useMemo(() => {
const start = page * pageSize;
@@ -404,7 +500,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({

const handleComplete = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
if (!canComplete || row.isCancelled) return;
openConfirm(t("Confirm to update this Job Order?"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
@@ -429,12 +525,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
}
});
},
[canManage, openConfirm, t, loadData],
[canComplete, openConfirm, t, loadData],
);

const handleCancel = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
if (!canCancel || row.isCancelled) return;
openConfirm(t("Cancel job order confirm message"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
@@ -448,7 +544,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
}
});
},
[canManage, openConfirm, t, loadData],
[canCancel, openConfirm, t, loadData],
);

const onConfirm = useCallback(async () => {
@@ -470,6 +566,18 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
if (row.rowKind === "pending_qc") return t("Waiting QC Put Away");
if (row.rowKind === "putawayed") return t("Put Awayed");
if (row.isCancelled) return t("Cancelled");
if (primaryTab === "carried_over") {
switch (row.pickProcessBucket) {
case "not_picked_not_started":
case "picked_not_started":
return t("pending");
case "picked_started":
case "not_picked_started":
return t("processing");
default:
break;
}
}
switch (row.pickProcessBucket) {
case "not_picked_not_started":
case "not_picked_started":
@@ -478,14 +586,27 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
case "picked_started":
return t("Picked");
default:
// Prefer explicit key over t("completed") → purchaseOrder「已上架」
if (String(row.statusLabel).toLowerCase() === "completed") {
return t("Completed");
}
return row.statusLabel || "-";
}
};

const showManageActions =
primaryTab !== "pending_qc" &&
primaryTab !== "putawayed" &&
!(primaryTab === "issue" && issueSub === "cancel");
const showManageActionsForRow = (row: OpsRow) => {
if (row.isCancelled || row.rowKind === "putawayed") return false;
if (row.rowKind === "pending_qc") return false;
if (primaryTab === "pending_qc" || primaryTab === "putawayed") return false;
if (primaryTab === "issue" && issueSub === "cancel") return false;
return true;
};

const showQcActionForRow = (row: OpsRow) =>
(primaryTab === "pending_qc" ||
primaryTab === "putawayed" ||
(primaryTab === "carried_over" && row.rowKind === "pending_qc")) &&
row.stockInLineId != null;

return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
@@ -519,72 +640,52 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }}
>
<Tab
value="all"
label={counts.all > 0 ? `${t("All")} (${counts.all})` : t("All")}
value="needs_action"
label={tabLabel(t("Needs action"), counts.needsAction)}
/>
<Tab
value="pending"
label={
counts.pending > 0
? `${t("pending")} (${counts.pending})`
: t("pending")
}
label={tabLabel(t("pending"), counts.pending)}
/>
<Tab
value="processing"
label={
counts.processing > 0
? `${t("Processing")} (${counts.processing})`
: t("Processing")
}
label={tabLabel(t("Processing"), counts.processing)}
/>
<Tab
value="carried_over"
label={tabLabel(t("Off-plan unfinished"), counts.carriedOver)}
/>
<Tab
value="pending_qc"
label={
counts.pendingQc > 0
? `${t("Waiting QC Put Away")} (${counts.pendingQc})`
: t("Waiting QC Put Away")
}
label={tabLabel(t("Waiting QC Put Away"), counts.pendingQc)}
/>
<Tab
value="putawayed"
label={
counts.putawayed > 0
? `${t("Put Awayed")} (${counts.putawayed})`
: t("Put Awayed")
}
label={tabLabel(t("Put Awayed"), counts.putawayed)}
/>
<Tab
value="issue"
label={
counts.stop + counts.cancel > 0
? `${t("Issue")} (${counts.stop + counts.cancel})`
: t("Issue")
}
label={tabLabel(t("Issue"), counts.stop + counts.cancel)}
/>
</Tabs>

{primaryTab === "pending" && (
<Tabs
value={pendingSub}
onChange={(_, v: PendingSubTab) => setPendingSub(v)}
sx={{ mb: 2 }}
>
<Tab value="all" label={t("All")} />
<Tab value="picked_not_started" label={t("Picked")} />
<Tab value="not_picked_not_started" label={t("Not picked")} />
</Tabs>
)}

{primaryTab === "processing" && (
{(
["needs_action", "pending", "processing"] as PrimaryTab[]
).includes(primaryTab) && (
<Tabs
value={processingSub}
onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)}
value={productionSub}
onChange={(_, v: ProductionSubTab) => setProductionSub(v)}
sx={{ mb: 2 }}
>
<Tab value="all" label={t("All")} />
<Tab value="picked_started" label={t("Picked")} />
<Tab value="not_picked_started" label={t("Not picked")} />
<Tab value="all" label={tabLabel(t("All"), counts.subAll)} />
<Tab
value="not_picked"
label={tabLabel(t("Not picked"), counts.subNotPicked)}
/>
<Tab
value="picked"
label={tabLabel(t("Picked"), counts.subPicked)}
/>
</Tabs>
)}

@@ -596,19 +697,11 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
>
<Tab
value="stop"
label={
counts.stop > 0
? `${t("Stop (paused)")} (${counts.stop})`
: t("Stop (paused)")
}
label={tabLabel(t("Stop (paused)"), counts.stop)}
/>
<Tab
value="cancel"
label={
counts.cancel > 0
? `${t("Cancelled")} (${counts.cancel})`
: t("Cancelled")
}
label={tabLabel(t("Cancelled"), counts.cancel)}
/>
</Tabs>
)}
@@ -643,24 +736,78 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
) : (
paginatedRows.map((row) => {
const busy = rowBusyIds.has(row.jobOrderId);
const carriedOver = isCarriedOver(row, searchDay);
const carriedCellSx = carriedOver
? {
bgcolor: "warning.light",
borderBottomColor: "warning.main",
}
: undefined;
return (
<TableRow key={row.key} hover>
<TableCell>{row.jobOrderCode}</TableCell>
<TableCell>
<TableRow
key={row.key}
hover={!carriedOver}
sx={
carriedOver
? {
bgcolor: "warning.light",
"&:hover": {
bgcolor: "warning.light",
},
"& > .MuiTableCell-root": {
bgcolor: "warning.light",
},
"&:hover > .MuiTableCell-root": {
bgcolor: "warning.light",
},
}
: undefined
}
>
<TableCell sx={carriedCellSx}>
<Stack
direction="row"
alignItems="center"
spacing={0.75}
>
<Typography variant="body2" component="span">
{row.jobOrderCode}
</Typography>
{carriedOver ? (
<Tooltip title={t("Carried over from past day")}>
<Avatar
aria-label={t("Carried over from past day")}
sx={{
width: 24,
height: 24,
flexShrink: 0,
bgcolor: "error.main",
color: "error.contrastText",
fontSize: "0.9rem",
fontWeight: 800,
}}
>
!
</Avatar>
</Tooltip>
) : null}
</Stack>
</TableCell>
<TableCell sx={carriedCellSx}>
<Typography variant="body2">
{[row.itemCode, row.itemName].filter(Boolean).join(" ")}
</Typography>
</TableCell>
<TableCell align="right">
<TableCell align="right" sx={carriedCellSx}>
{row.requiredQty}
{row.uom ? ` ${row.uom}` : ""}
</TableCell>
<TableCell>
<TableCell sx={carriedCellSx}>
{row.productionDate && dayjs(row.productionDate).isValid()
? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT)
: "-"}
</TableCell>
<TableCell>
<TableCell sx={carriedCellSx}>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{row.isPaused && (
<Chip size="small" color="warning" label={t("Stop (paused)")} />
@@ -672,7 +819,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
/>
</Stack>
</TableCell>
<TableCell align="center">
<TableCell align="center" sx={carriedCellSx}>
<Stack
direction="row"
spacing={1}
@@ -687,29 +834,23 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
>
{t("View Details")}
</Button>
{primaryTab === "pending_qc" && row.stockInLineId != null && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
>
{t("view stockin")}
</Button>
)}
{primaryTab === "putawayed" && row.stockInLineId != null && (
{showQcActionForRow(row) && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
>
{t("Put Away Detail")}
{primaryTab === "putawayed" ||
row.rowKind === "putawayed"
? t("Put Away Detail")
: t("view stockin")}
</Button>
)}
{showManageActions && !row.isCancelled && (
{showManageActionsForRow(row) && (
<Button
size="small"
variant="contained"
disabled={!canManage || busy}
disabled={!canComplete || busy}
onClick={() => handleComplete(row)}
startIcon={
busy ? (
@@ -720,12 +861,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
{t("Update Job Order")}
</Button>
)}
{showManageActions && !row.isCancelled && (
{showManageActionsForRow(row) && (
<Button
size="small"
variant="outlined"
color="warning"
disabled={!canManage || busy}
disabled={!canCancel || busy}
onClick={() => handleCancel(row)}
>
{t("Cancel Job Order")}


+ 10
- 3
src/components/ProductionProcess/ProductionProcessDetail.tsx Ver arquivo

@@ -33,6 +33,7 @@ import { Operator, Machine } from "@/app/api/jo";
import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider';
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import { AUTH, hasAbility } from "@/authorities";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import dayjs from "dayjs";
@@ -65,6 +66,7 @@ interface ProductProcessDetailProps {
fromJosave?: boolean;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.0 | 2026-08-05 */
const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
jobOrderId,
onBack,
@@ -74,6 +76,9 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
const { t } = useTranslation(["productionProcess", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
/** 「已完成」(Just Pass):僅 ADMIN */
const canAdminPass = hasAbility(abilities, AUTH.ADMIN);
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext();
const [showOutputPage, setShowOutputPage] = useState(false);
@@ -253,6 +258,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
}, [editingLineId]);

const handlePassLine = useCallback(async (lineId: number) => {
if (!canAdminPass) return;
try {
await passProductProcessLine(lineId);
// 刷新数据
@@ -261,12 +267,13 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
console.error("Error passing line:", error);
alert(t("Failed to pass line. Please try again."));
}
}, [fetchProcessDetail, t]);
}, [canAdminPass, fetchProcessDetail, t]);

const openPassConfirm = useCallback((lineId: number) => {
if (!canAdminPass) return;
setPassConfirmLineId(lineId);
setPassConfirmOpen(true);
}, []);
}, [canAdminPass]);

const closePassConfirm = useCallback(() => {
setPassConfirmOpen(false);
@@ -662,7 +669,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
const isPaused = statusLower === 'paused';
const isPending = statusLower === 'pending' || status === '';
const isPass = statusLower === 'pass';
const isPassDisabled = isCompleted || isPass;
const isPassDisabled = isCompleted || isPass || !canAdminPass;
return (
<TableRow key={line.id}>
<TableCell>


+ 1
- 1
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Ver arquivo

@@ -53,7 +53,7 @@ interface ProductProcessJobOrderDetailProps {
initialTabIndex?: number;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.3 | 2026-08-05 */
const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({
jobOrderId,
onBack,


+ 203
- 43
src/components/ProductionProcess/ProductionProcessList.tsx Ver arquivo

@@ -36,7 +36,7 @@ import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
import { AUTH } from "@/authorities";
import { AUTH, hasAbility } from "@/authorities";


import {
@@ -55,6 +55,7 @@ export type ProductionProcessListTab =
| "needs_action"
| "pending"
| "processing"
| "carried_over"
| "pending_qc"
| "putawayed";

@@ -67,7 +68,7 @@ export type ProductionProcessListPersistedState = {
selectedItemCodes: string[];
/**
* Unified list tabs:
* needs_action (= pending+processing) | pending | processing | pending_qc | putawayed
* needs_action | pending | processing | carried_over | pending_qc | putawayed
* Legacy: all → needs_action; fine pick buckets remapped to pending/processing.
*/
pickBucket: ProductionProcessListTab | string;
@@ -135,7 +136,14 @@ export function createDefaultProductionProcessListPersistedState(): ProductionPr

function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab {
const v = (raw || "needs_action").trim();
if (v === "pending" || v === "processing" || v === "pending_qc" || v === "putawayed" || v === "needs_action") {
if (
v === "pending" ||
v === "processing" ||
v === "carried_over" ||
v === "pending_qc" ||
v === "putawayed" ||
v === "needs_action"
) {
return v;
}
if (v === "all") return "needs_action";
@@ -144,7 +152,30 @@ function normalizeListTab(raw: string | undefined | null): ProductionProcessList
return "needs_action";
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.2 | 2026-08-05 */
function isProcessCarriedOver(
p: AllJoborderProductProcessInfoResponse,
searchDay: ReturnType<typeof dayjs> | null,
): boolean {
if (!searchDay || !p.date || !dayjs(p.date).isValid()) return false;
return dayjs(p.date).startOf("day").isBefore(searchDay);
}

function isPutawayCompleted(
p: AllJoborderProductProcessInfoResponse,
): boolean {
return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed";
}

/** Waiting QC put-away (has SIL, not completed/rejected). */
function isWaitingQcPutAway(
p: AllJoborderProductProcessInfoResponse,
): boolean {
if (p.stockInLineId == null) return false;
const s = String(p.stockInLineStatus ?? "").trim().toLowerCase();
return s !== "completed" && s !== "rejected";
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.3 | 2026-08-05 */
const ProductProcessList: React.FC<ProductProcessListProps> = ({
onSelectProcess,
printerCombo,
@@ -158,6 +189,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const sessionToken = session as SessionWithTokens | null;
const [loading, setLoading] = useState(false);
const [processes, setProcesses] = useState<AllJoborderProductProcessInfoResponse[]>([]);
const [productionCache, setProductionCache] = useState<
AllJoborderProductProcessInfoResponse[]
>([]);
const [pendingQcCache, setPendingQcCache] = useState<
AllJoborderProductProcessInfoResponse[]
>([]);
const [bucketCounts, setBucketCounts] =
useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS);
const [pendingQcCount, setPendingQcCount] = useState(0);
@@ -167,13 +204,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const [modalInfo, setModalInfo] = useState<StockInLineInput>();
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
// 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作
const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN);
/** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單);不含 ADMIN 以免群組 ADMIN 繞過勾選 */
const canManageUpdateJo = hasAbility(abilities, AUTH.PRODUCT_PROCESS);
type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other";

const listTab = normalizeListTab(listPersistedState.pickBucket);
const isProductionTab =
listTab === "needs_action" || listTab === "pending" || listTab === "processing";
const isCarriedOverTab = listTab === "carried_over";
const qcReady = listTab === "pending_qc" || listTab === "putawayed";
const putawayStatus =
listTab === "putawayed"
@@ -182,9 +220,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
? "notCompleted"
: null;
const includePutaway = qcReady ? true : null;
/** Production unfinished tabs: carry-over + pick buckets. Pending QC: carry-over only. */
/** Production unfinished tabs + pending QC + off-plan: carry-over window. */
const enableCarryOver =
!disableDateFilter && (isProductionTab || listTab === "pending_qc");
!disableDateFilter &&
(isProductionTab || listTab === "pending_qc" || isCarriedOverTab);

const appliedSearch = useMemo(
() => ({
@@ -352,27 +391,80 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
setLoading(true);
try {
const typeParam = filter === "all" ? undefined : filter;
// Production tabs share one fetch (bucket=all); pending/processing filter client-side.
const data = await fetchJoborderProductProcessesPage({
const base = {
date: disableDateFilter ? undefined : appliedSearch.date,
itemCode: appliedSearch.itemCode,
jobOrderCode: appliedSearch.jobOrderCode,
type: typeParam,
page: 0,
size: FETCH_SIZE,
};

if (isCarriedOverTab) {
const [prod, pendingQc] = await Promise.all([
fetchJoborderProductProcessesPage({
...base,
qcReady: false,
lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined,
bucket: "all",
}),
fetchJoborderProductProcessesPage({
...base,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined,
}),
]);
const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p));
const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p));
setProductionCache(prodContent);
setPendingQcCache(qcContent);
if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts);
setPendingQcCount(pendingQc?.totalJobOrders || 0);
setCarriedOverCount(prod?.carriedOverCount ?? 0);
const search = appliedSearch.date
? dayjs(appliedSearch.date).startOf("day")
: null;
const merged = [...qcContent, ...prodContent].filter((p) =>
isProcessCarriedOver(p, search),
);
// Dedupe by jobOrderId (prefer waiting-QC row when it has stockInLineId)
const byJo = new Map<number, AllJoborderProductProcessInfoResponse>();
for (const p of merged) {
if (p.jobOrderId == null) continue;
const existing = byJo.get(p.jobOrderId);
if (!existing) {
byJo.set(p.jobOrderId, p);
} else if (existing.stockInLineId == null && p.stockInLineId != null) {
byJo.set(p.jobOrderId, p);
}
}
const content = Array.from(byJo.values());
setProcesses(content);
setTotalJobOrders(content.length);
return;
}

// Production tabs share one fetch (bucket=all); pending/processing filter client-side.
const data = await fetchJoborderProductProcessesPage({
...base,
qcReady,
includePutaway,
putawayStatus,
type: typeParam,
lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined,
bucket: isProductionTab ? "all" : undefined,
page: 0,
size: FETCH_SIZE,
});

setProcesses(data?.content || []);
const content = data?.content || [];
setProcesses(content);
setTotalJobOrders(data?.totalJobOrders || 0);
if (isProductionTab && data?.bucketCounts) {
setBucketCounts(data.bucketCounts);
if (isProductionTab) {
setProductionCache(content);
if (data?.bucketCounts) setBucketCounts(data.bucketCounts);
}
if (qcReady && putawayStatus === "notCompleted") {
setPendingQcCache(content);
setPendingQcCount(data?.totalJobOrders || 0);
}
if (qcReady && putawayStatus === "completed") {
@@ -397,6 +489,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
putawayStatus,
enableCarryOver,
isProductionTab,
isCarriedOverTab,
]);

useEffect(() => {
@@ -413,7 +506,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
jobOrderCode: appliedSearch.jobOrderCode,
type: typeParam,
page: 0,
size: 1,
size: FETCH_SIZE,
};

(async () => {
@@ -434,6 +527,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}),
fetchJoborderProductProcessesPage({
...base,
size: 1,
qcReady: true,
includePutaway: true,
putawayStatus: "completed",
@@ -441,8 +535,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
]);
if (cancelled) return;
if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts);
if (prod?.content) {
setProductionCache(prod.content.filter((p) => !isPutawayCompleted(p)));
}
if (pendingQc?.content) {
setPendingQcCache(
pendingQc.content.filter((p) => !isPutawayCompleted(p)),
);
}
setPendingQcCount(pendingQc?.totalJobOrders || 0);
setPutawayedCount(putawayed?.totalJobOrders || 0);
if (prod?.carriedOverCount != null) setCarriedOverCount(prod.carriedOverCount);
} catch (e) {
console.error(e);
}
@@ -471,6 +574,30 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
bucketCounts.pickedStarted + bucketCounts.notPickedStarted;
const needsActionCount = pendingCount + processingCount;

const offPlanTabCount = useMemo(() => {
if (!searchDay) return 0;
const ids = new Set<number>();
for (const p of productionCache) {
if (
!isPutawayCompleted(p) &&
isProcessCarriedOver(p, searchDay) &&
p.jobOrderId != null
) {
ids.add(p.jobOrderId);
}
}
for (const p of pendingQcCache) {
if (
!isPutawayCompleted(p) &&
isProcessCarriedOver(p, searchDay) &&
p.jobOrderId != null
) {
ids.add(p.jobOrderId);
}
}
return ids.size;
}, [productionCache, pendingQcCache, searchDay]);

const filteredProcesses = useMemo(() => {
let list = processes;
if (listTab === "pending") {
@@ -482,17 +609,20 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")),
);
}
// carried_over: fetch already filtered to off-plan only
if (selectedItemCodes.length === 0) return list;
return list.filter((p) => selectedItemCodes.includes(p.itemCode));
}, [processes, selectedItemCodes, listTab]);

const displayTotalJobOrders = isProductionTab
? listTab === "pending"
? pendingCount
: listTab === "processing"
? processingCount
: totalJobOrders || needsActionCount
: totalJobOrders;
const displayTotalJobOrders = isCarriedOverTab
? filteredProcesses.length
: isProductionTab
? listTab === "pending"
? pendingCount
: listTab === "processing"
? processingCount
: totalJobOrders || needsActionCount
: totalJobOrders;

const displayCarriedOverCount = useMemo(() => {
if (!enableCarryOver || !searchDay) return 0;
@@ -800,6 +930,13 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
value="processing"
label={renderBucketTabLabel("Processing", processingCount)}
/>
<Tab
value="carried_over"
label={renderBucketTabLabel(
"Off-plan unfinished",
offPlanTabCount,
)}
/>
<Tab
value="pending_qc"
label={renderBucketTabLabel(
@@ -868,21 +1005,42 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const statusLower = status.toLowerCase();
const displayStatus =
statusLower === "in_progress" ? "processing" : status;
const chipLabel = qcReady
? putawayStatus === "completed"
? t("Put Awayed")
: t("Waiting QC Put Away")
: t(displayStatus);
const statusColor = qcReady
? putawayStatus === "completed"
? "success"
: "warning"
: statusLower === "completed"
? "success"
: statusLower === "in_progress" ||
statusLower === "processing"
const bucket = String(process.pickProcessBucket ?? "");
const waitingQc = isWaitingQcPutAway(process);

// Avoid t("completed") → purchaseOrder「已上架」; use explicit keys.
let chipLabel: string;
let statusColor: "success" | "warning" | "primary" | "default";
if (listTab === "putawayed") {
chipLabel = t("Put Awayed");
statusColor = "success";
} else if (listTab === "pending_qc" || (isCarriedOverTab && waitingQc)) {
chipLabel = t("Waiting QC Put Away");
statusColor = "warning";
} else if (isCarriedOverTab && PENDING_FINE_BUCKETS.has(bucket)) {
chipLabel = t("pending");
statusColor = "default";
} else if (isCarriedOverTab && PROCESSING_FINE_BUCKETS.has(bucket)) {
chipLabel = t("processing");
statusColor = "primary";
} else if (qcReady) {
chipLabel =
putawayStatus === "completed"
? t("Put Awayed")
: t("Waiting QC Put Away");
statusColor =
putawayStatus === "completed" ? "success" : "warning";
} else if (statusLower === "completed") {
chipLabel = t("Completed");
statusColor = "success";
} else {
chipLabel = t(displayStatus);
statusColor =
statusLower === "in_progress" ||
statusLower === "processing"
? "primary"
: "default";
}

const jobOrderCode =
(process as any).jobOrderCode ??
@@ -896,12 +1054,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const joDay = process.date
? dayjs(process.date).startOf("day")
: null;
const isCarriedOver = Boolean(
enableCarryOver &&
searchDay?.isValid() &&
joDay?.isValid() &&
joDay.isBefore(searchDay),
);
const isCarriedOver =
isCarriedOverTab ||
Boolean(
enableCarryOver &&
searchDay?.isValid() &&
joDay?.isValid() &&
joDay.isBefore(searchDay),
);

const bomDescription = process.bomDescription
? String(process.bomDescription).trim()


+ 4
- 1
src/components/ProductionProcess/ProductionProcessPage.tsx Ver arquivo

@@ -24,7 +24,10 @@ interface ProductionProcessPageProps {

const STORAGE_KEY = 'productionProcess_selectedMatchingStock';

/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20
* FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.2 | 2026-08-05
*/
const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => {
const { t } = useTranslation(["common", "productionProcess"]);
const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null);


+ 62
- 34
src/hooks/useJobOrderFgStockInAlerts.ts Ver arquivo

@@ -1,12 +1,16 @@
"use client";

import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import {
AllJoborderProductProcessInfoResponse,
fetchJoborderProductProcessesPage,
} from "@/app/api/jo/actions";
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";

const POLL_MS = 60_000;

const ALERTS_URL = `${NEXT_PUBLIC_API_URL}/product-process/Demo/Process/alerts/fg-qc-putaway`;
/** Same as ProductionProcessList pending_qc tab. */
export const PRODUCTION_LOOKBACK_DAYS = 4;
const FETCH_SIZE = 200;

export type JobOrderFgAlertItem = {
stockInLineId: number;
@@ -19,35 +23,55 @@ export type JobOrderFgAlertItem = {
lotNo: string | null;
};

function parseRow(o: Record<string, unknown>): JobOrderFgAlertItem {
return {
stockInLineId: Number(o.stockInLineId ?? o.stockinLineId ?? 0),
jobOrderId: Number(o.jobOrderId ?? o.joborderid ?? 0),
jobOrderCode: o.jobOrderCode != null ? String(o.jobOrderCode) : null,
itemNo: o.itemNo != null ? String(o.itemNo) : null,
itemName: o.itemName != null ? String(o.itemName) : null,
status: o.status != null ? String(o.status) : null,
processDate: o.processDate != null ? String(o.processDate) : null,
lotNo: o.lotNo != null ? String(o.lotNo) : null,
};
function isPutawayCompleted(p: AllJoborderProductProcessInfoResponse): boolean {
return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed";
}

function parsePayload(raw: unknown): { qc: JobOrderFgAlertItem[]; putAway: JobOrderFgAlertItem[] } {
if (!raw || typeof raw !== "object") return { qc: [], putAway: [] };
const p = raw as Record<string, unknown>;
const qcRaw = p.qc;
const putAwayRaw = p.putAway;
function toAlertItem(p: AllJoborderProductProcessInfoResponse): JobOrderFgAlertItem | null {
if (!p.stockInLineId || !p.jobOrderId) return null;
return {
qc: Array.isArray(qcRaw) ? qcRaw.map((r) => parseRow(r as Record<string, unknown>)) : [],
putAway: Array.isArray(putAwayRaw)
? putAwayRaw.map((r) => parseRow(r as Record<string, unknown>))
: [],
stockInLineId: p.stockInLineId,
jobOrderId: p.jobOrderId,
jobOrderCode: p.jobOrderCode ?? null,
itemNo: p.itemCode ?? null,
itemName: p.itemName ?? null,
status: p.stockInLineStatus ?? null,
processDate: p.date ?? null,
lotNo: p.lotNo ?? null,
};
}

function splitPendingQcRows(content: AllJoborderProductProcessInfoResponse[]): {
qc: JobOrderFgAlertItem[];
putAway: JobOrderFgAlertItem[];
} {
const qc: JobOrderFgAlertItem[] = [];
const putAway: JobOrderFgAlertItem[] = [];

for (const p of content) {
if (isPutawayCompleted(p)) continue;
const item = toAlertItem(p);
if (!item) continue;
const statusNorm = String(p.stockInLineStatus ?? "").trim().toLowerCase();
if (statusNorm === "received" || statusNorm === "partially_completed") {
putAway.push(item);
} else {
qc.push(item);
}
}

const sortByDate = (a: JobOrderFgAlertItem, b: JobOrderFgAlertItem) =>
(b.processDate ?? "").localeCompare(a.processDate ?? "");

qc.sort(sortByDate);
putAway.sort(sortByDate);
return { qc, putAway };
}

/**
* 與「完成QC工單」相同資格 + 產程日期為今日或昨日;分待 QC / 待上架。
* 與產程「待 QC 上架」tab 相同 API/日期(今日、lookback 4 天);分待 QC / 待上架。
*/
/** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */
export function useJobOrderFgStockInAlerts(enabled: boolean) {
const [qcItems, setQcItems] = useState<JobOrderFgAlertItem[]>([]);
const [putAwayItems, setPutAwayItems] = useState<JobOrderFgAlertItem[]>([]);
@@ -61,15 +85,19 @@ export function useJobOrderFgStockInAlerts(enabled: boolean) {
}
setLoading(true);
try {
const res = await clientAuthFetch(ALERTS_URL);
if (!res.ok) {
setQcItems([]);
setPutAwayItems([]);
return;
}
const data = parsePayload(await res.json());
setQcItems(data.qc);
setPutAwayItems(data.putAway);
const dateStr = dayjs().format("YYYY-MM-DD");
const data = await fetchJoborderProductProcessesPage({
date: dateStr,
lookbackDays: PRODUCTION_LOOKBACK_DAYS,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
page: 0,
size: FETCH_SIZE,
});
const { qc, putAway } = splitPendingQcRows(data?.content ?? []);
setQcItems(qc);
setPutAwayItems(putAway);
} catch {
setQcItems([]);
setPutAwayItems([]);


+ 1
- 0
src/i18n/en/productionProcess.json Ver arquivo

@@ -214,6 +214,7 @@
"Total job orders": "Total job orders",
"Including carried over": "Including carried over",
"Carried over from past day": "Carried over from past day",
"Off-plan unfinished": "Off-plan unfinished",
"All unfinished": "Needs action",
"Needs action": "Needs action",
"Not picked · Not started": "Not picked · Not started",


+ 2
- 1
src/i18n/zh/productionProcess.json Ver arquivo

@@ -77,7 +77,7 @@
"Job Order No.": "工單編號",
"Job Order and Product": "工單及貨品",
"Issue": "異常",
"Job Order Ops Table": "工單流程",
"Job Order Ops Table": "工單生產流程",
"Pending (picked)": "已提料",
"Pending (not picked)": "未提料",
"Processing (picked)": "已提料",
@@ -214,6 +214,7 @@
"Total job orders": "總工單數量",
"Including carried over": "含未按規劃完成工單",
"Carried over from past day": "未按規劃完成工單的工單",
"Off-plan unfinished": "未按規劃完成工單",
"All unfinished": "需處理",
"Needs action": "需處理",
"Not picked · Not started": "未提料 · 未開工",


Carregando…
Cancelar
Salvar