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" ? (
+ }
+ onClick={onRowNavigate}
+ >
+ QC
+
+ ) : (
+ }
+ endIcon={}
+ onClick={onRowNavigate}
+ >
+ 上架
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+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)" },
+ },
+ },
+ }}
+ >
+
+
+
+
+
+
+
+ >
+ );
+};
+
+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)" },
+ },
+ },
+ }}
+ >
+
+
+
+
+
+
+
+ >
+ );
+};
+
+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 };
+}