diff --git a/src/app/(main)/productionProcess/page.tsx b/src/app/(main)/productionProcess/page.tsx index 8c4c901..9d1ee22 100644 --- a/src/app/(main)/productionProcess/page.tsx +++ b/src/app/(main)/productionProcess/page.tsx @@ -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 () => { */} - + }> + + ); diff --git a/src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx b/src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx new file mode 100644 index 0000000..b463e82 --- /dev/null +++ b/src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx @@ -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 ( + + + {title}({rows.length}) + + + + + 工單 + 成品 + 狀態 + 產程日 + 批號 + 操作 + + + + {rows.map((r) => ( + + + {r.jobOrderCode ?? `#${r.jobOrderId}`} + + + + {r.itemNo ?? "—"} + + + {r.itemName ?? ""} + + + {statusLabel(r.status)} + {fmtProcessDate(r.processDate)} + {r.lotNo ?? "—"} + + {mode === "qc" ? ( + + ) : ( + + )} + + + ))} + +
+
+ ); +} + +type Props = { + enabled: boolean; +}; + +const JobOrderFgStockInNavAlerts: React.FC = ({ enabled }) => { + const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled); + const [open, setOpen] = useState(false); + + if (!enabled) return null; + + const onRowNavigate = () => setOpen(false); + + return ( + <> + 0 + ? `點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(今日/昨日產程、完成QC工單列表資格)` + : "今日/昨日無待 QC/待上架提醒" + } + placement="right" + > + + { + void reload(); + setOpen(true); + }} + sx={{ color: count > 0 ? "error.main" : "text.disabled" }} + > + 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)" }, + }, + }, + }} + > + + + + + + + setOpen(false)} maxWidth="md" fullWidth scroll="paper"> + + + + + 工單成品:待 QC/待上架 + + {loading && ( + + 更新中… + + )} + + + 僅含產程日期為今日或昨日的工單,且與「完成QC工單」相同條件(該工單所有工序行均為 + Completed/Pass、有成品入庫且未完成/未拒絕)。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。 + + setOpen(false)} + sx={{ position: "absolute", right: 8, top: 8 }} + > + + + + + {qcItems.length === 0 && putAwayItems.length === 0 && !loading ? ( + + 目前沒有符合條件的項目。 + + ) : ( + <> + + {qcItems.length > 0 && putAwayItems.length > 0 && } + + + )} + + + + ); +}; + +export default JobOrderFgStockInNavAlerts; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 15d7604..2404ee8 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -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([]); - // 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 = () => { {item.children.map( (child) => !child.isHidden && hasAbility(child.requiredAbility) && ( - - - {child.icon} - + + {child.icon} + + + + + + ) : child.path === "/productionProcess" ? ( + + + + {child.icon} + + + + + + ) : ( + + - - + > + {child.icon} + + + + ) ), )} diff --git a/src/components/NavigationContent/PurchaseStockInNavAlerts.tsx b/src/components/NavigationContent/PurchaseStockInNavAlerts.tsx new file mode 100644 index 0000000..1fc37af --- /dev/null +++ b/src/components/NavigationContent/PurchaseStockInNavAlerts.tsx @@ -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 = ({ enabled }) => { + const { items, count, loading, reload } = usePurchaseStockInAlerts(enabled); + const [open, setOpen] = useState(false); + + if (!enabled) return null; + + return ( + <> + 0 + ? `點擊查看 ${count} 筆近日待完成入庫(待處理/收貨中)並前往處理` + : "近日無待完成採購入庫提醒" + } + placement="right" + > + + { + void reload(); + setOpen(true); + }} + sx={{ + color: count > 0 ? "error.main" : "text.disabled", + }} + > + 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)" }, + }, + }, + }} + > + + + + + + + setOpen(false)} maxWidth="md" fullWidth scroll="paper"> + + + + + 近日待完成採購入庫 + + {loading && ( + + 更新中… + + )} + + + 僅列出近幾日建立、狀態為「待處理」或「收貨中」的採購入庫明細。點「前往處理」開啟採購單並定位該明細,可先檢視下方入庫清單再操作。 + + setOpen(false)} + sx={{ position: "absolute", right: 8, top: 8 }} + > + + + + + {count > items.length && items.length > 0 && ( + + 共 {count} 筆符合條件,以下顯示最近 {items.length} 筆(可於後端調高單次上限)。 + + )} + {items.length === 0 && !loading ? ( + + 目前沒有符合條件的待完成入庫項目。 + + ) : ( + + + + 採購單 + 物料 + 狀態 + 建立時間 + 批號 + 操作 + + + + {items.map((r) => ( + + + {r.poCode ?? `#${r.purchaseOrderId}`} + + + + {r.itemNo ?? "—"} + + + {r.itemName ?? ""} + + + {statusLabel(r.status)} + {fmtDt(r.lineCreated)} + {r.lotNo ?? "—"} + + + + + ))} + +
+ )} +
+
+ + ); +}; + +export default PurchaseStockInNavAlerts; diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index dd8910f..b83fdd1 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -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)? ( - + {loading ? ( + + ) : filteredPoList.length > 0 ? ( + {filteredPoList.map((poItem, index) => (
- + onSelect(poItem)} sx={{ - width: '100%', + width: "100%", "&.Mui-selected": { backgroundColor: "primary.light", "&:hover": { @@ -159,7 +198,7 @@ const PoSearchList: React.FC<{ > + {poItem.code} } @@ -174,10 +213,14 @@ const PoSearchList: React.FC<{ {index < filteredPoList.length - 1 && }
))} -
) : ( - - ) - } +
+ ) : ( + + {searchTerm.trim() + ? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" }) + : t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })} + + )} {searchTerm && ( {`${t("Found")} ${filteredPoList.length} ${t("Purchase Order")}`} @@ -195,6 +238,11 @@ interface PolInputResult { const PoDetail: React.FC = ({ 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 = ({ po, warehouse, printerCombo }) => { const searchParams = useSearchParams(); const [selectedRow, setSelectedRow] = useState(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([]); 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([]); + const [poList, setPoList] = useState(() => [po]); const [isPoListLoading, setIsPoListLoading] = useState(false); const [selectedPoId, setSelectedPoId] = useState(po.id); const [focusField, setFocusField] = useState(); @@ -257,32 +308,25 @@ const PoDetail: React.FC = ({ 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 = ({ 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 = ({ po, warehouse, printerCombo }) => { const receivedTotalText = decimalFormatter.format(totalStockReceived); const highlightColor = Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; + const needsStockInAttention = + canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); return ( <> *": { 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 = ({ po, warehouse, printerCombo }) => { {open ? : } */} - + + {needsStockInAttention && ( + `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, + zIndex: 1, + }} + /> + )} = ({ 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 = ({ po, warehouse, printerCombo }) => { {/* left side select po */} - - - - - + + + + {/* right side po info */} diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index ef34f7c..13501d0 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -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 = ({ 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(null); // Add printer selection state const [selectedPrinter, setSelectedPrinter] = useState( @@ -104,6 +96,33 @@ const ProductionProcessPage: React.FC = ({ 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 ( = ({ printerCo } return ( + <> {/* Header section with printer selection */} {tabIndex === 1 && ( @@ -238,6 +258,17 @@ const ProductionProcessPage: React.FC = ({ printerCo )} + 0)} + onClose={closeLinkQc} + inputDetail={linkQcSilId != null && linkQcSilId > 0 ? { id: linkQcSilId } : undefined} + printerCombo={printerCombo} + warehouse={[]} + printSource="productionProcess" + uiMode="default" + /> + ); }; diff --git a/src/components/PutAwayScan/PutAwayScan.tsx b/src/components/PutAwayScan/PutAwayScan.tsx index 84be976..1a46e35 100644 --- a/src/components/PutAwayScan/PutAwayScan.tsx +++ b/src/components/PutAwayScan/PutAwayScan.tsx @@ -34,7 +34,8 @@ type ScanStatusType = "pending" | "scanning" | "retry"; const PutAwayScan: React.FC = ({ warehouse }) => { const { t } = useTranslation("putAway"); - + const searchParams = useSearchParams(); + const [scanDisplay, setScanDisplay] = useState("pending"); const [openPutAwayModal, setOpenPutAwayModal] = useState(false); const [scannedSilId, setScannedSilId] = useState(0); // TODO use QR code info @@ -98,7 +99,17 @@ const PutAwayScan: React.FC = ({ 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(() => { diff --git a/src/hooks/useJobOrderFgStockInAlerts.ts b/src/hooks/useJobOrderFgStockInAlerts.ts new file mode 100644 index 0000000..6dbb5b1 --- /dev/null +++ b/src/hooks/useJobOrderFgStockInAlerts.ts @@ -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): 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; + const qcRaw = p.qc; + const putAwayRaw = p.putAway; + return { + qc: Array.isArray(qcRaw) ? qcRaw.map((r) => parseRow(r as Record)) : [], + putAway: Array.isArray(putAwayRaw) + ? putAwayRaw.map((r) => parseRow(r as Record)) + : [], + }; +} + +/** + * 與「完成QC工單」相同資格 + 產程日期為今日或昨日;分待 QC / 待上架。 + */ +export function useJobOrderFgStockInAlerts(enabled: boolean) { + const [qcItems, setQcItems] = useState([]); + const [putAwayItems, setPutAwayItems] = useState([]); + 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 }; +} diff --git a/src/hooks/usePurchaseStockInAlerts.ts b/src/hooks/usePurchaseStockInAlerts.ts new file mode 100644 index 0000000..5569c0f --- /dev/null +++ b/src/hooks/usePurchaseStockInAlerts.ts @@ -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; + 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([]); + 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 }; +}