| @@ -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 ( | |||||
| <> | |||||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| 打袋機 | |||||
| </Typography> | |||||
| </Stack> | |||||
| <BagPrintSearch /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default BagPrintPage; | |||||
| @@ -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<JobOrderListItem[]> { | |||||
| 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<PrinterStatusResponse> { | |||||
| 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<Blob> { | |||||
| 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(); | |||||
| } | |||||
| @@ -12,6 +12,13 @@ export interface UpdateBomWeightingScoreInputs { | |||||
| remarks?: string; | remarks?: string; | ||||
| } | } | ||||
| export const fetchBomWeightingScoresClient = async (): Promise<BomWeightingScoreResult[]> => { | |||||
| const response = await axiosInstance.get<BomWeightingScoreResult[]>( | |||||
| `${NEXT_PUBLIC_API_URL}/bomWeightingScores` | |||||
| ); | |||||
| return response.data; | |||||
| }; | |||||
| export const updateBomWeightingScoreClient = async ( | export const updateBomWeightingScoreClient = async ( | ||||
| data: UpdateBomWeightingScoreInputs | data: UpdateBomWeightingScoreInputs | ||||
| ): Promise<BomWeightingScoreResult> => { | ): Promise<BomWeightingScoreResult> => { | ||||
| @@ -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<JobOrderListItem[]>([]); | |||||
| const [loading, setLoading] = useState(true); | |||||
| const [error, setError] = useState<string | null>(null); | |||||
| const [connected, setConnected] = useState(false); | |||||
| const [printer, setPrinter] = useState<string>("dataflex"); | |||||
| const [selectedId, setSelectedId] = useState<number | null>(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<string>) => { | |||||
| 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 ( | |||||
| <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | |||||
| {/* Top: date nav + printer + settings */} | |||||
| <Paper sx={{ p: 2, mb: 2, backgroundColor: BG_TOP }}> | |||||
| <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}> | |||||
| <Stack direction="row" alignItems="center" spacing={2}> | |||||
| <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}> | |||||
| 前一天 | |||||
| </Button> | |||||
| <TextField | |||||
| type="date" | |||||
| value={planDate} | |||||
| onChange={(e) => setPlanDate(e.target.value)} | |||||
| size="small" | |||||
| sx={{ width: 160 }} | |||||
| InputLabelProps={{ shrink: true }} | |||||
| /> | |||||
| <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}> | |||||
| 後一天 | |||||
| </Button> | |||||
| </Stack> | |||||
| <Stack direction="row" alignItems="center" spacing={2}> | |||||
| <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> | |||||
| 設定 | |||||
| </Button> | |||||
| <Box | |||||
| sx={{ | |||||
| px: 1.5, | |||||
| py: 0.75, | |||||
| borderRadius: 1, | |||||
| backgroundColor: printerConnected ? BG_STATUS_OK : BG_STATUS_ERROR, | |||||
| color: printerConnected ? FG_STATUS_OK : FG_STATUS_ERROR, | |||||
| fontWeight: 600, | |||||
| whiteSpace: "nowrap", | |||||
| }} | |||||
| title={printerMessage} | |||||
| > | |||||
| 列印機: | |||||
| </Box> | |||||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||||
| <InputLabel>列印機</InputLabel> | |||||
| <Select value={printer} label="列印機" onChange={handlePrinterChange}> | |||||
| {PRINTER_OPTIONS.map((opt) => ( | |||||
| <MenuItem key={opt.value} value={opt.value}> | |||||
| {opt.label} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Stack> | |||||
| </Stack> | |||||
| <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}> | |||||
| {printerMessage} | |||||
| </Typography> | |||||
| <Stack direction="row" sx={{ mt: 2 }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Download />} | |||||
| onClick={handleDownloadOnPackQr} | |||||
| disabled={loading || downloadingOnPack || jobOrders.length === 0} | |||||
| > | |||||
| {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Paper> | |||||
| {/* Job orders list */} | |||||
| <Paper sx={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", backgroundColor: BG_LIST }}> | |||||
| {loading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", py: 8 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : jobOrders.length === 0 ? ( | |||||
| <Box sx={{ py: 8, textAlign: "center" }}> | |||||
| <Typography color="text.secondary">當日無工單</Typography> | |||||
| </Box> | |||||
| ) : ( | |||||
| <Box sx={{ overflow: "auto", flex: 1, p: 2 }}> | |||||
| <Stack spacing={1}> | |||||
| {jobOrders.map((jo) => { | |||||
| const batch = getBatch(jo); | |||||
| const qtyStr = formatQty(jo.reqQty); | |||||
| const isSelected = selectedId === jo.id; | |||||
| return ( | |||||
| <Paper | |||||
| key={jo.id} | |||||
| elevation={1} | |||||
| sx={{ | |||||
| p: 2, | |||||
| display: "flex", | |||||
| alignItems: "flex-start", | |||||
| gap: 2, | |||||
| cursor: "pointer", | |||||
| backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW, | |||||
| "&:hover": { backgroundColor: isSelected ? BG_ROW_SELECTED : "#b8d4eb" }, | |||||
| transition: "background-color 0.2s", | |||||
| }} | |||||
| onClick={() => handleRowClick(jo)} | |||||
| > | |||||
| <Box sx={{ minWidth: 120, flexShrink: 0 }}> | |||||
| <Typography variant="h6" sx={{ fontSize: "1.1rem" }}> | |||||
| {batch} | |||||
| </Typography> | |||||
| {qtyStr !== "—" && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 數量:{qtyStr} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| <Box sx={{ minWidth: 140, flexShrink: 0 }}> | |||||
| <Typography variant="h6" sx={{ fontSize: "1.1rem" }}> | |||||
| {jo.code || "—"} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box sx={{ minWidth: 140, flexShrink: 0 }}> | |||||
| <Typography variant="h6" sx={{ fontSize: "1.35rem" }}> | |||||
| {jo.itemCode || "—"} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||||
| <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}> | |||||
| {jo.itemName || "—"} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| startIcon={<Print />} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleRowClick(jo); | |||||
| }} | |||||
| > | |||||
| 列印 | |||||
| </Button> | |||||
| </Paper> | |||||
| ); | |||||
| })} | |||||
| </Stack> | |||||
| </Box> | |||||
| )} | |||||
| </Paper> | |||||
| {/* Settings dialog */} | |||||
| <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | |||||
| <DialogTitle>設定</DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||||
| <Typography variant="subtitle2" color="primary"> | |||||
| 打袋機 DataFlex | |||||
| </Typography> | |||||
| <TextField | |||||
| label="IP" | |||||
| size="small" | |||||
| value={settings.dabag_ip} | |||||
| onChange={(e) => setSettings((s) => ({ ...s, dabag_ip: e.target.value }))} | |||||
| fullWidth | |||||
| /> | |||||
| <TextField | |||||
| label="Port" | |||||
| size="small" | |||||
| value={settings.dabag_port} | |||||
| onChange={(e) => setSettings((s) => ({ ...s, dabag_port: e.target.value }))} | |||||
| fullWidth | |||||
| /> | |||||
| <Typography variant="subtitle2" color="primary"> | |||||
| 激光機 | |||||
| </Typography> | |||||
| <TextField | |||||
| label="IP" | |||||
| size="small" | |||||
| value={settings.laser_ip} | |||||
| onChange={(e) => setSettings((s) => ({ ...s, laser_ip: e.target.value }))} | |||||
| fullWidth | |||||
| /> | |||||
| <TextField | |||||
| label="Port" | |||||
| size="small" | |||||
| value={settings.laser_port} | |||||
| onChange={(e) => setSettings((s) => ({ ...s, laser_port: e.target.value }))} | |||||
| fullWidth | |||||
| /> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setSettingsOpen(false)}>取消</Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={() => { | |||||
| saveSettings(settings); | |||||
| setSnackbar({ open: true, message: "設定已儲存", severity: "success" }); | |||||
| setSettingsOpen(false); | |||||
| checkCurrentPrinter(); | |||||
| }} | |||||
| > | |||||
| 儲存 | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Snackbar | |||||
| open={snackbar.open} | |||||
| autoHideDuration={3000} | |||||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | |||||
| message={snackbar.message} | |||||
| anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | |||||
| /> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default BagPrintSearch; | |||||
| @@ -16,9 +16,10 @@ import Stack from "@mui/material/Stack"; | |||||
| interface Props { | interface Props { | ||||
| bomWeightingScores: BomWeightingScoreResult[]; | bomWeightingScores: BomWeightingScoreResult[]; | ||||
| onWeightingUpdated?: () => void; | |||||
| } | } | ||||
| const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => { | |||||
| const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores, onWeightingUpdated }) => { | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores); | const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores); | ||||
| const [isEditMode, setIsEditMode] = useState(false); | const [isEditMode, setIsEditMode] = useState(false); | ||||
| @@ -120,10 +121,28 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW | |||||
| prev.map((r) => updatedById.get(r.id) ?? r), | 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 | // After weighting changes, trigger BOM baseScore recalculation on the server | ||||
| try { | try { | ||||
| console.log("Triggering BOM base score recalculation..."); | |||||
| const result = await recalcBomScoresClient(); | const result = await recalcBomScoresClient(); | ||||
| updatedCount = result?.updatedCount ?? null; | 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) { | } catch (recalcError) { | ||||
| console.error("Failed to recalculate BOM base scores:", recalcError); | console.error("Failed to recalculate BOM base scores:", recalcError); | ||||
| // We don't block the main save flow if recalculation fails | // We don't block the main save flow if recalculation fails | ||||
| @@ -3,6 +3,7 @@ | |||||
| import React, { useState, useEffect } from "react"; | import React, { useState, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | ||||
| import { fetchBomWeightingScoresClient } from "@/app/api/settings/bomWeighting/client"; | |||||
| import type { BomScoreResult } from "@/app/api/bom"; | import type { BomScoreResult } from "@/app/api/bom"; | ||||
| import { fetchBomScoresClient } from "@/app/api/bom/client"; | import { fetchBomScoresClient } from "@/app/api/bom/client"; | ||||
| import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; | import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; | ||||
| @@ -16,37 +17,61 @@ interface Props { | |||||
| bomWeightingScores: BomWeightingScoreResult[]; | bomWeightingScores: BomWeightingScoreResult[]; | ||||
| } | } | ||||
| const BomWeightingTabs: React.FC<Props> = ({ bomWeightingScores }) => { | |||||
| const BomWeightingTabs: React.FC<Props> = ({ bomWeightingScores: initialBomWeightingScores }) => { | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [tab, setTab] = useState(0); | const [tab, setTab] = useState(0); | ||||
| const [bomWeightingScores, setBomWeightingScores] = useState<BomWeightingScoreResult[]>(initialBomWeightingScores); | |||||
| const [bomScores, setBomScores] = useState<BomScoreResult[] | null>(null); | const [bomScores, setBomScores] = useState<BomScoreResult[] | null>(null); | ||||
| const [loadingScores, setLoadingScores] = useState(false); | const [loadingScores, setLoadingScores] = useState(false); | ||||
| const [loadError, setLoadError] = useState<string | null>(null); | const [loadError, setLoadError] = useState<string | null>(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 ( | return ( | ||||
| <> | <> | ||||
| @@ -83,7 +108,10 @@ const BomWeightingTabs: React.FC<Props> = ({ bomWeightingScores }) => { | |||||
| <Box> | <Box> | ||||
| {tab === 0 && ( | {tab === 0 && ( | ||||
| <BomWeightingScoreTable bomWeightingScores={bomWeightingScores} /> | |||||
| <BomWeightingScoreTable | |||||
| bomWeightingScores={bomWeightingScores} | |||||
| onWeightingUpdated={handleWeightingUpdated} | |||||
| /> | |||||
| )} | )} | ||||
| {tab === 1 && ( | {tab === 1 && ( | ||||
| loadingScores ? ( | loadingScores ? ( | ||||
| @@ -38,6 +38,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/putAway": "Put Away", | "/putAway": "Put Away", | ||||
| "/stockIssue": "Stock Issue", | "/stockIssue": "Stock Issue", | ||||
| "/report": "Report", | "/report": "Report", | ||||
| "/bagPrint": "打袋機", | |||||
| }; | }; | ||||
| const Breadcrumb = () => { | const Breadcrumb = () => { | ||||
| @@ -24,7 +24,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||||
| if (id) { | if (id) { | ||||
| result = await fetchItem(id); | result = await fetchItem(id); | ||||
| const item = result.item; | const item = result.item; | ||||
| qcChecks = result.qcChecks; | |||||
| qcChecks = result.qcChecks ?? []; | |||||
| const activeRows = qcChecks.filter((it) => it.isActive).map((i) => i.id); | const activeRows = qcChecks.filter((it) => it.isActive).map((i) => i.id); | ||||
| // Normalize LocationCode field (handle case sensitivity from MySQL) | // Normalize LocationCode field (handle case sensitivity from MySQL) | ||||
| @@ -172,9 +172,9 @@ const NavigationContent: React.FC = () => { | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <Print />, | icon: <Print />, | ||||
| label: "打袋機列印", | |||||
| path: "/testing", | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| label: "打袋機", | |||||
| path: "/bagPrint", | |||||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | |||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -92,7 +92,7 @@ | |||||
| "User": "用戶", | "User": "用戶", | ||||
| "user": "用戶", | "user": "用戶", | ||||
| "User Group": "用戶群組", | "User Group": "用戶群組", | ||||
| "Items": "物料", | |||||
| "Items": "物品", | |||||
| "BOM Weighting Score List": "物料清單權重得分", | "BOM Weighting Score List": "物料清單權重得分", | ||||
| "Material Weighting": "物料清單加權", | "Material Weighting": "物料清單加權", | ||||
| "Material Score": "物料清單得分", | "Material Score": "物料清單得分", | ||||
| @@ -190,7 +190,7 @@ | |||||
| "Inventory": "庫存", | "Inventory": "庫存", | ||||
| "scheduling": "排程", | "scheduling": "排程", | ||||
| "settings": "設定", | "settings": "設定", | ||||
| "items": "物料", | |||||
| "items": "物品", | |||||
| "edit":"編輯", | "edit":"編輯", | ||||
| "bag": "包裝袋", | "bag": "包裝袋", | ||||
| "Bag Usage": "包裝袋使用記錄", | "Bag Usage": "包裝袋使用記錄", | ||||
| @@ -3,7 +3,7 @@ | |||||
| "FG & Material Demand Forecast Detail": "FG 及材料需求預測詳情", | "FG & Material Demand Forecast Detail": "FG 及材料需求預測詳情", | ||||
| "Release": "發佈", | "Release": "發佈", | ||||
| "Actions": "操作", | "Actions": "操作", | ||||
| "Product": "產品", | |||||
| "Product": "物品", | |||||
| "Details": "詳情", | "Details": "詳情", | ||||
| "View BoM": "查看 BoM", | "View BoM": "查看 BoM", | ||||
| "description": "描述", | "description": "描述", | ||||