diff --git a/src/app/(main)/bagPrint/page.tsx b/src/app/(main)/bagPrint/page.tsx new file mode 100644 index 0000000..e935cee --- /dev/null +++ b/src/app/(main)/bagPrint/page.tsx @@ -0,0 +1,23 @@ +import BagPrintSearch from "@/components/BagPrint/BagPrintSearch"; +import { Stack, Typography } from "@mui/material"; +import { Metadata } from "next"; +import React from "react"; + +export const metadata: Metadata = { + title: "打袋機", +}; + +const BagPrintPage: React.FC = () => { + return ( + <> + + + 打袋機 + + + + + ); +}; + +export default BagPrintPage; diff --git a/src/app/api/bagPrint/actions.ts b/src/app/api/bagPrint/actions.ts new file mode 100644 index 0000000..b6bf3e1 --- /dev/null +++ b/src/app/api/bagPrint/actions.ts @@ -0,0 +1,82 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; + +export interface JobOrderListItem { + id: number; + code: string | null; + planStart: string | null; + itemCode: string | null; + itemName: string | null; + reqQty: number | null; + stockInLineId: number | null; + itemId: number | null; + lotNo: string | null; +} + +export interface PrinterStatusRequest { + printerType: "dataflex" | "laser"; + printerIp?: string; + printerPort?: number; +} + +export interface PrinterStatusResponse { + connected: boolean; + message: string; +} + +export interface OnPackQrDownloadRequest { + jobOrders: { + jobOrderId: number; + itemCode: string; + }[]; +} + +/** + * Fetch job orders by plan date from GET /py/job-orders. + * Client-side only; uses auth token from localStorage. + */ +export async function fetchJobOrders(planStart: string): Promise { + const url = `${NEXT_PUBLIC_API_URL}/py/job-orders?planStart=${encodeURIComponent(planStart)}`; + const res = await clientAuthFetch(url, { method: "GET" }); + if (!res.ok) { + throw new Error(`Failed to fetch job orders: ${res.status}`); + } + return res.json(); +} + +export async function checkPrinterStatus( + request: PrinterStatusRequest, +): Promise { + const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`; + const res = await clientAuthFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + + const data = (await res.json()) as PrinterStatusResponse; + if (!res.ok) { + return data; + } + + return data; +} + +export async function downloadOnPackQrZip( + request: OnPackQrDownloadRequest, +): Promise { + const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`; + const res = await clientAuthFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + + if (!res.ok) { + throw new Error((await res.text()) || "Download failed"); + } + + return res.blob(); +} diff --git a/src/app/api/settings/bomWeighting/client.ts b/src/app/api/settings/bomWeighting/client.ts index a502763..e8480b2 100644 --- a/src/app/api/settings/bomWeighting/client.ts +++ b/src/app/api/settings/bomWeighting/client.ts @@ -12,6 +12,13 @@ export interface UpdateBomWeightingScoreInputs { remarks?: string; } +export const fetchBomWeightingScoresClient = async (): Promise => { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/bomWeightingScores` + ); + return response.data; +}; + export const updateBomWeightingScoreClient = async ( data: UpdateBomWeightingScoreInputs ): Promise => { diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx new file mode 100644 index 0000000..3792540 --- /dev/null +++ b/src/components/BagPrint/BagPrintSearch.tsx @@ -0,0 +1,445 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + Typography, + Paper, + CircularProgress, + SelectChangeEvent, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Snackbar, +} from "@mui/material"; +import ChevronLeft from "@mui/icons-material/ChevronLeft"; +import ChevronRight from "@mui/icons-material/ChevronRight"; +import Settings from "@mui/icons-material/Settings"; +import Print from "@mui/icons-material/Print"; +import Download from "@mui/icons-material/Download"; +import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions"; +import dayjs from "dayjs"; + +// Light blue theme (matching Python Bag1) +const BG_TOP = "#E8F4FC"; +const BG_LIST = "#D4E8F7"; +const BG_ROW = "#C5E1F5"; +const BG_ROW_SELECTED = "#6BB5FF"; +const BG_STATUS_ERROR = "#FFCCCB"; +const BG_STATUS_OK = "#90EE90"; +const FG_STATUS_ERROR = "#B22222"; +const FG_STATUS_OK = "#006400"; + +const PRINTER_OPTIONS = [ + { value: "dataflex", label: "打袋機 DataFlex" }, + { value: "laser", label: "激光機" }, +]; + +const REFRESH_MS = 60 * 1000; +const PRINTER_CHECK_MS = 60 * 1000; +const PRINTER_RETRY_MS = 30 * 1000; +const SETTINGS_KEY = "bagPrint_settings"; + +const DEFAULT_SETTINGS = { + dabag_ip: "", + dabag_port: "3008", + laser_ip: "192.168.17.10", + laser_port: "45678", +}; + +function loadSettings(): typeof DEFAULT_SETTINGS { + if (typeof window === "undefined") return DEFAULT_SETTINGS; + try { + const s = localStorage.getItem(SETTINGS_KEY); + if (s) return { ...DEFAULT_SETTINGS, ...JSON.parse(s) }; + } catch {} + return DEFAULT_SETTINGS; +} + +function saveSettings(s: typeof DEFAULT_SETTINGS) { + if (typeof window === "undefined") return; + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); + } catch {} +} + +function formatQty(val: number | null | undefined): string { + if (val == null) return "—"; + try { + const n = Number(val); + if (Number.isInteger(n)) return n.toLocaleString(); + return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }).replace(/\.?0+$/, ""); + } catch { + return String(val); + } +} + +function getBatch(jo: JobOrderListItem): string { + return (jo.lotNo || "—").trim() || "—"; +} + +const BagPrintSearch: React.FC = () => { + const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); + const [jobOrders, setJobOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [connected, setConnected] = useState(false); + const [printer, setPrinter] = useState("dataflex"); + const [selectedId, setSelectedId] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [printerConnected, setPrinterConnected] = useState(false); + const [printerMessage, setPrinterMessage] = useState("列印機未連接"); + const [downloadingOnPack, setDownloadingOnPack] = useState(false); + + useEffect(() => { + setSettings(loadSettings()); + }, []); + + const loadJobOrders = useCallback(async (fromUserChange = false) => { + setLoading(true); + setError(null); + try { + const data = await fetchJobOrders(planDate); + setJobOrders(data); + setConnected(true); + if (fromUserChange) setSelectedId(null); + } catch (e) { + setError(e instanceof Error ? e.message : "連接不到服務器"); + setConnected(false); + setJobOrders([]); + } finally { + setLoading(false); + } + }, [planDate]); + + useEffect(() => { + loadJobOrders(true); + }, [planDate]); + + useEffect(() => { + if (!connected) return; + const id = setInterval(() => loadJobOrders(false), REFRESH_MS); + return () => clearInterval(id); + }, [connected, loadJobOrders]); + + const checkCurrentPrinter = useCallback(async () => { + try { + const request = + printer === "dataflex" + ? { + printerType: "dataflex" as const, + printerIp: settings.dabag_ip, + printerPort: Number(settings.dabag_port || 3008), + } + : { + printerType: "laser" as const, + printerIp: settings.laser_ip, + printerPort: Number(settings.laser_port || 45678), + }; + + const result = await checkPrinterStatus(request); + setPrinterConnected(result.connected); + setPrinterMessage(result.message); + } catch (e) { + setPrinterConnected(false); + setPrinterMessage(e instanceof Error ? e.message : "列印機狀態檢查失敗"); + } + }, [printer, settings]); + + useEffect(() => { + checkCurrentPrinter(); + }, [checkCurrentPrinter]); + + useEffect(() => { + const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS; + const id = setInterval(() => { + checkCurrentPrinter(); + }, intervalMs); + + return () => clearInterval(id); + }, [printerConnected, checkCurrentPrinter]); + + const goPrevDay = () => { + setPlanDate((d) => dayjs(d).subtract(1, "day").format("YYYY-MM-DD")); + }; + + const goNextDay = () => { + setPlanDate((d) => dayjs(d).add(1, "day").format("YYYY-MM-DD")); + }; + + const handlePrinterChange = (e: SelectChangeEvent) => { + setPrinter(e.target.value); + }; + + const handleRowClick = (jo: JobOrderListItem) => { + setSelectedId(jo.id); + const batch = getBatch(jo); + const itemCode = jo.itemCode || "—"; + const itemName = jo.itemName || "—"; + setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" }); + // TODO: Actual printing would require backend API to proxy to printer (TCP/serial) + // For now, show info. Backend could add /py/print-dataflex, /py/print-label, /py/print-laser + }; + + const handleDownloadOnPackQr = async () => { + const onPackJobOrders = jobOrders + .map((jobOrder) => ({ + jobOrderId: jobOrder.id, + itemCode: jobOrder.itemCode?.trim() || "", + })) + .filter((jobOrder) => jobOrder.itemCode.length > 0); + + if (onPackJobOrders.length === 0) { + setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); + return; + } + + setDownloadingOnPack(true); + try { + const blob = await downloadOnPackQrZip({ + jobOrders: onPackJobOrders, + }); + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `onpack_qr_${planDate}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + setSnackbar({ open: true, message: "OnPack QR code ZIP 已下載", severity: "success" }); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "下載 OnPack QR code 失敗", + severity: "error", + }); + } finally { + setDownloadingOnPack(false); + } + }; + + return ( + + {/* Top: date nav + printer + settings */} + + + + + setPlanDate(e.target.value)} + size="small" + sx={{ width: 160 }} + InputLabelProps={{ shrink: true }} + /> + + + + + + 列印機: + + + 列印機 + + + + + + {printerMessage} + + + + + + + {/* Job orders list */} + + {loading ? ( + + + + ) : jobOrders.length === 0 ? ( + + 當日無工單 + + ) : ( + + + {jobOrders.map((jo) => { + const batch = getBatch(jo); + const qtyStr = formatQty(jo.reqQty); + const isSelected = selectedId === jo.id; + return ( + handleRowClick(jo)} + > + + + {batch} + + {qtyStr !== "—" && ( + + 數量:{qtyStr} + + )} + + + + {jo.code || "—"} + + + + + {jo.itemCode || "—"} + + + + + {jo.itemName || "—"} + + + + + ); + })} + + + )} + + + {/* Settings dialog */} + setSettingsOpen(false)} maxWidth="sm" fullWidth> + 設定 + + + + 打袋機 DataFlex + + setSettings((s) => ({ ...s, dabag_ip: e.target.value }))} + fullWidth + /> + setSettings((s) => ({ ...s, dabag_port: e.target.value }))} + fullWidth + /> + + 激光機 + + setSettings((s) => ({ ...s, laser_ip: e.target.value }))} + fullWidth + /> + setSettings((s) => ({ ...s, laser_port: e.target.value }))} + fullWidth + /> + + + + + + + + + setSnackbar((s) => ({ ...s, open: false }))} + message={snackbar.message} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}; + +export default BagPrintSearch; diff --git a/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx b/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx index 200357d..6767949 100644 --- a/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx +++ b/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx @@ -16,9 +16,10 @@ import Stack from "@mui/material/Stack"; interface Props { bomWeightingScores: BomWeightingScoreResult[]; + onWeightingUpdated?: () => void; } -const BomWeightingScoreTable: React.FC & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => { +const BomWeightingScoreTable: React.FC & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores, onWeightingUpdated }) => { const { t } = useTranslation("common"); const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores); const [isEditMode, setIsEditMode] = useState(false); @@ -120,10 +121,28 @@ const BomWeightingScoreTable: React.FC & { Loading?: React.FC } = ({ bomW prev.map((r) => updatedById.get(r.id) ?? r), ); + // Wait a bit to ensure all weighting updates are committed to database + // before recalculating base scores + console.log("Waiting for weighting updates to commit..."); + await new Promise(resolve => setTimeout(resolve, 200)); + // After weighting changes, trigger BOM baseScore recalculation on the server try { + console.log("Triggering BOM base score recalculation..."); const result = await recalcBomScoresClient(); updatedCount = result?.updatedCount ?? null; + console.log(`BOM base scores recalculated: ${updatedCount} BOMs updated`); + + // Wait a bit more to ensure recalculation transaction is committed + // before refreshing the frontend + await new Promise(resolve => setTimeout(resolve, 300)); + + // Notify parent component to refresh BOM scores if needed + if (onWeightingUpdated) { + console.log("Refreshing BOM scores in frontend..."); + await onWeightingUpdated(); + console.log("BOM scores refreshed"); + } } catch (recalcError) { console.error("Failed to recalculate BOM base scores:", recalcError); // We don't block the main save flow if recalculation fails diff --git a/src/components/BomWeightingTabs/BomWeightingTabs.tsx b/src/components/BomWeightingTabs/BomWeightingTabs.tsx index fe7320e..6323dea 100644 --- a/src/components/BomWeightingTabs/BomWeightingTabs.tsx +++ b/src/components/BomWeightingTabs/BomWeightingTabs.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; +import { fetchBomWeightingScoresClient } from "@/app/api/settings/bomWeighting/client"; import type { BomScoreResult } from "@/app/api/bom"; import { fetchBomScoresClient } from "@/app/api/bom/client"; import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; @@ -16,37 +17,61 @@ interface Props { bomWeightingScores: BomWeightingScoreResult[]; } -const BomWeightingTabs: React.FC = ({ bomWeightingScores }) => { +const BomWeightingTabs: React.FC = ({ bomWeightingScores: initialBomWeightingScores }) => { const { t } = useTranslation("common"); const [tab, setTab] = useState(0); + const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores); const [bomScores, setBomScores] = useState(null); const [loadingScores, setLoadingScores] = useState(false); const [loadError, setLoadError] = useState(null); - useEffect(() => { - if (tab !== 1) return; + const loadBomScores = React.useCallback(async () => { + try { + setLoadingScores(true); + setLoadError(null); + console.log("Fetching BOM scores from /bom/scores..."); + const data = await fetchBomScoresClient(); + console.log("BOM scores received:", data); + setBomScores(data || []); + } catch (err: any) { + console.error("Failed to load BOM scores:", err); + const errorMsg = + err?.response?.data?.message || err?.message || t("Update Failed") || "Load failed"; + setLoadError(errorMsg); + setBomScores([]); + } finally { + setLoadingScores(false); + } + }, [t]); + + const loadBomWeightingScores = React.useCallback(async () => { + try { + console.log("Fetching BOM weighting scores..."); + const data = await fetchBomWeightingScoresClient(); + console.log("BOM weighting scores received:", data); + setBomWeightingScores(data || []); + } catch (err: any) { + console.error("Failed to load BOM weighting scores:", err); + } + }, []); + + const handleWeightingUpdated = React.useCallback(async () => { + // Refresh both weighting scores and BOM scores + await Promise.all([ + loadBomWeightingScores(), + loadBomScores() + ]); + }, [loadBomWeightingScores, loadBomScores]); - const load = async () => { - try { - setLoadingScores(true); - setLoadError(null); - console.log("Fetching BOM scores from /bom/scores..."); - const data = await fetchBomScoresClient(); - console.log("BOM scores received:", data); - setBomScores(data || []); - } catch (err: any) { - console.error("Failed to load BOM scores:", err); - const errorMsg = - err?.response?.data?.message || err?.message || t("Update Failed") || "Load failed"; - setLoadError(errorMsg); - setBomScores([]); - } finally { - setLoadingScores(false); - } - }; + // Sync initial prop values + useEffect(() => { + setBomWeightingScores(initialBomWeightingScores); + }, [initialBomWeightingScores]); - void load(); - }, [tab, t]); + useEffect(() => { + if (tab !== 1) return; + void loadBomScores(); + }, [tab, loadBomScores]); return ( <> @@ -83,7 +108,10 @@ const BomWeightingTabs: React.FC = ({ bomWeightingScores }) => { {tab === 0 && ( - + )} {tab === 1 && ( loadingScores ? ( diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 066d65c..a462fef 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -38,6 +38,7 @@ const pathToLabelMap: { [path: string]: string } = { "/putAway": "Put Away", "/stockIssue": "Stock Issue", "/report": "Report", + "/bagPrint": "打袋機", }; const Breadcrumb = () => { diff --git a/src/components/CreateItem/CreateItemWrapper.tsx b/src/components/CreateItem/CreateItemWrapper.tsx index 343e6bf..2a4b468 100644 --- a/src/components/CreateItem/CreateItemWrapper.tsx +++ b/src/components/CreateItem/CreateItemWrapper.tsx @@ -24,7 +24,7 @@ const CreateItemWrapper: React.FC & SubComponents = async ({ id }) => { if (id) { result = await fetchItem(id); const item = result.item; - qcChecks = result.qcChecks; + qcChecks = result.qcChecks ?? []; const activeRows = qcChecks.filter((it) => it.isActive).map((i) => i.id); // Normalize LocationCode field (handle case sensitivity from MySQL) diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index ae1c632..508f09e 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -172,9 +172,9 @@ const NavigationContent: React.FC = () => { }, { icon: , - label: "打袋機列印", - path: "/testing", - requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + label: "打袋機", + path: "/bagPrint", + requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], isHidden: false, }, { diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 5a58930..0ac3c96 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -92,7 +92,7 @@ "User": "用戶", "user": "用戶", "User Group": "用戶群組", - "Items": "物料", + "Items": "物品", "BOM Weighting Score List": "物料清單權重得分", "Material Weighting": "物料清單加權", "Material Score": "物料清單得分", @@ -190,7 +190,7 @@ "Inventory": "庫存", "scheduling": "排程", "settings": "設定", - "items": "物料", + "items": "物品", "edit":"編輯", "bag": "包裝袋", "Bag Usage": "包裝袋使用記錄", diff --git a/src/i18n/zh/project.json b/src/i18n/zh/project.json index 7522b1c..f5d7a11 100644 --- a/src/i18n/zh/project.json +++ b/src/i18n/zh/project.json @@ -3,7 +3,7 @@ "FG & Material Demand Forecast Detail": "FG 及材料需求預測詳情", "Release": "發佈", "Actions": "操作", - "Product": "產品", + "Product": "物品", "Details": "詳情", "View BoM": "查看 BoM", "description": "描述",