| @@ -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> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -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 { 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> | ||||
| @@ -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, | 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}> | ||||
| @@ -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" | |||||
| /> | |||||
| </> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -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(() => { | ||||
| @@ -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 }; | |||||
| } | |||||