Bladeren bron

added red spot for stock in po

MergeProblem1
PC-20260115JRSN\Administrator 1 dag geleden
bovenliggende
commit
790a8c8a60
9 gewijzigde bestanden met toevoegingen van 994 en 113 verwijderingen
  1. +4
    -4
      src/app/(main)/productionProcess/page.tsx
  2. +244
    -0
      src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx
  3. +145
    -23
      src/components/NavigationContent/NavigationContent.tsx
  4. +196
    -0
      src/components/NavigationContent/PurchaseStockInNavAlerts.tsx
  5. +144
    -67
      src/components/PoDetail/PoDetail.tsx
  6. +48
    -17
      src/components/ProductionProcess/ProductionProcessPage.tsx
  7. +13
    -2
      src/components/PutAwayScan/PutAwayScan.tsx
  8. +95
    -0
      src/hooks/useJobOrderFgStockInAlerts.ts
  9. +105
    -0
      src/hooks/usePurchaseStockInAlerts.ts

+ 4
- 4
src/app/(main)/productionProcess/page.tsx Bestand weergeven

@@ -1,12 +1,10 @@
import ProductionProcessPage from "../../../components/ProductionProcess/ProductionProcessPage"; import ProductionProcessPage from "../../../components/ProductionProcess/ProductionProcessPage";
import ProductionProcessLoading from "../../../components/ProductionProcess/ProductionProcessLoading";
import { I18nProvider, getServerI18n } from "../../../i18n"; import { I18nProvider, getServerI18n } from "../../../i18n";


import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { Metadata } from "next"; import { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { fetchPrinterCombo } from "@/app/api/settings/printer"; import { fetchPrinterCombo } from "@/app/api/settings/printer";


@@ -39,7 +37,9 @@ const productionProcess: React.FC = async () => {
</Button> */} </Button> */}
</Stack> </Stack>
<I18nProvider namespaces={["common", "production","purchaseOrder","jo","dashboard"]}> <I18nProvider namespaces={["common", "production","purchaseOrder","jo","dashboard"]}>
<ProductionProcessPage printerCombo={printerCombo} />
<Suspense fallback={<ProductionProcessLoading />}>
<ProductionProcessPage printerCombo={printerCombo} />
</Suspense>
</I18nProvider> </I18nProvider>
</> </>
); );


+ 244
- 0
src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx Bestand weergeven

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

import { useJobOrderFgStockInAlerts } from "@/hooks/useJobOrderFgStockInAlerts";
import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts";
import CloseIcon from "@mui/icons-material/Close";
import FactCheckIcon from "@mui/icons-material/FactCheck";
import InventoryIcon from "@mui/icons-material/Inventory";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Badge from "@mui/material/Badge";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import Link from "next/link";
import React, { useState } from "react";

function fmtDt(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}

function fmtProcessDate(s: string | null): string {
if (!s) return "—";
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
return fmtDt(s);
}

function qcHref(stockInLineId: number): string {
return `/productionProcess?openStockInLineId=${stockInLineId}`;
}

function putAwayHref(stockInLineId: number): string {
return `/putAway?stockInLineId=${stockInLineId}`;
}

function statusLabel(s: string | null): string {
const x = (s ?? "").toLowerCase();
if (x === "pending") return "待處理";
if (x === "receiving") return "收貨中";
if (x === "received") return "已收貨";
if (x === "partially_completed") return "部分完成";
return s ?? "—";
}

function AlertTable({
title,
rows,
mode,
onRowNavigate,
}: {
title: string;
rows: JobOrderFgAlertItem[];
mode: "qc" | "putAway";
onRowNavigate: () => void;
}) {
if (rows.length === 0) return null;
return (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" fontWeight={700} color="text.primary" sx={{ mb: 1 }}>
{title}({rows.length})
</Typography>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>工單</TableCell>
<TableCell>成品</TableCell>
<TableCell>狀態</TableCell>
<TableCell>產程日</TableCell>
<TableCell>批號</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((r) => (
<TableRow key={`${mode}-${r.stockInLineId}`} hover>
<TableCell sx={{ fontWeight: 600, whiteSpace: "nowrap" }}>
{r.jobOrderCode ?? `#${r.jobOrderId}`}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>
{r.itemNo ?? "—"}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{r.itemName ?? ""}
</Typography>
</TableCell>
<TableCell>{statusLabel(r.status)}</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmtProcessDate(r.processDate)}</TableCell>
<TableCell>{r.lotNo ?? "—"}</TableCell>
<TableCell align="right">
{mode === "qc" ? (
<Button
component={Link}
href={qcHref(r.stockInLineId)}
size="small"
variant="contained"
color="primary"
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
onClick={onRowNavigate}
>
QC
</Button>
) : (
<Button
component={Link}
href={putAwayHref(r.stockInLineId)}
size="small"
variant="contained"
color="secondary"
startIcon={<InventoryIcon sx={{ fontSize: 16 }} />}
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
onClick={onRowNavigate}
>
上架
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}

type Props = {
enabled: boolean;
};

const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled);
const [open, setOpen] = useState(false);

if (!enabled) return null;

const onRowNavigate = () => setOpen(false);

return (
<>
<Tooltip
title={
count > 0
? `點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(今日/昨日產程、完成QC工單列表資格)`
: "今日/昨日無待 QC/待上架提醒"
}
placement="right"
>
<Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}>
<IconButton
size="small"
aria-label="工單成品 QC 上架提醒"
onClick={() => {
void reload();
setOpen(true);
}}
sx={{ color: count > 0 ? "error.main" : "text.disabled" }}
>
<Badge
color="error"
badgeContent={count > 99 ? "99+" : count}
invisible={count === 0}
sx={{
"& .MuiBadge-badge": {
fontWeight: 800,
animation: count > 0 ? "fpsmsJoFgPulse 1.35s ease-in-out infinite" : "none",
"@keyframes fpsmsJoFgPulse": {
"0%, 100%": { transform: "scale(1)" },
"50%": { transform: "scale(1.12)" },
},
},
}}
>
<FactCheckIcon fontSize="small" />
</Badge>
</IconButton>
</Box>
</Tooltip>

<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth scroll="paper">
<DialogTitle sx={{ pr: 6 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<FactCheckIcon color="error" />
<Typography variant="h6" component="span" fontWeight={700}>
工單成品:待 QC/待上架
</Typography>
{loading && (
<Typography variant="caption" color="text.secondary">
更新中…
</Typography>
)}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
僅含<strong>產程日期為今日或昨日</strong>的工單,且與「完成QC工單」相同條件(該工單<strong>所有工序行</strong>均為
Completed/Pass、有成品入庫且未完成/未拒絕)。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。
</Typography>
<IconButton
aria-label="關閉"
onClick={() => setOpen(false)}
sx={{ position: "absolute", right: 8, top: 8 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{qcItems.length === 0 && putAwayItems.length === 0 && !loading ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
目前沒有符合條件的項目。
</Typography>
) : (
<>
<AlertTable
title="待 QC(等同完成QC工單列表中可開 QC 的階段)"
rows={qcItems}
mode="qc"
onRowNavigate={onRowNavigate}
/>
{qcItems.length > 0 && putAwayItems.length > 0 && <Divider sx={{ my: 2 }} />}
<AlertTable
title="待上架(已完成 QC、待掃碼上架)"
rows={putAwayItems}
mode="putAway"
onRowNavigate={onRowNavigate}
/>
</>
)}
</DialogContent>
</Dialog>
</>
);
};

export default JobOrderFgStockInNavAlerts;

+ 145
- 23
src/components/NavigationContent/NavigationContent.tsx Bestand weergeven

@@ -44,6 +44,8 @@ import Link from "next/link";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";
import Logo from "../Logo"; import Logo from "../Logo";
import { AUTH } from "../../authorities"; import { AUTH } from "../../authorities";
import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts";
import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts";


interface NavigationItem { interface NavigationItem {
icon: React.ReactNode; icon: React.ReactNode;
@@ -353,14 +355,39 @@ const NavigationContent: React.FC = () => {
]; ];
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const pathname = usePathname(); const pathname = usePathname();
const abilitySet = new Set(abilities.map((a) => String(a).trim()));
/** 採購入庫側欄紅點:TESTING / ADMIN / STOCK */
const canSeePoAlerts =
abilitySet.has(AUTH.TESTING) || abilitySet.has(AUTH.ADMIN) || abilitySet.has(AUTH.STOCK);
/** 工單 QC/上架紅點:仍僅 TESTING */
const canSeeJoFgAlerts = abilitySet.has(AUTH.TESTING);


const [openItems, setOpenItems] = React.useState<string[]>([]); const [openItems, setOpenItems] = React.useState<string[]>([]);
// Keep "圖表報告" expanded when on any chart sub-route
/** Keep parent sections expanded on deep links (e.g. /po/edit from nav red spot) so alerts stay visible. */
React.useEffect(() => { React.useEffect(() => {
if (pathname.startsWith("/chart/") && !openItems.includes("圖表報告")) {
setOpenItems((prev) => [...prev, "圖表報告"]);
const ensureOpen: string[] = [];
if (pathname.startsWith("/chart")) {
ensureOpen.push("圖表報告");
} }
}, [pathname, openItems]);
if (pathname === "/po" || pathname.startsWith("/po/")) {
ensureOpen.push("Store Management");
}
if (pathname === "/productionProcess" || pathname.startsWith("/productionProcess/")) {
ensureOpen.push("Management Job Order");
}
if (ensureOpen.length === 0) return;
setOpenItems((prev) => {
const set = new Set(prev);
let changed = false;
for (const label of ensureOpen) {
if (!set.has(label)) {
set.add(label);
changed = true;
}
}
return changed ? Array.from(set) : prev;
});
}, [pathname]);
const toggleItem = (label: string) => { const toggleItem = (label: string) => {
setOpenItems((prevOpenItems) => setOpenItems((prevOpenItems) =>
prevOpenItems.includes(label) prevOpenItems.includes(label)
@@ -413,30 +440,125 @@ const NavigationContent: React.FC = () => {
<List sx={{ pl: 2, py: 0 }}> <List sx={{ pl: 2, py: 0 }}>
{item.children.map( {item.children.map(
(child) => !child.isHidden && hasAbility(child.requiredAbility) && ( (child) => !child.isHidden && hasAbility(child.requiredAbility) && (
<Box
key={`${child.label}-${child.path}`}
component={Link}
href={child.path}
sx={{ textDecoration: "none", color: "inherit" }}
>
<ListItemButton
selected={pathname === child.path || (!!child.path && pathname.startsWith(child.path + "/"))}
child.path === "/po" ? (
<Box
key={`${child.label}-${child.path}`}
sx={{ sx={{
display: "flex",
alignItems: "stretch",
mx: 1, mx: 1,
py: 1,
"&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
borderRadius: 1,
overflow: "hidden",
"&:hover": { bgcolor: "action.hover" },
}} }}
> >
<ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
<ListItemText
primary={t(child.label)}
primaryTypographyProps={{
fontWeight: pathname === child.path || (child.path && pathname.startsWith(child.path + "/")) ? 600 : 500,
fontSize: "0.875rem",
<Box
component={Link}
href={child.path}
sx={{
flex: 1,
minWidth: 0,
textDecoration: "none",
color: "inherit",
display: "flex",
}}
>
<ListItemButton
selected={pathname === child.path || pathname.startsWith(`${child.path}/`)}
sx={{
flex: 1,
py: 1,
pr: 0.5,
"&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
<ListItemText
primary={t(child.label)}
primaryTypographyProps={{
fontWeight:
pathname === child.path || pathname.startsWith(`${child.path}/`) ? 600 : 500,
fontSize: "0.875rem",
}}
/>
</ListItemButton>
</Box>
<PurchaseStockInNavAlerts enabled={canSeePoAlerts} />
</Box>
) : child.path === "/productionProcess" ? (
<Box
key={`${child.label}-${child.path}`}
sx={{
display: "flex",
alignItems: "stretch",
mx: 1,
borderRadius: 1,
overflow: "hidden",
"&:hover": { bgcolor: "action.hover" },
}}
>
<Box
component={Link}
href={child.path}
sx={{
flex: 1,
minWidth: 0,
textDecoration: "none",
color: "inherit",
display: "flex",
}}
>
<ListItemButton
selected={pathname === child.path || pathname.startsWith(`${child.path}/`)}
sx={{
flex: 1,
py: 1,
pr: 0.5,
"&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
<ListItemText
primary={t(child.label)}
primaryTypographyProps={{
fontWeight:
pathname === child.path || pathname.startsWith(`${child.path}/`) ? 600 : 500,
fontSize: "0.875rem",
}}
/>
</ListItemButton>
</Box>
<JobOrderFgStockInNavAlerts enabled={canSeeJoFgAlerts} />
</Box>
) : (
<Box
key={`${child.label}-${child.path}`}
component={Link}
href={child.path}
sx={{ textDecoration: "none", color: "inherit" }}
>
<ListItemButton
selected={pathname === child.path || (!!child.path && pathname.startsWith(child.path + "/"))}
sx={{
mx: 1,
py: 1,
"&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
}} }}
/>
</ListItemButton>
</Box>
>
<ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
<ListItemText
primary={t(child.label)}
primaryTypographyProps={{
fontWeight:
pathname === child.path || (child.path && pathname.startsWith(child.path + "/"))
? 600
: 500,
fontSize: "0.875rem",
}}
/>
</ListItemButton>
</Box>
)
), ),
)} )}
</List> </List>


+ 196
- 0
src/components/NavigationContent/PurchaseStockInNavAlerts.tsx Bestand weergeven

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

import { usePurchaseStockInAlerts } from "@/hooks/usePurchaseStockInAlerts";
import AssignmentLateIcon from "@mui/icons-material/AssignmentLate";
import CloseIcon from "@mui/icons-material/Close";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Badge from "@mui/material/Badge";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import Link from "next/link";
import React, { useState } from "react";

function statusLabel(s: string | null): string {
const x = (s ?? "").toLowerCase();
if (x === "pending") return "待處理";
if (x === "receiving") return "收貨中";
return s ?? "—";
}

function fmtDt(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}

/** Do not pass `stockInLineId` — PoInputGrid opens QC/stock-in modal when that query exists; user should see the list first. */
function poEditHref(row: {
purchaseOrderId: number;
purchaseOrderLineId: number;
}): string {
const q = new URLSearchParams({
id: String(row.purchaseOrderId),
polId: String(row.purchaseOrderLineId),
selectedIds: String(row.purchaseOrderId),
});
return `/po/edit?${q.toString()}`;
}

type Props = {
enabled: boolean;
};

/**
* Sidebar control: opens dialog with recent pending/receiving PO stock-in lines and links to PO edit.
*/
const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
const { items, count, loading, reload } = usePurchaseStockInAlerts(enabled);
const [open, setOpen] = useState(false);

if (!enabled) return null;

return (
<>
<Tooltip
title={
count > 0
? `點擊查看 ${count} 筆近日待完成入庫(待處理/收貨中)並前往處理`
: "近日無待完成採購入庫提醒"
}
placement="right"
>
<Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}>
<IconButton
size="small"
aria-label="採購入庫提醒"
onClick={() => {
void reload();
setOpen(true);
}}
sx={{
color: count > 0 ? "error.main" : "text.disabled",
}}
>
<Badge
color="error"
badgeContent={count > 99 ? "99+" : count}
invisible={count === 0}
sx={{
"& .MuiBadge-badge": {
fontWeight: 800,
animation: count > 0 ? "fpsmsQuestPulse 1.35s ease-in-out infinite" : "none",
"@keyframes fpsmsQuestPulse": {
"0%, 100%": { transform: "scale(1)" },
"50%": { transform: "scale(1.12)" },
},
},
}}
>
<AssignmentLateIcon fontSize="small" />
</Badge>
</IconButton>
</Box>
</Tooltip>

<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth scroll="paper">
<DialogTitle sx={{ pr: 6 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<AssignmentLateIcon color="error" />
<Typography variant="h6" component="span" fontWeight={700}>
近日待完成採購入庫
</Typography>
{loading && (
<Typography variant="caption" color="text.secondary">
更新中…
</Typography>
)}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
僅列出近幾日建立、狀態為「待處理」或「收貨中」的採購入庫明細。點「前往處理」開啟採購單並定位該明細,可先檢視下方入庫清單再操作。
</Typography>
<IconButton
aria-label="關閉"
onClick={() => setOpen(false)}
sx={{ position: "absolute", right: 8, top: 8 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{count > items.length && items.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1 }}>
共 {count} 筆符合條件,以下顯示最近 {items.length} 筆(可於後端調高單次上限)。
</Typography>
)}
{items.length === 0 && !loading ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
目前沒有符合條件的待完成入庫項目。
</Typography>
) : (
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>採購單</TableCell>
<TableCell>物料</TableCell>
<TableCell>狀態</TableCell>
<TableCell>建立時間</TableCell>
<TableCell>批號</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((r) => (
<TableRow key={r.stockInLineId} hover>
<TableCell sx={{ fontWeight: 600, whiteSpace: "nowrap" }}>
{r.poCode ?? `#${r.purchaseOrderId}`}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>
{r.itemNo ?? "—"}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{r.itemName ?? ""}
</Typography>
</TableCell>
<TableCell>{statusLabel(r.status)}</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmtDt(r.lineCreated)}</TableCell>
<TableCell>{r.lotNo ?? "—"}</TableCell>
<TableCell align="right">
<Button
component={Link}
href={poEditHref(r)}
target="_blank"
rel="noopener noreferrer"
size="small"
variant="contained"
color="primary"
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
onClick={() => setOpen(false)}
>
前往處理
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
</>
);
};

export default PurchaseStockInNavAlerts;

+ 144
- 67
src/components/PoDetail/PoDetail.tsx Bestand weergeven

@@ -30,6 +30,7 @@ import {
Card, Card,
CardContent, CardContent,
Radio, Radio,
alpha,
} from "@mui/material"; } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { submitDialogWithWarning } from "../Swal/CustomAlerts"; import { submitDialogWithWarning } from "../Swal/CustomAlerts";
@@ -43,7 +44,6 @@ import {
import { import {
checkPolAndCompletePo, checkPolAndCompletePo,
fetchPoInClient, fetchPoInClient,
fetchPoListClient,
fetchPoSummariesClient, fetchPoSummariesClient,
startPo, startPo,
} from "@/app/api/po/actions"; } from "@/app/api/po/actions";
@@ -82,6 +82,8 @@ import { getMailTemplatePdfForStockInLine } from "@/app/api/mailTemplate/actions
import { PrinterCombo } from "@/app/api/settings/printer"; import { PrinterCombo } from "@/app/api/settings/printer";
import { EscalationCombo } from "@/app/api/user"; import { EscalationCombo } from "@/app/api/user";
import { StockInLine } from "@/app/api/stockIn"; import { StockInLine } from "@/app/api/stockIn";
import { useSession } from "next-auth/react";
import { AUTH } from "@/authorities";
//import { useRouter } from "next/navigation"; //import { useRouter } from "next/navigation";




@@ -92,6 +94,41 @@ type Props = {
printerCombo: PrinterCombo[]; printerCombo: PrinterCombo[];
}; };


/** PO stock-in lines still in pre-complete workflow (align with nav alert: pending / receiving). */
const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]);

/** Sum of put-away in stock units (matches StockInForm「已上架數量」stockQty). */
function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number {
return row.stockInLine
.filter((sil) => sil.purchaseOrderLineId === row.id)
.reduce((acc, sil) => {
const lineSum =
sil.putAwayLines?.reduce(
(s, p) => s + Number(p.stockQty ?? p.qty ?? 0),
0,
) ?? 0;
return acc + lineSum;
}, 0);
}

/** POL order demand in stock units (same basis as PoDetail processed / backend PO detail). */
function polOrderStockQty(row: PurchaseOrderLine): number {
return Number(row.stockUom?.stockQty ?? row.qty ?? 0);
}

function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean {
const orderStock = polOrderStockQty(row);
const putAway = totalPutAwayStockQtyForPol(row);
if (orderStock > 0 && putAway >= orderStock) {
return false;
}
return row.stockInLine
.filter((sil) => sil.purchaseOrderLineId === row.id)
.some((sil) =>
PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()),
);
}

type EntryError = type EntryError =
| { | {
[field in keyof StockInLine]?: string; [field in keyof StockInLine]?: string;
@@ -102,8 +139,8 @@ const PoSearchList: React.FC<{
poList: PoResult[]; poList: PoResult[];
selectedPoId: number; selectedPoId: number;
onSelect: (po: PoResult) => void; onSelect: (po: PoResult) => void;
}> = ({ poList, selectedPoId, onSelect }) => {
loading?: boolean;
}> = ({ poList, selectedPoId, onSelect, loading = false }) => {
const { t } = useTranslation(["purchaseOrder", "dashboard"]); const { t } = useTranslation(["purchaseOrder", "dashboard"]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');


@@ -139,16 +176,18 @@ const PoSearchList: React.FC<{
), ),
}} }}
/> />
{(filteredPoList.length > 0)? (
<List dense sx={{ width: '100%' }}>
{loading ? (
<LoadingComponent />
) : filteredPoList.length > 0 ? (
<List dense sx={{ width: "100%" }}>
{filteredPoList.map((poItem, index) => ( {filteredPoList.map((poItem, index) => (
<div key={poItem.id}> <div key={poItem.id}>
<ListItem disablePadding sx={{ width: '100%' }}>
<ListItem disablePadding sx={{ width: "100%" }}>
<ListItemButton <ListItemButton
selected={selectedPoId === poItem.id} selected={selectedPoId === poItem.id}
onClick={() => onSelect(poItem)} onClick={() => onSelect(poItem)}
sx={{ sx={{
width: '100%',
width: "100%",
"&.Mui-selected": { "&.Mui-selected": {
backgroundColor: "primary.light", backgroundColor: "primary.light",
"&:hover": { "&:hover": {
@@ -159,7 +198,7 @@ const PoSearchList: React.FC<{
> >
<ListItemText <ListItemText
primary={ primary={
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
<Typography variant="body2" sx={{ wordBreak: "break-all" }}>
{poItem.code} {poItem.code}
</Typography> </Typography>
} }
@@ -174,10 +213,14 @@ const PoSearchList: React.FC<{
{index < filteredPoList.length - 1 && <Divider />} {index < filteredPoList.length - 1 && <Divider />}
</div> </div>
))} ))}
</List>) : (
<LoadingComponent/>
)
}
</List>
) : (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{searchTerm.trim()
? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" })
: t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })}
</Typography>
)}
{searchTerm && ( {searchTerm && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block" }}> <Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block" }}>
{`${t("Found")} ${filteredPoList.length} ${t("Purchase Order")}`} {`${t("Found")} ${filteredPoList.length} ${t("Purchase Order")}`}
@@ -195,6 +238,11 @@ interface PolInputResult {


const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const cameras = useContext(CameraContext); const cameras = useContext(CameraContext);
const { data: session } = useSession();
const canSeeStockInReminders = useMemo(() => {
const set = new Set((session?.user?.abilities ?? []).map((a) => String(a).trim()));
return set.has(AUTH.TESTING) || set.has(AUTH.ADMIN) || set.has(AUTH.STOCK);
}, [session?.user?.abilities]);
// console.log(cameras); // console.log(cameras);
const { t } = useTranslation("purchaseOrder"); const { t } = useTranslation("purchaseOrder");
const apiRef = useGridApiRef(); const apiRef = useGridApiRef();
@@ -231,19 +279,22 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [selectedRow, setSelectedRow] = useState<PurchaseOrderLine | null>(null); const [selectedRow, setSelectedRow] = useState<PurchaseOrderLine | null>(null);
const defaultPolId = searchParams.get("polId")
useEffect(() => {
if (defaultPolId) {
setSelectedRow(rows.find((r) => r.id.toString() === defaultPolId) ?? null)
console.log("%c StockIn:", "color:green", selectedRow);
}
}, [])

const [stockInLine, setStockInLine] = useState<StockInLine[]>([]); const [stockInLine, setStockInLine] = useState<StockInLine[]>([]);
const [processedQty, setProcessedQty] = useState(0); const [processedQty, setProcessedQty] = useState(0);


useEffect(() => {
const polIdParam = searchParams.get("polId");
if (!polIdParam || rows.length === 0) return;
const match = rows.find((r) => r.id.toString() === polIdParam);
if (match) {
setSelectedRow(match);
setStockInLine(match.stockInLine);
setProcessedQty(match.processed);
}
}, [rows, searchParams]);

const router = useRouter(); const router = useRouter();
const [poList, setPoList] = useState<PoResult[]>([]);
const [poList, setPoList] = useState<PoResult[]>(() => [po]);
const [isPoListLoading, setIsPoListLoading] = useState(false); const [isPoListLoading, setIsPoListLoading] = useState(false);
const [selectedPoId, setSelectedPoId] = useState(po.id); const [selectedPoId, setSelectedPoId] = useState(po.id);
const [focusField, setFocusField] = useState<HTMLInputElement>(); const [focusField, setFocusField] = useState<HTMLInputElement>();
@@ -257,32 +308,25 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
receiptDate: dayjsToDateString(dayjs()) receiptDate: dayjsToDateString(dayjs())
} }
}) })
/** Only loads sidebar list when `selectedIds` is in the URL; otherwise show current PO only (no /po/list fetch). */
const fetchPoList = useCallback(async () => { const fetchPoList = useCallback(async () => {
if (!selectedIdsParam) return;
setIsPoListLoading(true); setIsPoListLoading(true);
try { try {
if (selectedIdsParam) {
const MAX_IDS = 20; // 一次最多加载 20 个,防止卡死
const allIds = selectedIdsParam
.split(',')
.map(id => parseInt(id))
.filter(id => !Number.isNaN(id));
const limitedIds = allIds.slice(0, MAX_IDS);
if (allIds.length > MAX_IDS) {
console.warn(
`selectedIds too many (${allIds.length}), only loading first ${MAX_IDS}.`
);
}
const result = await fetchPoSummariesClient(limitedIds);
setPoList(result as any);
} else {
const result = await fetchPoListClient({ limit: 20, offset: 0 });
if (result && result.records) {
setPoList(result.records);
}
const MAX_IDS = 20; // 一次最多加载 20 个,防止卡死

const allIds = selectedIdsParam
.split(',')
.map((id) => parseInt(id))
.filter((id) => !Number.isNaN(id));

const limitedIds = allIds.slice(0, MAX_IDS);

if (allIds.length > MAX_IDS) {
console.warn(`selectedIds too many (${allIds.length}), only loading first ${MAX_IDS}.`);
} }
const result = await fetchPoSummariesClient(limitedIds);
setPoList(result as any);
} catch (error) { } catch (error) {
console.error("Failed to fetch PO list:", error); console.error("Failed to fetch PO list:", error);
} finally { } finally {
@@ -342,11 +386,18 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
fetchPoDetail(currentPoId); fetchPoDetail(currentPoId);
} }
}, [currentPoId, fetchPoDetail]); }, [currentPoId, fetchPoDetail]);
/*
useEffect(() => { useEffect(() => {
fetchPoList();
}, [fetchPoList]);
*/
if (selectedIdsParam) {
void fetchPoList();
}
}, [selectedIdsParam, fetchPoList]);

useEffect(() => {
if (selectedIdsParam) return;
setPoList([purchaseOrder]);
}, [selectedIdsParam, purchaseOrder]);

useEffect(() => { useEffect(() => {
if (currentPoId) { if (currentPoId) {
setSelectedPoId(parseInt(currentPoId)); setSelectedPoId(parseInt(currentPoId));
@@ -547,13 +598,28 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const receivedTotalText = decimalFormatter.format(totalStockReceived); const receivedTotalText = decimalFormatter.format(totalStockReceived);
const highlightColor = const highlightColor =
Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit";
const needsStockInAttention =
canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row);
return ( return (
<> <>


<TableRow <TableRow
sx={{ "& > *": { borderBottom: "unset" },
color: "black"
hover
title={
needsStockInAttention
? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。"
: undefined
}
sx={{
"& > *": { borderBottom: "unset" },
color: "black",
...(needsStockInAttention
? (theme) => ({
boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`,
backgroundColor: alpha(theme.palette.error.main, 0.07),
})
: {}),
}} }}
onClick={() => changeStockInLines(row.id)} onClick={() => changeStockInLines(row.id)}
> >
@@ -568,7 +634,26 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton> </IconButton>
</TableCell> */} </TableCell> */}
<TableCell align="center" sx={{ width: '60px' }}>
<TableCell align="center" sx={{ width: "60px", position: "relative" }}>
{needsStockInAttention && (
<Box
component="span"
aria-hidden
sx={{
position: "absolute",
top: 6,
left: 8,
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: "error.main",
border: "2px solid",
borderColor: "background.paper",
boxShadow: (theme) => `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`,
zIndex: 1,
}}
/>
)}
<Radio <Radio
checked={selectedRow?.id === row.id} checked={selectedRow?.id === row.id}
// onChange={handleRowSelect} // onChange={handleRowSelect}
@@ -761,14 +846,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
} }
}, []); }, []);


useEffect(() => {
const params = searchParams.get("polId")
if (params) {
const polId = parseInt(params)

}
}, [searchParams])

const handleDatePickerChange = useCallback((value: Dayjs | null, onChange: (...event: any[]) => void) => { const handleDatePickerChange = useCallback((value: Dayjs | null, onChange: (...event: any[]) => void) => {
if (value != null) { if (value != null) {
const updatedValue = dayjsToDateString(value) const updatedValue = dayjsToDateString(value)
@@ -795,15 +872,15 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}> <Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}>
{/* left side select po */} {/* left side select po */}
<Grid item xs={4}> <Grid item xs={4}>
<Stack spacing={1}>
<PoSearchList
poList={poList}
selectedPoId={selectedPoId}
onSelect={handlePoSelect}
/>
</Stack>
</Grid>
<Stack spacing={1}>
<PoSearchList
poList={poList}
selectedPoId={selectedPoId}
onSelect={handlePoSelect}
loading={isPoListLoading}
/>
</Stack>
</Grid>


{/* right side po info */} {/* right side po info */}
<Grid item xs={8}> <Grid item xs={8}>


+ 48
- 17
src/components/ProductionProcess/ProductionProcessPage.tsx Bestand weergeven

@@ -3,6 +3,8 @@ import React, { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig"; import { SessionWithTokens } from "@/config/authConfig";
import { Box, Tabs, Tab, Stack, Typography, Autocomplete, TextField } from "@mui/material"; import { Box, Tabs, Tab, Stack, Typography, Autocomplete, TextField } from "@mui/material";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import QcStockInModal from "@/components/Qc/QcStockInModal";
import ProductionProcessList, { import ProductionProcessList, {
createDefaultProductionProcessListPersistedState, createDefaultProductionProcessListPersistedState,
} from "@/components/ProductionProcess/ProductionProcessList"; } from "@/components/ProductionProcess/ProductionProcessList";
@@ -12,24 +14,9 @@ import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionse
import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus";
import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard";
import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard";
import {
fetchProductProcesses,
fetchProductProcessesByJobOrderId,
ProductProcessLineResponse
} from "@/app/api/jo/actions";
import type { PrinterCombo } from "@/app/api/settings/printer";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";


type PrinterCombo = {
id: number;
value: number;
label?: string;
code?: string;
name?: string;
description?: string;
ip?: string;
port?: number;
};

interface ProductionProcessPageProps { interface ProductionProcessPageProps {
printerCombo: PrinterCombo[]; printerCombo: PrinterCombo[];
} }
@@ -53,7 +40,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
createDefaultProductionProcessListPersistedState, createDefaultProductionProcessListPersistedState,
); );
const { data: session } = useSession() as { data: SessionWithTokens | null }; const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const sessionToken = session as SessionWithTokens | null;
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [linkQcOpen, setLinkQcOpen] = useState(false);
const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null);


// Add printer selection state // Add printer selection state
const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
@@ -104,6 +96,33 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
setTabIndex(newValue); setTabIndex(newValue);
}, []); }, []);


const openStockInLineIdQ = searchParams.get("openStockInLineId");

/** Deep link from nav alert: /productionProcess?openStockInLineId=… → 「完成QC工單」tab + FG QC modal */
useEffect(() => {
if (!openStockInLineIdQ) {
setLinkQcOpen(false);
setLinkQcSilId(null);
return;
}
const id = parseInt(openStockInLineIdQ, 10);
if (!Number.isFinite(id) || id <= 0) return;
setSelectedProcessId(null);
setSelectedMatchingStock(null);
setTabIndex(1);
setLinkQcSilId(id);
setLinkQcOpen(true);
}, [openStockInLineIdQ]);

const closeLinkQc = useCallback(() => {
setLinkQcOpen(false);
setLinkQcSilId(null);
const p = new URLSearchParams(searchParams.toString());
p.delete("openStockInLineId");
const q = p.toString();
router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
}, [pathname, router, searchParams]);

if (selectedMatchingStock) { if (selectedMatchingStock) {
return ( return (
<JobPickExecutionsecondscan <JobPickExecutionsecondscan
@@ -127,6 +146,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
} }


return ( return (
<>
<Box> <Box>
{/* Header section with printer selection */} {/* Header section with printer selection */}
{tabIndex === 1 && ( {tabIndex === 1 && (
@@ -238,6 +258,17 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
<EquipmentStatusDashboard /> <EquipmentStatusDashboard />
)} )}
</Box> </Box>
<QcStockInModal
session={sessionToken}
open={Boolean(linkQcOpen && linkQcSilId != null && linkQcSilId > 0)}
onClose={closeLinkQc}
inputDetail={linkQcSilId != null && linkQcSilId > 0 ? { id: linkQcSilId } : undefined}
printerCombo={printerCombo}
warehouse={[]}
printSource="productionProcess"
uiMode="default"
/>
</>
); );
}; };



+ 13
- 2
src/components/PutAwayScan/PutAwayScan.tsx Bestand weergeven

@@ -34,7 +34,8 @@ type ScanStatusType = "pending" | "scanning" | "retry";


const PutAwayScan: React.FC<Props> = ({ warehouse }) => { const PutAwayScan: React.FC<Props> = ({ warehouse }) => {
const { t } = useTranslation("putAway"); const { t } = useTranslation("putAway");
const searchParams = useSearchParams();

const [scanDisplay, setScanDisplay] = useState<ScanStatusType>("pending"); const [scanDisplay, setScanDisplay] = useState<ScanStatusType>("pending");
const [openPutAwayModal, setOpenPutAwayModal] = useState(false); const [openPutAwayModal, setOpenPutAwayModal] = useState(false);
const [scannedSilId, setScannedSilId] = useState<number>(0); // TODO use QR code info const [scannedSilId, setScannedSilId] = useState<number>(0); // TODO use QR code info
@@ -98,7 +99,17 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => {
if (scannedSilId > 0) { if (scannedSilId > 0) {
openModal(); openModal();
} }
}, [scannedSilId])
}, [scannedSilId]);

const stockInLineIdQ = searchParams.get("stockInLineId");

/** Deep link from nav alert: /putAway?stockInLineId=… */
useEffect(() => {
if (!stockInLineIdQ) return;
const id = parseInt(stockInLineIdQ, 10);
if (!Number.isFinite(id) || id <= 0) return;
setScannedSilId((prev) => (prev === id ? prev : id));
}, [stockInLineIdQ]);


// Get Scanned Values // Get Scanned Values
useEffect(() => { useEffect(() => {


+ 95
- 0
src/hooks/useJobOrderFgStockInAlerts.ts Bestand weergeven

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

import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
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`;

export type JobOrderFgAlertItem = {
stockInLineId: number;
jobOrderId: number;
jobOrderCode: string | null;
itemNo: string | null;
itemName: string | null;
status: string | null;
processDate: string | null;
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 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;
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>))
: [],
};
}

/**
* 與「完成QC工單」相同資格 + 產程日期為今日或昨日;分待 QC / 待上架。
*/
export function useJobOrderFgStockInAlerts(enabled: boolean) {
const [qcItems, setQcItems] = useState<JobOrderFgAlertItem[]>([]);
const [putAwayItems, setPutAwayItems] = useState<JobOrderFgAlertItem[]>([]);
const [loading, setLoading] = useState(false);

const load = useCallback(async () => {
if (!enabled) {
setQcItems([]);
setPutAwayItems([]);
return;
}
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);
} catch {
setQcItems([]);
setPutAwayItems([]);
} finally {
setLoading(false);
}
}, [enabled]);

useEffect(() => {
if (!enabled) {
setQcItems([]);
setPutAwayItems([]);
return;
}
void load();
const id = window.setInterval(() => void load(), POLL_MS);
return () => window.clearInterval(id);
}, [enabled, load]);

const count = qcItems.length + putAwayItems.length;

return { qcItems, putAwayItems, count, loading, reload: load };
}

+ 105
- 0
src/hooks/usePurchaseStockInAlerts.ts Bestand weergeven

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

import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { useCallback, useEffect, useState } from "react";

const POLL_MS = 60_000;

/** Match backend [PurchaseStockInAlertRow] JSON. */
export type PurchaseStockInAlertItem = {
stockInLineId: number;
purchaseOrderId: number;
purchaseOrderLineId: number;
poCode: string | null;
itemNo: string | null;
itemName: string | null;
status: string | null;
lineCreated: string | null;
receiptDate: string | null;
lotNo: string | null;
};

function parseAlertsPayload(raw: unknown): PurchaseStockInAlertItem[] {
if (!Array.isArray(raw)) return [];
return raw.map((r) => {
const o = r as Record<string, unknown>;
return {
stockInLineId: Number(o.stockInLineId ?? o.stockinLineId ?? 0),
purchaseOrderId: Number(o.purchaseOrderId ?? o.purchaseorderid ?? 0),
purchaseOrderLineId: Number(o.purchaseOrderLineId ?? o.purchaseorderlineid ?? 0),
poCode: o.poCode != null ? String(o.poCode) : 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,
lineCreated: o.lineCreated != null ? String(o.lineCreated) : null,
receiptDate: o.receiptDate != null ? String(o.receiptDate) : null,
lotNo: o.lotNo != null ? String(o.lotNo) : null,
};
});
}

/**
* Recent PO stock-in lines in pending / receiving (backend lookback window).
* Fetches full list for alert dialog + count for badge.
*/
export function usePurchaseStockInAlerts(enabled: boolean, days?: number) {
const [items, setItems] = useState<PurchaseStockInAlertItem[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);

const load = useCallback(async () => {
if (!enabled) {
setItems([]);
setCount(0);
return;
}
setLoading(true);
try {
const listParams = new URLSearchParams();
if (days != null && Number.isFinite(days)) listParams.set("days", String(days));
listParams.set("limit", "80");
const listUrl = `${NEXT_PUBLIC_API_URL}/stockInLine/alerts/purchase-incomplete?${listParams}`;

const countParams = new URLSearchParams();
if (days != null && Number.isFinite(days)) countParams.set("days", String(days));
const countSuffix = countParams.toString() ? `?${countParams}` : "";
const countUrl = `${NEXT_PUBLIC_API_URL}/stockInLine/alerts/purchase-incomplete-count${countSuffix}`;
const [resList, resCount] = await Promise.all([
clientAuthFetch(listUrl),
clientAuthFetch(countUrl),
]);
if (!resList.ok) {
setItems([]);
} else {
const data = await resList.json();
setItems(parseAlertsPayload(data));
}
if (resCount.ok) {
const c = (await resCount.json()) as { count?: number };
const n = Number(c.count ?? 0);
setCount(Number.isFinite(n) && n > 0 ? n : 0);
} else {
setCount(0);
}
} catch {
setItems([]);
setCount(0);
} finally {
setLoading(false);
}
}, [enabled, days]);

useEffect(() => {
if (!enabled) {
setItems([]);
setCount(0);
return;
}
void load();
const id = window.setInterval(() => void load(), POLL_MS);
return () => window.clearInterval(id);
}, [enabled, load]);

return { items, count, loading, reload: load };
}

Laden…
Annuleren
Opslaan