| @@ -1,12 +1,10 @@ | |||
| import ProductionProcessPage from "../../../components/ProductionProcess/ProductionProcessPage"; | |||
| import ProductionProcessLoading from "../../../components/ProductionProcess/ProductionProcessLoading"; | |||
| 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 Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| import Link from "next/link"; | |||
| import { Suspense } from "react"; | |||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||
| @@ -39,7 +37,9 @@ const productionProcess: React.FC = async () => { | |||
| </Button> */} | |||
| </Stack> | |||
| <I18nProvider namespaces={["common", "production","purchaseOrder","jo","dashboard"]}> | |||
| <ProductionProcessPage printerCombo={printerCombo} /> | |||
| <Suspense fallback={<ProductionProcessLoading />}> | |||
| <ProductionProcessPage printerCombo={printerCombo} /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| @@ -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; | |||
| @@ -44,6 +44,8 @@ import Link from "next/link"; | |||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | |||
| import Logo from "../Logo"; | |||
| import { AUTH } from "../../authorities"; | |||
| import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts"; | |||
| import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts"; | |||
| interface NavigationItem { | |||
| icon: React.ReactNode; | |||
| @@ -353,14 +355,39 @@ const NavigationContent: React.FC = () => { | |||
| ]; | |||
| const { t } = useTranslation("common"); | |||
| 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[]>([]); | |||
| // 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(() => { | |||
| 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) => { | |||
| setOpenItems((prevOpenItems) => | |||
| prevOpenItems.includes(label) | |||
| @@ -413,30 +440,125 @@ const NavigationContent: React.FC = () => { | |||
| <List sx={{ pl: 2, py: 0 }}> | |||
| {item.children.map( | |||
| (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={{ | |||
| display: "flex", | |||
| alignItems: "stretch", | |||
| 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> | |||
| @@ -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; | |||
| @@ -30,6 +30,7 @@ import { | |||
| Card, | |||
| CardContent, | |||
| Radio, | |||
| alpha, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||
| @@ -43,7 +44,6 @@ import { | |||
| import { | |||
| checkPolAndCompletePo, | |||
| fetchPoInClient, | |||
| fetchPoListClient, | |||
| fetchPoSummariesClient, | |||
| startPo, | |||
| } from "@/app/api/po/actions"; | |||
| @@ -82,6 +82,8 @@ import { getMailTemplatePdfForStockInLine } from "@/app/api/mailTemplate/actions | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import { EscalationCombo } from "@/app/api/user"; | |||
| import { StockInLine } from "@/app/api/stockIn"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { AUTH } from "@/authorities"; | |||
| //import { useRouter } from "next/navigation"; | |||
| @@ -92,6 +94,41 @@ type Props = { | |||
| 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 = | |||
| | { | |||
| [field in keyof StockInLine]?: string; | |||
| @@ -102,8 +139,8 @@ const PoSearchList: React.FC<{ | |||
| poList: PoResult[]; | |||
| selectedPoId: number; | |||
| onSelect: (po: PoResult) => void; | |||
| }> = ({ poList, selectedPoId, onSelect }) => { | |||
| loading?: boolean; | |||
| }> = ({ poList, selectedPoId, onSelect, loading = false }) => { | |||
| const { t } = useTranslation(["purchaseOrder", "dashboard"]); | |||
| 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) => ( | |||
| <div key={poItem.id}> | |||
| <ListItem disablePadding sx={{ width: '100%' }}> | |||
| <ListItem disablePadding sx={{ width: "100%" }}> | |||
| <ListItemButton | |||
| selected={selectedPoId === poItem.id} | |||
| onClick={() => onSelect(poItem)} | |||
| sx={{ | |||
| width: '100%', | |||
| width: "100%", | |||
| "&.Mui-selected": { | |||
| backgroundColor: "primary.light", | |||
| "&:hover": { | |||
| @@ -159,7 +198,7 @@ const PoSearchList: React.FC<{ | |||
| > | |||
| <ListItemText | |||
| primary={ | |||
| <Typography variant="body2" sx={{ wordBreak: 'break-all' }}> | |||
| <Typography variant="body2" sx={{ wordBreak: "break-all" }}> | |||
| {poItem.code} | |||
| </Typography> | |||
| } | |||
| @@ -174,10 +213,14 @@ const PoSearchList: React.FC<{ | |||
| {index < filteredPoList.length - 1 && <Divider />} | |||
| </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 && ( | |||
| <Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block" }}> | |||
| {`${t("Found")} ${filteredPoList.length} ${t("Purchase Order")}`} | |||
| @@ -195,6 +238,11 @@ interface PolInputResult { | |||
| const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| 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); | |||
| const { t } = useTranslation("purchaseOrder"); | |||
| const apiRef = useGridApiRef(); | |||
| @@ -231,19 +279,22 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| const searchParams = useSearchParams(); | |||
| 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 [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 [poList, setPoList] = useState<PoResult[]>([]); | |||
| const [poList, setPoList] = useState<PoResult[]>(() => [po]); | |||
| const [isPoListLoading, setIsPoListLoading] = useState(false); | |||
| const [selectedPoId, setSelectedPoId] = useState(po.id); | |||
| const [focusField, setFocusField] = useState<HTMLInputElement>(); | |||
| @@ -257,32 +308,25 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| 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 () => { | |||
| if (!selectedIdsParam) return; | |||
| setIsPoListLoading(true); | |||
| 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) { | |||
| console.error("Failed to fetch PO list:", error); | |||
| } finally { | |||
| @@ -342,11 +386,18 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| fetchPoDetail(currentPoId); | |||
| } | |||
| }, [currentPoId, fetchPoDetail]); | |||
| /* | |||
| useEffect(() => { | |||
| fetchPoList(); | |||
| }, [fetchPoList]); | |||
| */ | |||
| if (selectedIdsParam) { | |||
| void fetchPoList(); | |||
| } | |||
| }, [selectedIdsParam, fetchPoList]); | |||
| useEffect(() => { | |||
| if (selectedIdsParam) return; | |||
| setPoList([purchaseOrder]); | |||
| }, [selectedIdsParam, purchaseOrder]); | |||
| useEffect(() => { | |||
| if (currentPoId) { | |||
| setSelectedPoId(parseInt(currentPoId)); | |||
| @@ -547,13 +598,28 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| const receivedTotalText = decimalFormatter.format(totalStockReceived); | |||
| const highlightColor = | |||
| Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; | |||
| const needsStockInAttention = | |||
| canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); | |||
| return ( | |||
| <> | |||
| <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)} | |||
| > | |||
| @@ -568,7 +634,26 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} | |||
| </IconButton> | |||
| </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 | |||
| checked={selectedRow?.id === row.id} | |||
| // 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) => { | |||
| if (value != null) { | |||
| const updatedValue = dayjsToDateString(value) | |||
| @@ -795,15 +872,15 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| <Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}> | |||
| {/* left side select po */} | |||
| <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 */} | |||
| <Grid item xs={8}> | |||
| @@ -3,6 +3,8 @@ import React, { useState, useEffect, useCallback } from "react"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| 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, { | |||
| createDefaultProductionProcessListPersistedState, | |||
| } from "@/components/ProductionProcess/ProductionProcessList"; | |||
| @@ -12,24 +14,9 @@ import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionse | |||
| import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; | |||
| import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; | |||
| 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"; | |||
| type PrinterCombo = { | |||
| id: number; | |||
| value: number; | |||
| label?: string; | |||
| code?: string; | |||
| name?: string; | |||
| description?: string; | |||
| ip?: string; | |||
| port?: number; | |||
| }; | |||
| interface ProductionProcessPageProps { | |||
| printerCombo: PrinterCombo[]; | |||
| } | |||
| @@ -53,7 +40,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| createDefaultProductionProcessListPersistedState, | |||
| ); | |||
| 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 | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | |||
| @@ -104,6 +96,33 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| 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) { | |||
| return ( | |||
| <JobPickExecutionsecondscan | |||
| @@ -127,6 +146,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| } | |||
| return ( | |||
| <> | |||
| <Box> | |||
| {/* Header section with printer selection */} | |||
| {tabIndex === 1 && ( | |||
| @@ -238,6 +258,17 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| <EquipmentStatusDashboard /> | |||
| )} | |||
| </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" | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -34,7 +34,8 @@ type ScanStatusType = "pending" | "scanning" | "retry"; | |||
| const PutAwayScan: React.FC<Props> = ({ warehouse }) => { | |||
| const { t } = useTranslation("putAway"); | |||
| const searchParams = useSearchParams(); | |||
| const [scanDisplay, setScanDisplay] = useState<ScanStatusType>("pending"); | |||
| const [openPutAwayModal, setOpenPutAwayModal] = useState(false); | |||
| const [scannedSilId, setScannedSilId] = useState<number>(0); // TODO use QR code info | |||
| @@ -98,7 +99,17 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => { | |||
| if (scannedSilId > 0) { | |||
| 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 | |||
| useEffect(() => { | |||
| @@ -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 }; | |||
| } | |||
| @@ -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 }; | |||
| } | |||