| @@ -0,0 +1,54 @@ | |||||
| /** | |||||
| * Multi-sheet 總表 export for the 採購 chart page — mirrors on-screen charts and drill-down data. | |||||
| */ | |||||
| import { exportMultiSheetToXlsx, type MultiSheetSpec } from "../_components/exportChartToXlsx"; | |||||
| export type PurchaseChartMasterExportPayload = { | |||||
| /** ISO timestamp for audit */ | |||||
| exportedAtIso: string; | |||||
| /** 篩選與情境 — key-value rows */ | |||||
| metaRows: Record<string, unknown>[]; | |||||
| /** 預計送貨 donut (依預計到貨日、上方篩選) */ | |||||
| estimatedDonutRows: Record<string, unknown>[]; | |||||
| /** 實際已送貨 donut (依訂單日期、上方篩選) */ | |||||
| actualStatusDonutRows: Record<string, unknown>[]; | |||||
| /** 貨品摘要表 (當前 drill) */ | |||||
| itemSummaryRows: Record<string, unknown>[]; | |||||
| /** 供應商分佈 (由採購單明細彙總) */ | |||||
| supplierDistributionRows: Record<string, unknown>[]; | |||||
| /** 採購單列表 */ | |||||
| purchaseOrderListRows: Record<string, unknown>[]; | |||||
| /** 全量採購單行明細 (每張 PO 所有行) */ | |||||
| purchaseOrderLineRows: Record<string, unknown>[]; | |||||
| }; | |||||
| function sheetOrPlaceholder(name: string, rows: Record<string, unknown>[], emptyMessage: string): MultiSheetSpec { | |||||
| if (rows.length > 0) return { name, rows }; | |||||
| return { | |||||
| name, | |||||
| rows: [{ 說明: emptyMessage }], | |||||
| }; | |||||
| } | |||||
| /** | |||||
| * Build worksheet specs (used by {@link exportPurchaseChartMasterToFile}). | |||||
| */ | |||||
| export function buildPurchaseChartMasterSheets(payload: PurchaseChartMasterExportPayload): MultiSheetSpec[] { | |||||
| return [ | |||||
| { name: "篩選條件與情境", rows: payload.metaRows }, | |||||
| sheetOrPlaceholder("預計送貨", payload.estimatedDonutRows, "無資料(請確認訂單日期與篩選)"), | |||||
| sheetOrPlaceholder("實際已送貨", payload.actualStatusDonutRows, "無資料"), | |||||
| sheetOrPlaceholder("貨品摘要", payload.itemSummaryRows, "無資料(可能為篩選交集為空或未載入)"), | |||||
| sheetOrPlaceholder("供應商分佈", payload.supplierDistributionRows, "無資料"), | |||||
| sheetOrPlaceholder("採購單列表", payload.purchaseOrderListRows, "無採購單明細可匯出"), | |||||
| sheetOrPlaceholder("採購單行明細", payload.purchaseOrderLineRows, "無行資料(採購單列表為空)"), | |||||
| ]; | |||||
| } | |||||
| export function exportPurchaseChartMasterToFile( | |||||
| payload: PurchaseChartMasterExportPayload, | |||||
| filenameBase: string | |||||
| ): void { | |||||
| const sheets = buildPurchaseChartMasterSheets(payload); | |||||
| exportMultiSheetToXlsx(sheets, filenameBase); | |||||
| } | |||||
| @@ -0,0 +1,23 @@ | |||||
| import LaserPrintSearch from "@/components/LaserPrint/LaserPrintSearch"; | |||||
| import { Stack, Typography } from "@mui/material"; | |||||
| import { Metadata } from "next"; | |||||
| import React from "react"; | |||||
| export const metadata: Metadata = { | |||||
| title: "檸檬機(激光機)", | |||||
| }; | |||||
| const LaserPrintPage: React.FC = () => { | |||||
| return ( | |||||
| <> | |||||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| 檸檬機(激光機) | |||||
| </Typography> | |||||
| </Stack> | |||||
| <LaserPrintSearch /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default LaserPrintPage; | |||||
| @@ -504,7 +504,7 @@ export default function ReportPage() { | |||||
| setLoading={setLoading} | setLoading={setLoading} | ||||
| reportTitle={currentReport.title} | reportTitle={currentReport.title} | ||||
| /> | /> | ||||
| ) : currentReport.id === 'rep-013' ? ( | |||||
| ) : currentReport.id === 'rep-013' || currentReport.id === 'rep-009' ? ( | |||||
| <> | <> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -1,19 +1,12 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useState } from "react"; | import React, { useState } from "react"; | ||||
| import { | |||||
| Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, | |||||
| DialogContent, DialogActions, TextField, Stack, Table, | |||||
| TableBody, TableCell, TableContainer, TableHead, TableRow, | |||||
| Tabs, Tab // ← Added for tabs | |||||
| } from "@mui/material"; | |||||
| import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material"; | |||||
| import dayjs from "dayjs"; | |||||
| import { Box, Paper, Typography, Button, TextField, Stack, Tabs, Tab } from "@mui/material"; | |||||
| import { FileDownload } from "@mui/icons-material"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import * as XLSX from "xlsx"; | import * as XLSX from "xlsx"; | ||||
| // Simple TabPanel component for conditional rendering | |||||
| interface TabPanelProps { | interface TabPanelProps { | ||||
| children?: React.ReactNode; | children?: React.ReactNode; | ||||
| index: number; | index: number; | ||||
| @@ -30,192 +23,29 @@ function TabPanel(props: TabPanelProps) { | |||||
| aria-labelledby={`simple-tab-${index}`} | aria-labelledby={`simple-tab-${index}`} | ||||
| {...other} | {...other} | ||||
| > | > | ||||
| {value === index && ( | |||||
| <Box sx={{ p: 3 }}> | |||||
| {children} | |||||
| </Box> | |||||
| )} | |||||
| {value === index && <Box sx={{ p: 3 }}>{children}</Box>} | |||||
| </div> | </div> | ||||
| ); | ); | ||||
| } | } | ||||
| export default function TestingPage() { | export default function TestingPage() { | ||||
| // Tab state | |||||
| const [tabValue, setTabValue] = useState(0); | const [tabValue, setTabValue] = useState(0); | ||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | ||||
| setTabValue(newValue); | setTabValue(newValue); | ||||
| }; | }; | ||||
| // --- 1. TSC Section States --- | |||||
| const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); | |||||
| const [tscItems, setTscItems] = useState([ | |||||
| { id: 1, itemCode: 'FG-001', itemName: 'Yellow Curry Sauce', lotNo: 'LOT-TSC-01', expiryDate: '2025-12-01' }, | |||||
| { id: 2, itemCode: 'FG-002', itemName: 'Red Curry Paste', lotNo: 'LOT-TSC-02', expiryDate: '2025-12-05' }, | |||||
| ]); | |||||
| // --- 2. DataFlex Section States --- | |||||
| const [dfConfig, setDfConfig] = useState({ ip: '192.168.1.101', port: '9100' }); | |||||
| const [dfItems, setDfItems] = useState([ | |||||
| { id: 1, itemCode: 'DF-101', itemName: 'Instant Noodle A', lotNo: 'LOT-DF-01', expiryDate: '2026-01-10' }, | |||||
| { id: 2, itemCode: 'DF-102', itemName: 'Instant Noodle B', lotNo: 'LOT-DF-02', expiryDate: '2026-01-15' }, | |||||
| ]); | |||||
| // --- 3. OnPack Section States --- | |||||
| const [isPrinterModalOpen, setIsPrinterModalOpen] = useState(false); | |||||
| const [printerFormData, setPrinterFormData] = useState({ | |||||
| itemCode: '', | |||||
| lotNo: '', | |||||
| expiryDate: dayjs().format('YYYY-MM-DD'), | |||||
| productName: '' | |||||
| }); | |||||
| // --- 4. Laser Section States --- | |||||
| const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); | |||||
| const [laserItems, setLaserItems] = useState([ | |||||
| { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, | |||||
| ]); | |||||
| // --- 5. HANS600S-M Section States --- | |||||
| const [hansConfig, setHansConfig] = useState({ ip: '192.168.76.10', port: '45678' }); | |||||
| const [hansItems, setHansItems] = useState([ | |||||
| { | |||||
| id: 1, | |||||
| textChannel3: 'SN-HANS-001-20260117', // channel 3 (e.g. serial / text1) | |||||
| textChannel4: 'BATCH-HK-TEST-OK', // channel 4 (e.g. batch / text2) | |||||
| text3ObjectName: 'Text3', // EZCAD object name for channel 3 | |||||
| text4ObjectName: 'Text4' // EZCAD object name for channel 4 | |||||
| }, | |||||
| ]); | |||||
| // --- 6. GRN Preview (M18) --- | |||||
| // --- 1. GRN Preview (M18) --- | |||||
| const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); | const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); | ||||
| // --- 7. M18 PO Sync by Code --- | |||||
| // --- 2. M18 PO Sync by Code --- | |||||
| const [m18PoCode, setM18PoCode] = useState(""); | const [m18PoCode, setM18PoCode] = useState(""); | ||||
| const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); | const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); | ||||
| const [m18PoSyncResult, setM18PoSyncResult] = useState<string>(""); | const [m18PoSyncResult, setM18PoSyncResult] = useState<string>(""); | ||||
| // --- 3. M18 DO Sync by Code --- | |||||
| const [m18DoCode, setM18DoCode] = useState(""); | |||||
| const [isSyncingM18Do, setIsSyncingM18Do] = useState(false); | |||||
| const [m18DoSyncResult, setM18DoSyncResult] = useState<string>(""); | |||||
| // Generic handler for inline table edits | |||||
| const handleItemChange = (setter: any, id: number, field: string, value: string) => { | |||||
| setter((prev: any[]) => prev.map(item => | |||||
| item.id === id ? { ...item, [field]: value } : item | |||||
| )); | |||||
| }; | |||||
| // --- API CALLS --- | |||||
| // TSC Print (Section 1) | |||||
| const handleTscPrint = async (row: any) => { | |||||
| const payload = { ...row, printerIp: tscConfig.ip, printerPort: tscConfig.port }; | |||||
| try { | |||||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(payload) | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (response.ok) alert(`TSC Print Command Sent for ${row.itemCode}!`); | |||||
| else alert("TSC Print Failed"); | |||||
| } catch (e) { console.error("TSC Error:", e); } | |||||
| }; | |||||
| // DataFlex Print (Section 2) | |||||
| const handleDfPrint = async (row: any) => { | |||||
| const payload = { ...row, printerIp: dfConfig.ip, printerPort: dfConfig.port }; | |||||
| try { | |||||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(payload) | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (response.ok) alert(`DataFlex Print Command Sent for ${row.itemCode}!`); | |||||
| else alert("DataFlex Print Failed"); | |||||
| } catch (e) { console.error("DataFlex Error:", e); } | |||||
| }; | |||||
| // OnPack Zip Download (Section 3) | |||||
| const handleDownloadPrintJob = async () => { | |||||
| const params = new URLSearchParams(printerFormData); | |||||
| try { | |||||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, { | |||||
| method: 'GET', | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) throw new Error('Download failed'); | |||||
| const blob = await response.blob(); | |||||
| const url = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement('a'); | |||||
| link.href = url; | |||||
| link.setAttribute('download', `${printerFormData.lotNo || 'OnPack'}.zip`); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(url); | |||||
| setIsPrinterModalOpen(false); | |||||
| } catch (e) { console.error("OnPack Error:", e); } | |||||
| }; | |||||
| // Laser Print (Section 4 - original) | |||||
| const handleLaserPrint = async (row: any) => { | |||||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port }; | |||||
| try { | |||||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(payload) | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (response.ok) alert(`Laser Command Sent: ${row.templateId}`); | |||||
| } catch (e) { console.error(e); } | |||||
| }; | |||||
| const handleLaserPreview = async (row: any) => { | |||||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) }; | |||||
| try { | |||||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(payload) | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (response.ok) alert("Red light preview active!"); | |||||
| } catch (e) { console.error("Preview Error:", e); } | |||||
| }; | |||||
| // HANS600S-M TCP Print (Section 5) | |||||
| const handleHansPrint = async (row: any) => { | |||||
| const payload = { | |||||
| printerIp: hansConfig.ip, | |||||
| printerPort: hansConfig.port, | |||||
| textChannel3: row.textChannel3, | |||||
| textChannel4: row.textChannel4, | |||||
| text3ObjectName: row.text3ObjectName, | |||||
| text4ObjectName: row.text4ObjectName | |||||
| }; | |||||
| try { | |||||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(payload) | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| const result = await response.text(); | |||||
| if (response.ok) { | |||||
| alert(`HANS600S-M Mark Success: ${result}`); | |||||
| } else { | |||||
| alert(`HANS600S-M Failed: ${result}`); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("HANS600S-M Error:", e); | |||||
| alert("HANS600S-M Connection Error"); | |||||
| } | |||||
| }; | |||||
| // GRN Preview CSV Download (Section 6) | |||||
| const handleDownloadGrnPreviewXlsx = async () => { | const handleDownloadGrnPreviewXlsx = async () => { | ||||
| try { | try { | ||||
| const response = await clientAuthFetch( | const response = await clientAuthFetch( | ||||
| @@ -251,7 +81,6 @@ export default function TestingPage() { | |||||
| } | } | ||||
| }; | }; | ||||
| // M18 PO Sync By Code (Section 7) | |||||
| const handleSyncM18PoByCode = async () => { | const handleSyncM18PoByCode = async () => { | ||||
| if (!m18PoCode.trim()) { | if (!m18PoCode.trim()) { | ||||
| alert("Please enter PO code."); | alert("Please enter PO code."); | ||||
| @@ -278,258 +107,55 @@ export default function TestingPage() { | |||||
| } | } | ||||
| }; | }; | ||||
| // Layout Helper | |||||
| const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( | |||||
| <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | |||||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}> | |||||
| const handleSyncM18DoByCode = async () => { | |||||
| if (!m18DoCode.trim()) { | |||||
| alert("Please enter DO / shop PO code."); | |||||
| return; | |||||
| } | |||||
| setIsSyncingM18Do(true); | |||||
| setM18DoSyncResult(""); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| const text = await response.text(); | |||||
| setM18DoSyncResult(text); | |||||
| if (!response.ok) { | |||||
| alert(`Sync failed: ${response.status}`); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("M18 DO Sync By Code Error:", e); | |||||
| alert("M18 DO sync failed. Check console/network."); | |||||
| } finally { | |||||
| setIsSyncingM18Do(false); | |||||
| } | |||||
| }; | |||||
| const Section = ({ title, children }: { title: string; children?: React.ReactNode }) => ( | |||||
| <Paper sx={{ p: 3, minHeight: "450px", display: "flex", flexDirection: "column" }}> | |||||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: "2px solid #f0f0f0", pb: 1, mb: 2 }}> | |||||
| {title} | {title} | ||||
| </Typography> | </Typography> | ||||
| {children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>} | |||||
| {children || <Typography color="textSecondary" sx={{ m: "auto" }}>Waiting for implementation...</Typography>} | |||||
| </Paper> | </Paper> | ||||
| ); | ); | ||||
| return ( | return ( | ||||
| <Box sx={{ p: 4 }}> | <Box sx={{ p: 4 }}> | ||||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing</Typography> | |||||
| <Tabs value={tabValue} onChange={handleTabChange} aria-label="printer sections tabs" centered variant="fullWidth"> | |||||
| <Tab label="1. TSC" /> | |||||
| <Tab label="2. DataFlex" /> | |||||
| <Tab label="3. OnPack" /> | |||||
| <Tab label="4. Laser" /> | |||||
| <Tab label="5. HANS600S-M" /> | |||||
| <Tab label="6. GRN Preview" /> | |||||
| <Tab label="7. M18 PO Sync" /> | |||||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: "bold" }}> | |||||
| Testing | |||||
| </Typography> | |||||
| <Tabs value={tabValue} onChange={handleTabChange} aria-label="testing sections tabs" centered variant="fullWidth"> | |||||
| <Tab label="1. GRN Preview" /> | |||||
| <Tab label="2. M18 PO Sync" /> | |||||
| <Tab label="3. M18 DO Sync" /> | |||||
| </Tabs> | </Tabs> | ||||
| <TabPanel value={tabValue} index={0}> | <TabPanel value={tabValue} index={0}> | ||||
| <Section title="1. TSC"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||||
| <TextField size="small" label="Printer IP" value={tscConfig.ip} onChange={e => setTscConfig({...tscConfig, ip: e.target.value})} /> | |||||
| <TextField size="small" label="Port" value={tscConfig.port} onChange={e => setTscConfig({...tscConfig, port: e.target.value})} /> | |||||
| <SettingsEthernet color="action" /> | |||||
| </Stack> | |||||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||||
| <Table size="small" stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>Code</TableCell> | |||||
| <TableCell>Name</TableCell> | |||||
| <TableCell>Lot</TableCell> | |||||
| <TableCell>Expiry</TableCell> | |||||
| <TableCell align="center">Action</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {tscItems.map(row => ( | |||||
| <TableRow key={row.id}> | |||||
| <TableCell><TextField variant="standard" value={row.itemCode} onChange={e => handleItemChange(setTscItems, row.id, 'itemCode', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" value={row.itemName} onChange={e => handleItemChange(setTscItems, row.id, 'itemName', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setTscItems, row.id, 'lotNo', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setTscItems, row.id, 'expiryDate', e.target.value)} /></TableCell> | |||||
| <TableCell align="center"><Button variant="contained" size="small" startIcon={<Print />} onClick={() => handleTscPrint(row)}>Print</Button></TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={1}> | |||||
| <Section title="2. DataFlex"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||||
| <TextField size="small" label="Printer IP" value={dfConfig.ip} onChange={e => setDfConfig({...dfConfig, ip: e.target.value})} /> | |||||
| <TextField size="small" label="Port" value={dfConfig.port} onChange={e => setDfConfig({...dfConfig, port: e.target.value})} /> | |||||
| <Lan color="action" /> | |||||
| </Stack> | |||||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||||
| <Table size="small" stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>Code</TableCell> | |||||
| <TableCell>Name</TableCell> | |||||
| <TableCell>Lot</TableCell> | |||||
| <TableCell>Expiry</TableCell> | |||||
| <TableCell align="center">Action</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {dfItems.map(row => ( | |||||
| <TableRow key={row.id}> | |||||
| <TableCell><TextField variant="standard" value={row.itemCode} onChange={e => handleItemChange(setDfItems, row.id, 'itemCode', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" value={row.itemName} onChange={e => handleItemChange(setDfItems, row.id, 'itemName', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setDfItems, row.id, 'lotNo', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setDfItems, row.id, 'expiryDate', e.target.value)} /></TableCell> | |||||
| <TableCell align="center"><Button variant="contained" color="secondary" size="small" startIcon={<Print />} onClick={() => handleDfPrint(row)}>Print</Button></TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={2}> | |||||
| <Section title="3. OnPack"> | |||||
| <Box sx={{ m: 'auto', textAlign: 'center' }}> | |||||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | |||||
| Calls /plastic/get-printer6 to generate CoLOS .job bundle. | |||||
| </Typography> | |||||
| <Button variant="contained" color="success" size="large" startIcon={<FileDownload />} onClick={() => setIsPrinterModalOpen(true)}> | |||||
| Generate CoLOS Files | |||||
| </Button> | |||||
| </Box> | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={3}> | |||||
| <Section title="4. Laser"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||||
| <TextField size="small" label="Laser IP" value={laserConfig.ip} onChange={e => setLaserConfig({...laserConfig, ip: e.target.value})} /> | |||||
| <TextField size="small" label="Port" value={laserConfig.port} onChange={e => setLaserConfig({...laserConfig, port: e.target.value})} /> | |||||
| <Router color="action" /> | |||||
| </Stack> | |||||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||||
| <Table size="small" stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>Template</TableCell> | |||||
| <TableCell>Lot</TableCell> | |||||
| <TableCell>Exp</TableCell> | |||||
| <TableCell>Pwr%</TableCell> | |||||
| <TableCell align="center">Action</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {laserItems.map(row => ( | |||||
| <TableRow key={row.id}> | |||||
| <TableCell><TextField variant="standard" value={row.templateId} onChange={e => handleItemChange(setLaserItems, row.id, 'templateId', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setLaserItems, row.id, 'lotNo', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setLaserItems, row.id, 'expiryDate', e.target.value)} /></TableCell> | |||||
| <TableCell><TextField variant="standard" value={row.power} sx={{ width: 40 }} onChange={e => handleItemChange(setLaserItems, row.id, 'power', e.target.value)} /></TableCell> | |||||
| <TableCell align="center"> | |||||
| <Stack direction="row" spacing={1} justifyContent="center"> | |||||
| <Button | |||||
| variant="outlined" | |||||
| color="info" | |||||
| size="small" | |||||
| onClick={() => handleLaserPreview(row)} | |||||
| > | |||||
| Preview | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| color="warning" | |||||
| size="small" | |||||
| startIcon={<Print />} | |||||
| onClick={() => handleLaserPrint(row)} | |||||
| > | |||||
| Mark | |||||
| </Button> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary' }}> | |||||
| Note: HANS Laser requires pre-saved templates on the controller. | |||||
| </Typography> | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={4}> | |||||
| <Section title="5. HANS600S-M"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="Laser IP" | |||||
| value={hansConfig.ip} | |||||
| onChange={e => setHansConfig({...hansConfig, ip: e.target.value})} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| label="Port" | |||||
| value={hansConfig.port} | |||||
| onChange={e => setHansConfig({...hansConfig, port: e.target.value})} | |||||
| /> | |||||
| <Router color="action" sx={{ ml: 'auto' }} /> | |||||
| </Stack> | |||||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||||
| <Table size="small" stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>Ch3 Text (SN)</TableCell> | |||||
| <TableCell>Ch4 Text (Batch)</TableCell> | |||||
| <TableCell>Obj3 Name</TableCell> | |||||
| <TableCell>Obj4 Name</TableCell> | |||||
| <TableCell align="center">Action</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {hansItems.map(row => ( | |||||
| <TableRow key={row.id}> | |||||
| <TableCell> | |||||
| <TextField | |||||
| variant="standard" | |||||
| value={row.textChannel3} | |||||
| onChange={e => handleItemChange(setHansItems, row.id, 'textChannel3', e.target.value)} | |||||
| sx={{ minWidth: 180 }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <TextField | |||||
| variant="standard" | |||||
| value={row.textChannel4} | |||||
| onChange={e => handleItemChange(setHansItems, row.id, 'textChannel4', e.target.value)} | |||||
| sx={{ minWidth: 140 }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <TextField | |||||
| variant="standard" | |||||
| value={row.text3ObjectName} | |||||
| onChange={e => handleItemChange(setHansItems, row.id, 'text3ObjectName', e.target.value)} | |||||
| size="small" | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <TextField | |||||
| variant="standard" | |||||
| value={row.text4ObjectName} | |||||
| onChange={e => handleItemChange(setHansItems, row.id, 'text4ObjectName', e.target.value)} | |||||
| size="small" | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Button | |||||
| variant="contained" | |||||
| color="error" | |||||
| size="small" | |||||
| startIcon={<Print />} | |||||
| onClick={() => handleHansPrint(row)} | |||||
| sx={{ minWidth: 80 }} | |||||
| > | |||||
| TCP Mark | |||||
| </Button> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary', fontSize: '0.75rem' }}> | |||||
| TCP Push to EZCAD3 (Ch3/Ch4 via E3_SetTextObject) | IP:192.168.76.10:45678 | Backend: /print-laser-tcp | |||||
| </Typography> | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={5}> | |||||
| <Section title="6. GRN Preview (M18)"> | |||||
| <Section title="1. GRN Preview (M18)"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| @@ -555,8 +181,8 @@ export default function TestingPage() { | |||||
| </Section> | </Section> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tabValue} index={6}> | |||||
| <Section title="7. M18 PO Sync by Code"> | |||||
| <TabPanel value={tabValue} index={1}> | |||||
| <Section title="2. M18 PO Sync by Code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| @@ -566,12 +192,7 @@ export default function TestingPage() { | |||||
| placeholder="e.g. PFP002PO26030341" | placeholder="e.g. PFP002PO26030341" | ||||
| sx={{ minWidth: 320 }} | sx={{ minWidth: 320 }} | ||||
| /> | /> | ||||
| <Button | |||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={handleSyncM18PoByCode} | |||||
| disabled={isSyncingM18Po} | |||||
| > | |||||
| <Button variant="contained" color="primary" onClick={handleSyncM18PoByCode} disabled={isSyncingM18Po}> | |||||
| {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| @@ -592,22 +213,37 @@ export default function TestingPage() { | |||||
| </Section> | </Section> | ||||
| </TabPanel> | </TabPanel> | ||||
| {/* Dialog for OnPack */} | |||||
| <Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> | |||||
| <DialogTitle sx={{ bgcolor: 'success.main', color: 'white' }}>OnPack Printer Job Details</DialogTitle> | |||||
| <DialogContent sx={{ mt: 2 }}> | |||||
| <Stack spacing={3}> | |||||
| <TextField label="Item Code" fullWidth value={printerFormData.itemCode} onChange={(e) => setPrinterFormData({ ...printerFormData, itemCode: e.target.value })} /> | |||||
| <TextField label="Lot Number" fullWidth value={printerFormData.lotNo} onChange={(e) => setPrinterFormData({ ...printerFormData, lotNo: e.target.value })} /> | |||||
| <TextField label="Product Name" fullWidth value={printerFormData.productName} onChange={(e) => setPrinterFormData({ ...printerFormData, productName: e.target.value })} /> | |||||
| <TextField label="Expiry Date" type="date" fullWidth InputLabelProps={{ shrink: true }} value={printerFormData.expiryDate} onChange={(e) => setPrinterFormData({ ...printerFormData, expiryDate: e.target.value })} /> | |||||
| <TabPanel value={tabValue} index={2}> | |||||
| <Section title="3. M18 DO Sync by Code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="DO / Shop PO Code" | |||||
| value={m18DoCode} | |||||
| onChange={(e) => setM18DoCode(e.target.value)} | |||||
| placeholder="e.g. same document code as M18 shop PO" | |||||
| sx={{ minWidth: 320 }} | |||||
| /> | |||||
| <Button variant="contained" color="primary" onClick={handleSyncM18DoByCode} disabled={isSyncingM18Do}> | |||||
| {isSyncingM18Do ? "Syncing..." : "Sync DO from M18"} | |||||
| </Button> | |||||
| </Stack> | </Stack> | ||||
| </DialogContent> | |||||
| <DialogActions sx={{ p: 3 }}> | |||||
| <Button onClick={() => setIsPrinterModalOpen(false)} variant="outlined" color="inherit">Cancel</Button> | |||||
| <Button variant="contained" color="success" onClick={handleDownloadPrintJob}>Generate & Download</Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Typography variant="body2" color="textSecondary"> | |||||
| Backend endpoint: <code>/m18/test/do-by-code?code=YOUR_CODE</code> | |||||
| </Typography> | |||||
| {m18DoSyncResult ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={4} | |||||
| margin="normal" | |||||
| label="Sync Result" | |||||
| value={m18DoSyncResult} | |||||
| InputProps={{ readOnly: true }} | |||||
| /> | |||||
| ) : null} | |||||
| </Section> | |||||
| </TabPanel> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | |||||
| } | |||||
| @@ -33,6 +33,29 @@ export interface OnPackQrDownloadRequest { | |||||
| }[]; | }[]; | ||||
| } | } | ||||
| /** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */ | |||||
| async function zipDownloadError(res: Response): Promise<Error> { | |||||
| const text = await res.text(); | |||||
| const ct = res.headers.get("content-type") ?? ""; | |||||
| if (ct.includes("application/json")) { | |||||
| try { | |||||
| const j = JSON.parse(text) as { message?: string; error?: string }; | |||||
| if (typeof j.message === "string" && j.message.length > 0) { | |||||
| return new Error(j.message); | |||||
| } | |||||
| if (typeof j.error === "string" && j.error.length > 0) { | |||||
| return new Error(j.error); | |||||
| } | |||||
| } catch { | |||||
| /* ignore parse */ | |||||
| } | |||||
| } | |||||
| if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { | |||||
| return new Error(text); | |||||
| } | |||||
| return new Error(`下載失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`); | |||||
| } | |||||
| /** | /** | ||||
| * Fetch job orders by plan date from GET /py/job-orders. | * Fetch job orders by plan date from GET /py/job-orders. | ||||
| * Client-side only; uses auth token from localStorage. | * Client-side only; uses auth token from localStorage. | ||||
| @@ -75,7 +98,25 @@ export async function downloadOnPackQrZip( | |||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw new Error((await res.text()) || "Download failed"); | |||||
| throw await zipDownloadError(res); | |||||
| } | |||||
| return res.blob(); | |||||
| } | |||||
| /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ | |||||
| export async function downloadOnPackTextQrZip( | |||||
| request: OnPackQrDownloadRequest, | |||||
| ): Promise<Blob> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`; | |||||
| const res = await clientAuthFetch(url, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify(request), | |||||
| }); | |||||
| if (!res.ok) { | |||||
| throw await zipDownloadError(res); | |||||
| } | } | ||||
| return res.blob(); | return res.blob(); | ||||
| @@ -115,4 +115,44 @@ export async function fetchBomComboClient(): Promise<BomCombo[]> { | |||||
| { params: { batchId } } | { params: { batchId } } | ||||
| ); | ); | ||||
| return response.data; | return response.data; | ||||
| } | |||||
| } | |||||
| /** Master `equipment` rows for BOM process editor (description/name → code). */ | |||||
| export type EquipmentMasterRow = { | |||||
| code: string; | |||||
| name: string; | |||||
| description: string; | |||||
| }; | |||||
| /** Master `process` rows for BOM process editor (dropdown by code). */ | |||||
| export type ProcessMasterRow = { | |||||
| code: string; | |||||
| name: string; | |||||
| }; | |||||
| export async function fetchAllEquipmentsMasterClient(): Promise< | |||||
| EquipmentMasterRow[] | |||||
| > { | |||||
| const response = await axiosInstance.get<unknown[]>( | |||||
| `${NEXT_PUBLIC_API_URL}/Equipment`, | |||||
| ); | |||||
| const rows = Array.isArray(response.data) ? response.data : []; | |||||
| return rows.map((r: any) => ({ | |||||
| code: String(r?.code ?? "").trim(), | |||||
| name: String(r?.name ?? "").trim(), | |||||
| description: String(r?.description ?? "").trim(), | |||||
| })); | |||||
| } | |||||
| export async function fetchAllProcessesMasterClient(): Promise< | |||||
| ProcessMasterRow[] | |||||
| > { | |||||
| const response = await axiosInstance.get<unknown[]>( | |||||
| `${NEXT_PUBLIC_API_URL}/Process`, | |||||
| ); | |||||
| const rows = Array.isArray(response.data) ? response.data : []; | |||||
| return rows.map((r: any) => ({ | |||||
| code: String(r?.code ?? "").trim(), | |||||
| name: String(r?.name ?? "").trim(), | |||||
| })); | |||||
| } | |||||
| @@ -29,6 +29,81 @@ export interface PurchaseOrderByStatusRow { | |||||
| count: number; | count: number; | ||||
| } | } | ||||
| /** Multi-select filters for purchase charts (repeated `supplierId` / `itemCode` / `purchaseOrderNo` query params). */ | |||||
| export type PurchaseOrderChartFilters = { | |||||
| supplierIds?: number[]; | |||||
| itemCodes?: string[]; | |||||
| purchaseOrderNos?: string[]; | |||||
| /** Single supplier code (drill when row has no supplier id); not used with `supplierIds`. */ | |||||
| supplierCode?: string; | |||||
| }; | |||||
| function appendPurchaseOrderListParams(p: URLSearchParams, filters?: PurchaseOrderChartFilters) { | |||||
| (filters?.supplierIds ?? []).forEach((id) => { | |||||
| if (Number.isFinite(id) && id > 0) p.append("supplierId", String(id)); | |||||
| }); | |||||
| (filters?.itemCodes ?? []).forEach((c) => { | |||||
| const t = String(c).trim(); | |||||
| if (t) p.append("itemCode", t); | |||||
| }); | |||||
| (filters?.purchaseOrderNos ?? []).forEach((n) => { | |||||
| const t = String(n).trim(); | |||||
| if (t) p.append("purchaseOrderNo", t); | |||||
| }); | |||||
| const sc = filters?.supplierCode?.trim(); | |||||
| if (sc) p.set("supplierCode", sc); | |||||
| } | |||||
| export interface PoFilterSupplierOption { | |||||
| supplierId: number; | |||||
| code: string; | |||||
| name: string; | |||||
| } | |||||
| export interface PoFilterItemOption { | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| } | |||||
| export interface PoFilterPoNoOption { | |||||
| poNo: string; | |||||
| } | |||||
| export interface PurchaseOrderFilterOptions { | |||||
| suppliers: PoFilterSupplierOption[]; | |||||
| items: PoFilterItemOption[]; | |||||
| poNos: PoFilterPoNoOption[]; | |||||
| } | |||||
| export interface PurchaseOrderEstimatedArrivalRow { | |||||
| bucket: string; | |||||
| count: number; | |||||
| } | |||||
| export interface PurchaseOrderDetailByStatusRow { | |||||
| purchaseOrderId: number; | |||||
| purchaseOrderNo: string; | |||||
| status: string; | |||||
| orderDate: string; | |||||
| estimatedArrivalDate: string; | |||||
| /** Shop / supplier FK; use for grouping when code is blank */ | |||||
| supplierId: number | null; | |||||
| supplierCode: string; | |||||
| supplierName: string; | |||||
| itemCount: number; | |||||
| totalQty: number; | |||||
| } | |||||
| export interface PurchaseOrderItemRow { | |||||
| purchaseOrderLineId: number; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| orderedQty: number; | |||||
| uom: string; | |||||
| receivedQty: number; | |||||
| pendingQty: number; | |||||
| } | |||||
| export interface StockInOutByDateRow { | export interface StockInOutByDateRow { | ||||
| date: string; | date: string; | ||||
| inQty: number; | inQty: number; | ||||
| @@ -317,11 +392,13 @@ export async function fetchDeliveryOrderByDate( | |||||
| } | } | ||||
| export async function fetchPurchaseOrderByStatus( | export async function fetchPurchaseOrderByStatus( | ||||
| targetDate?: string | |||||
| targetDate?: string, | |||||
| filters?: PurchaseOrderChartFilters | |||||
| ): Promise<PurchaseOrderByStatusRow[]> { | ): Promise<PurchaseOrderByStatusRow[]> { | ||||
| const q = targetDate | |||||
| ? buildParams({ targetDate }) | |||||
| : ""; | |||||
| const p = new URLSearchParams(); | |||||
| if (targetDate) p.set("targetDate", targetDate); | |||||
| appendPurchaseOrderListParams(p, filters); | |||||
| const q = p.toString(); | |||||
| const res = await clientAuthFetch( | const res = await clientAuthFetch( | ||||
| q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status` | q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status` | ||||
| ); | ); | ||||
| @@ -333,6 +410,229 @@ export async function fetchPurchaseOrderByStatus( | |||||
| })); | })); | ||||
| } | } | ||||
| export async function fetchPurchaseOrderFilterOptions( | |||||
| targetDate?: string | |||||
| ): Promise<PurchaseOrderFilterOptions> { | |||||
| const p = new URLSearchParams(); | |||||
| if (targetDate) p.set("targetDate", targetDate); | |||||
| const q = p.toString(); | |||||
| const res = await clientAuthFetch( | |||||
| q ? `${BASE}/purchase-order-filter-options?${q}` : `${BASE}/purchase-order-filter-options` | |||||
| ); | |||||
| if (!res.ok) throw new Error("Failed to fetch purchase order filter options"); | |||||
| const data = await res.json(); | |||||
| const row = (data ?? {}) as Record<string, unknown>; | |||||
| const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record<string, unknown>[]; | |||||
| const items = (Array.isArray(row.items) ? row.items : []) as Record<string, unknown>[]; | |||||
| const poNos = (Array.isArray(row.poNos) ? row.poNos : []) as Record<string, unknown>[]; | |||||
| return { | |||||
| suppliers: suppliers.map((r) => ({ | |||||
| supplierId: Number(r.supplierId ?? r.supplierid ?? 0), | |||||
| code: String(r.code ?? ""), | |||||
| name: String(r.name ?? ""), | |||||
| })), | |||||
| items: items.map((r) => ({ | |||||
| itemCode: String(r.itemCode ?? r.itemcode ?? ""), | |||||
| itemName: String(r.itemName ?? r.itemname ?? ""), | |||||
| })), | |||||
| poNos: poNos.map((r) => ({ | |||||
| poNo: String(r.poNo ?? r.pono ?? ""), | |||||
| })), | |||||
| }; | |||||
| } | |||||
| export async function fetchPurchaseOrderEstimatedArrivalSummary( | |||||
| targetDate?: string, | |||||
| filters?: PurchaseOrderChartFilters | |||||
| ): Promise<PurchaseOrderEstimatedArrivalRow[]> { | |||||
| const p = new URLSearchParams(); | |||||
| if (targetDate) p.set("targetDate", targetDate); | |||||
| appendPurchaseOrderListParams(p, filters); | |||||
| const q = p.toString(); | |||||
| const res = await clientAuthFetch( | |||||
| q | |||||
| ? `${BASE}/purchase-order-estimated-arrival-summary?${q}` | |||||
| : `${BASE}/purchase-order-estimated-arrival-summary` | |||||
| ); | |||||
| if (!res.ok) throw new Error("Failed to fetch estimated arrival summary"); | |||||
| const data = await res.json(); | |||||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||||
| bucket: String(r.bucket ?? ""), | |||||
| count: Number(r.count ?? 0), | |||||
| })); | |||||
| } | |||||
| export interface EstimatedArrivalBreakdownSupplierRow { | |||||
| supplierId: number | null; | |||||
| supplierCode: string; | |||||
| supplierName: string; | |||||
| poCount: number; | |||||
| } | |||||
| export interface EstimatedArrivalBreakdownItemRow { | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| poCount: number; | |||||
| totalQty: number; | |||||
| } | |||||
| export interface EstimatedArrivalBreakdownPoRow { | |||||
| purchaseOrderId: number; | |||||
| purchaseOrderNo: string; | |||||
| status: string; | |||||
| orderDate: string; | |||||
| supplierId: number | null; | |||||
| supplierCode: string; | |||||
| supplierName: string; | |||||
| } | |||||
| export interface PurchaseOrderEstimatedArrivalBreakdown { | |||||
| suppliers: EstimatedArrivalBreakdownSupplierRow[]; | |||||
| items: EstimatedArrivalBreakdownItemRow[]; | |||||
| purchaseOrders: EstimatedArrivalBreakdownPoRow[]; | |||||
| } | |||||
| /** Related suppliers / items / POs for one 預計送貨 bucket (same bar filters as the donut). */ | |||||
| export async function fetchPurchaseOrderEstimatedArrivalBreakdown( | |||||
| targetDate: string, | |||||
| estimatedArrivalBucket: string, | |||||
| filters?: PurchaseOrderChartFilters | |||||
| ): Promise<PurchaseOrderEstimatedArrivalBreakdown> { | |||||
| const p = new URLSearchParams(); | |||||
| p.set("targetDate", targetDate); | |||||
| p.set("estimatedArrivalBucket", estimatedArrivalBucket.trim().toLowerCase()); | |||||
| appendPurchaseOrderListParams(p, filters); | |||||
| const res = await clientAuthFetch(`${BASE}/purchase-order-estimated-arrival-breakdown?${p.toString()}`); | |||||
| if (!res.ok) throw new Error("Failed to fetch estimated arrival breakdown"); | |||||
| const data = await res.json(); | |||||
| const row = (data ?? {}) as Record<string, unknown>; | |||||
| const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record<string, unknown>[]; | |||||
| const items = (Array.isArray(row.items) ? row.items : []) as Record<string, unknown>[]; | |||||
| const purchaseOrders = (Array.isArray(row.purchaseOrders) ? row.purchaseOrders : []) as Record<string, unknown>[]; | |||||
| return { | |||||
| suppliers: suppliers.map((r) => ({ | |||||
| supplierId: (() => { | |||||
| const v = r.supplierId ?? r.supplierid; | |||||
| if (v == null || v === "") return null; | |||||
| const n = Number(v); | |||||
| return Number.isFinite(n) ? n : null; | |||||
| })(), | |||||
| supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""), | |||||
| supplierName: String(r.supplierName ?? r.suppliername ?? ""), | |||||
| poCount: Number(r.poCount ?? r.pocount ?? 0), | |||||
| })), | |||||
| items: items.map((r) => ({ | |||||
| itemCode: String(r.itemCode ?? r.itemcode ?? ""), | |||||
| itemName: String(r.itemName ?? r.itemname ?? ""), | |||||
| poCount: Number(r.poCount ?? r.pocount ?? 0), | |||||
| totalQty: Number(r.totalQty ?? r.totalqty ?? 0), | |||||
| })), | |||||
| purchaseOrders: purchaseOrders.map((r) => ({ | |||||
| purchaseOrderId: Number(r.purchaseOrderId ?? r.purchaseorderid ?? 0), | |||||
| purchaseOrderNo: String(r.purchaseOrderNo ?? r.purchaseorderno ?? ""), | |||||
| status: String(r.status ?? ""), | |||||
| orderDate: String(r.orderDate ?? r.orderdate ?? ""), | |||||
| supplierId: (() => { | |||||
| const v = r.supplierId ?? r.supplierid; | |||||
| if (v == null || v === "") return null; | |||||
| const n = Number(v); | |||||
| return Number.isFinite(n) ? n : null; | |||||
| })(), | |||||
| supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""), | |||||
| supplierName: String(r.supplierName ?? r.suppliername ?? ""), | |||||
| })), | |||||
| }; | |||||
| } | |||||
| export type PurchaseOrderDrillQuery = PurchaseOrderChartFilters & { | |||||
| /** order = PO order date; complete = PO complete date (for received/completed on a day) */ | |||||
| dateFilter?: "order" | "complete"; | |||||
| /** delivered | not_delivered | cancelled | other — same as 預計送貨 donut buckets */ | |||||
| estimatedArrivalBucket?: string; | |||||
| }; | |||||
| export async function fetchPurchaseOrderDetailsByStatus( | |||||
| status: string, | |||||
| targetDate?: string, | |||||
| opts?: PurchaseOrderDrillQuery | |||||
| ): Promise<PurchaseOrderDetailByStatusRow[]> { | |||||
| const p = new URLSearchParams(); | |||||
| p.set("status", status.trim().toLowerCase()); | |||||
| if (targetDate) p.set("targetDate", targetDate); | |||||
| if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter); | |||||
| if (opts?.estimatedArrivalBucket?.trim()) { | |||||
| p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase()); | |||||
| } | |||||
| appendPurchaseOrderListParams(p, opts); | |||||
| const q = p.toString(); | |||||
| const res = await clientAuthFetch(`${BASE}/purchase-order-details-by-status?${q}`); | |||||
| if (!res.ok) throw new Error("Failed to fetch purchase order details by status"); | |||||
| const data = await res.json(); | |||||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||||
| purchaseOrderId: Number(r.purchaseOrderId ?? 0), | |||||
| purchaseOrderNo: String(r.purchaseOrderNo ?? ""), | |||||
| status: String(r.status ?? ""), | |||||
| orderDate: String(r.orderDate ?? ""), | |||||
| estimatedArrivalDate: String(r.estimatedArrivalDate ?? ""), | |||||
| supplierId: (() => { | |||||
| const v = r.supplierId; | |||||
| if (v == null || v === "") return null; | |||||
| const n = Number(v); | |||||
| return Number.isFinite(n) && n > 0 ? n : null; | |||||
| })(), | |||||
| supplierCode: String(r.supplierCode ?? ""), | |||||
| supplierName: String(r.supplierName ?? ""), | |||||
| itemCount: Number(r.itemCount ?? 0), | |||||
| totalQty: Number(r.totalQty ?? 0), | |||||
| })); | |||||
| } | |||||
| export async function fetchPurchaseOrderItems( | |||||
| purchaseOrderId: number | |||||
| ): Promise<PurchaseOrderItemRow[]> { | |||||
| const q = buildParams({ purchaseOrderId }); | |||||
| const res = await clientAuthFetch(`${BASE}/purchase-order-items?${q}`); | |||||
| if (!res.ok) throw new Error("Failed to fetch purchase order items"); | |||||
| const data = await res.json(); | |||||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||||
| purchaseOrderLineId: Number(r.purchaseOrderLineId ?? 0), | |||||
| itemCode: String(r.itemCode ?? ""), | |||||
| itemName: String(r.itemName ?? ""), | |||||
| orderedQty: Number(r.orderedQty ?? 0), | |||||
| uom: String(r.uom ?? ""), | |||||
| receivedQty: Number(r.receivedQty ?? 0), | |||||
| pendingQty: Number(r.pendingQty ?? 0), | |||||
| })); | |||||
| } | |||||
| export async function fetchPurchaseOrderItemsByStatus( | |||||
| status: string, | |||||
| targetDate?: string, | |||||
| opts?: PurchaseOrderDrillQuery | |||||
| ): Promise<PurchaseOrderItemRow[]> { | |||||
| const p = new URLSearchParams(); | |||||
| p.set("status", status.trim().toLowerCase()); | |||||
| if (targetDate) p.set("targetDate", targetDate); | |||||
| if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter); | |||||
| if (opts?.estimatedArrivalBucket?.trim()) { | |||||
| p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase()); | |||||
| } | |||||
| appendPurchaseOrderListParams(p, opts); | |||||
| const q = p.toString(); | |||||
| const res = await clientAuthFetch(`${BASE}/purchase-order-items-by-status?${q}`); | |||||
| if (!res.ok) throw new Error("Failed to fetch purchase order items by status"); | |||||
| const data = await res.json(); | |||||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||||
| purchaseOrderLineId: 0, | |||||
| itemCode: String(r.itemCode ?? ""), | |||||
| itemName: String(r.itemName ?? ""), | |||||
| orderedQty: Number(r.orderedQty ?? 0), | |||||
| uom: String(r.uom ?? ""), | |||||
| receivedQty: Number(r.receivedQty ?? 0), | |||||
| pendingQty: Number(r.pendingQty ?? 0), | |||||
| })); | |||||
| } | |||||
| export async function fetchStockInOutByDate( | export async function fetchStockInOutByDate( | ||||
| startDate?: string, | startDate?: string, | ||||
| endDate?: string | endDate?: string | ||||
| @@ -0,0 +1,135 @@ | |||||
| "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 LaserBag2Settings { | |||||
| host: string; | |||||
| port: number; | |||||
| /** Comma-separated item codes; empty string = show all packaging job orders */ | |||||
| itemCodes: string; | |||||
| } | |||||
| export interface LaserBag2SendRequest { | |||||
| itemId: number | null; | |||||
| stockInLineId: number | null; | |||||
| itemCode: string | null; | |||||
| itemName: string | null; | |||||
| printerIp?: string; | |||||
| printerPort?: number; | |||||
| } | |||||
| export interface LaserBag2SendResponse { | |||||
| success: boolean; | |||||
| message: string; | |||||
| payloadSent?: string | null; | |||||
| } | |||||
| /** | |||||
| * Uses server LASER_PRINT.itemCodes filter. Calls public GET /py/laser-job-orders (same as Python Bag2 /py/job-orders), | |||||
| * so it works without relying on authenticated /plastic routes. | |||||
| */ | |||||
| export async function fetchLaserJobOrders(planStart: string): Promise<JobOrderListItem[]> { | |||||
| const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); | |||||
| if (!base) { | |||||
| throw new Error("NEXT_PUBLIC_API_URL is not set; cannot reach API."); | |||||
| } | |||||
| const url = `${base}/py/laser-job-orders?planStart=${encodeURIComponent(planStart)}`; | |||||
| let res: Response; | |||||
| try { | |||||
| res = await clientAuthFetch(url, { method: "GET" }); | |||||
| } catch (e) { | |||||
| const msg = e instanceof Error ? e.message : String(e); | |||||
| throw new Error( | |||||
| `無法連線 API(${url}):${msg}。請確認後端已啟動且 NEXT_PUBLIC_API_URL 指向正確(例如 http://localhost:8090/api)。`, | |||||
| ); | |||||
| } | |||||
| if (!res.ok) { | |||||
| const body = await res.text().catch(() => ""); | |||||
| throw new Error( | |||||
| `載入工單失敗(${res.status})${body ? `:${body.slice(0, 200)}` : ""}`, | |||||
| ); | |||||
| } | |||||
| return res.json() as Promise<JobOrderListItem[]>; | |||||
| } | |||||
| export async function fetchLaserBag2Settings(): Promise<LaserBag2Settings> { | |||||
| const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); | |||||
| if (!base) { | |||||
| throw new Error("NEXT_PUBLIC_API_URL is not set."); | |||||
| } | |||||
| const url = `${base}/plastic/laser-bag2-settings`; | |||||
| let res: Response; | |||||
| try { | |||||
| res = await clientAuthFetch(url, { method: "GET" }); | |||||
| } catch (e) { | |||||
| const msg = e instanceof Error ? e.message : String(e); | |||||
| throw new Error(`無法連線至 ${url}:${msg}`); | |||||
| } | |||||
| if (!res.ok) { | |||||
| const body = await res.text().catch(() => ""); | |||||
| throw new Error(`載入設定失敗(${res.status})${body ? body.slice(0, 200) : ""}`); | |||||
| } | |||||
| return res.json() as Promise<LaserBag2Settings>; | |||||
| } | |||||
| export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; | |||||
| const res = await clientAuthFetch(url, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify(body), | |||||
| }); | |||||
| const data = (await res.json()) as LaserBag2SendResponse; | |||||
| if (!res.ok) { | |||||
| return data; | |||||
| } | |||||
| return data; | |||||
| } | |||||
| export interface PrinterStatusRequest { | |||||
| printerType: "laser"; | |||||
| printerIp?: string; | |||||
| printerPort?: number; | |||||
| } | |||||
| export interface PrinterStatusResponse { | |||||
| connected: boolean; | |||||
| message: string; | |||||
| } | |||||
| 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; | |||||
| return data; | |||||
| } | |||||
| export async function patchSetting(name: string, value: string): Promise<void> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`; | |||||
| const res = await clientAuthFetch(url, { | |||||
| method: "PATCH", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify({ value }), | |||||
| }); | |||||
| if (!res.ok) { | |||||
| const t = await res.text().catch(() => ""); | |||||
| throw new Error(t || `Failed to save setting: ${res.status}`); | |||||
| } | |||||
| } | |||||
| @@ -3,6 +3,7 @@ | |||||
| import { cache } from 'react'; | import { cache } from 'react'; | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson | import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson | ||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| //import { stockTakeDebugLog } from "@/components/StockTakeManagement/stockTakeDebugLog"; | |||||
| export interface RecordsRes<T> { | export interface RecordsRes<T> { | ||||
| records: T[]; | records: T[]; | ||||
| @@ -41,6 +42,39 @@ export interface InventoryLotDetailResponse { | |||||
| approverBadQty: number | null; | approverBadQty: number | null; | ||||
| finalQty: number | null; | finalQty: number | null; | ||||
| bookQty: number | null; | bookQty: number | null; | ||||
| lastSelect?: number | null; | |||||
| stockTakeSection?: string | null; | |||||
| stockTakeSectionDescription?: string | null; | |||||
| stockTakerName?: string | null; | |||||
| /** ISO string or backend LocalDateTime array */ | |||||
| stockTakeEndTime?: string | string[] | null; | |||||
| /** ISO string or backend LocalDateTime array */ | |||||
| approverTime?: string | string[] | null; | |||||
| } | |||||
| /** | |||||
| * `approverInventoryLotDetailsAll*`: | |||||
| * - `total` = 全域 `inventory_lot_line` 中 `status = available` 筆數(與 DB COUNT 一致) | |||||
| * - `filteredRecordCount` = 目前 tab/篩選後筆數(分頁用) | |||||
| */ | |||||
| export interface ApproverInventoryLotDetailsRecordsRes extends RecordsRes<InventoryLotDetailResponse> { | |||||
| filteredRecordCount?: number; | |||||
| totalWaitingForApprover?: number; | |||||
| totalApproved?: number; | |||||
| } | |||||
| function normalizeApproverInventoryLotDetailsRes( | |||||
| raw: ApproverInventoryLotDetailsRecordsRes | |||||
| ): ApproverInventoryLotDetailsRecordsRes { | |||||
| const waiting = Number(raw.totalWaitingForApprover ?? 0) || 0; | |||||
| const approved = Number(raw.totalApproved ?? 0) || 0; | |||||
| return { | |||||
| records: Array.isArray(raw.records) ? raw.records : [], | |||||
| total: Number(raw.total ?? 0) || 0, | |||||
| filteredRecordCount: Number(raw.filteredRecordCount ?? 0) || 0, | |||||
| totalWaitingForApprover: waiting, | |||||
| totalApproved: approved, | |||||
| }; | |||||
| } | } | ||||
| export const getInventoryLotDetailsBySection = async ( | export const getInventoryLotDetailsBySection = async ( | ||||
| @@ -114,13 +148,13 @@ export const getApproverInventoryLotDetailsAll = async ( | |||||
| } | } | ||||
| const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`; | const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`; | ||||
| const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||||
| const response = await serverFetchJson<ApproverInventoryLotDetailsRecordsRes>( | |||||
| url, | url, | ||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| }, | }, | ||||
| ); | ); | ||||
| return response; | |||||
| return normalizeApproverInventoryLotDetailsRes(response); | |||||
| } | } | ||||
| export const getApproverInventoryLotDetailsAllPending = async ( | export const getApproverInventoryLotDetailsAllPending = async ( | ||||
| stockTakeId?: number | null, | stockTakeId?: number | null, | ||||
| @@ -134,7 +168,8 @@ export const getApproverInventoryLotDetailsAllPending = async ( | |||||
| params.append("stockTakeId", String(stockTakeId)); | params.append("stockTakeId", String(stockTakeId)); | ||||
| } | } | ||||
| const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllPending?${params.toString()}`; | const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllPending?${params.toString()}`; | ||||
| return serverFetchJson<RecordsRes<InventoryLotDetailResponse>>(url, { method: "GET" }); | |||||
| const response = await serverFetchJson<ApproverInventoryLotDetailsRecordsRes>(url, { method: "GET" }); | |||||
| return normalizeApproverInventoryLotDetailsRes(response); | |||||
| } | } | ||||
| export const getApproverInventoryLotDetailsAllApproved = async ( | export const getApproverInventoryLotDetailsAllApproved = async ( | ||||
| stockTakeId?: number | null, | stockTakeId?: number | null, | ||||
| @@ -148,7 +183,8 @@ export const getApproverInventoryLotDetailsAllApproved = async ( | |||||
| params.append("stockTakeId", String(stockTakeId)); | params.append("stockTakeId", String(stockTakeId)); | ||||
| } | } | ||||
| const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllApproved?${params.toString()}`; | const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllApproved?${params.toString()}`; | ||||
| return serverFetchJson<RecordsRes<InventoryLotDetailResponse>>(url, { method: "GET" }); | |||||
| const response = await serverFetchJson<ApproverInventoryLotDetailsRecordsRes>(url, { method: "GET" }); | |||||
| return normalizeApproverInventoryLotDetailsRes(response); | |||||
| } | } | ||||
| export const importStockTake = async (data: FormData) => { | export const importStockTake = async (data: FormData) => { | ||||
| @@ -234,6 +270,7 @@ export const saveStockTakeRecord = async ( | |||||
| console.log('saveStockTakeRecord: request:', request); | console.log('saveStockTakeRecord: request:', request); | ||||
| console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); | console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); | ||||
| console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); | console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); | ||||
| return result; | return result; | ||||
| } catch (error: any) { | } catch (error: any) { | ||||
| // 尝试从错误响应中提取消息 | // 尝试从错误响应中提取消息 | ||||
| @@ -263,12 +300,14 @@ export interface BatchSaveStockTakeRecordResponse { | |||||
| errors: string[]; | errors: string[]; | ||||
| } | } | ||||
| export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRecordRequest) => { | export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRecordRequest) => { | ||||
| return serverFetchJson<BatchSaveStockTakeRecordResponse>(`${BASE_API_URL}/stockTakeRecord/batchSaveStockTakeRecords`, | |||||
| const r = await serverFetchJson<BatchSaveStockTakeRecordResponse>(`${BASE_API_URL}/stockTakeRecord/batchSaveStockTakeRecords`, | |||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| }) | }) | ||||
| return r | |||||
| }) | }) | ||||
| // Add these interfaces and functions | // Add these interfaces and functions | ||||
| @@ -279,6 +318,7 @@ export interface SaveApproverStockTakeRecordRequest { | |||||
| approverId?: number | null; | approverId?: number | null; | ||||
| approverQty?: number | null; | approverQty?: number | null; | ||||
| approverBadQty?: number | null; | approverBadQty?: number | null; | ||||
| lastSelect?: number | null; | |||||
| } | } | ||||
| export interface BatchSaveApproverStockTakeRecordRequest { | export interface BatchSaveApproverStockTakeRecordRequest { | ||||
| @@ -316,6 +356,7 @@ export const saveApproverStockTakeRecord = async ( | |||||
| body: JSON.stringify(request), | body: JSON.stringify(request), | ||||
| }, | }, | ||||
| ); | ); | ||||
| return result; | return result; | ||||
| } catch (error: any) { | } catch (error: any) { | ||||
| if (error?.response) { | if (error?.response) { | ||||
| @@ -345,7 +386,7 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp | |||||
| ) | ) | ||||
| export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => { | export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => { | ||||
| return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>( | |||||
| const r = await serverFetchJson<BatchSaveApproverStockTakeRecordResponse>( | |||||
| `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`, | `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`, | ||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| @@ -353,6 +394,8 @@ export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSave | |||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| } | } | ||||
| ) | ) | ||||
| return r | |||||
| }) | }) | ||||
| export const updateStockTakeRecordStatusToNotMatch = async ( | export const updateStockTakeRecordStatusToNotMatch = async ( | ||||
| @@ -25,7 +25,13 @@ import ChevronRight from "@mui/icons-material/ChevronRight"; | |||||
| import Settings from "@mui/icons-material/Settings"; | import Settings from "@mui/icons-material/Settings"; | ||||
| import Print from "@mui/icons-material/Print"; | import Print from "@mui/icons-material/Print"; | ||||
| import Download from "@mui/icons-material/Download"; | import Download from "@mui/icons-material/Download"; | ||||
| import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions"; | |||||
| import { | |||||
| checkPrinterStatus, | |||||
| downloadOnPackQrZip, | |||||
| downloadOnPackTextQrZip, | |||||
| fetchJobOrders, | |||||
| JobOrderListItem, | |||||
| } from "@/app/api/bagPrint/actions"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| @@ -107,6 +113,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| const [printerConnected, setPrinterConnected] = useState(false); | const [printerConnected, setPrinterConnected] = useState(false); | ||||
| const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | ||||
| const [downloadingOnPack, setDownloadingOnPack] = useState(false); | const [downloadingOnPack, setDownloadingOnPack] = useState(false); | ||||
| const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| setSettings(loadSettings()); | setSettings(loadSettings()); | ||||
| @@ -306,6 +313,46 @@ const BagPrintSearch: React.FC = () => { | |||||
| } | } | ||||
| }; | }; | ||||
| const handleDownloadOnPackTextQr = 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; | |||||
| } | |||||
| setDownloadingOnPackText(true); | |||||
| try { | |||||
| const blob = await downloadOnPackTextQrZip({ | |||||
| jobOrders: onPackJobOrders, | |||||
| }); | |||||
| const url = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement("a"); | |||||
| link.href = url; | |||||
| link.setAttribute("download", `onpack2023_lemon_qr_${planDate}.zip`); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(url); | |||||
| setSnackbar({ open: true, message: "OnPack2023檸檬機 ZIP 已下載", severity: "success" }); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機 失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| setDownloadingOnPackText(false); | |||||
| } | |||||
| }; | |||||
| return ( | return ( | ||||
| <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | ||||
| {/* Top: date nav + printer + settings */} | {/* Top: date nav + printer + settings */} | ||||
| @@ -360,15 +407,24 @@ const BagPrintSearch: React.FC = () => { | |||||
| <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}> | <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}> | ||||
| {printerMessage} | {printerMessage} | ||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" sx={{ mt: 2 }}> | |||||
| <Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| startIcon={<Download />} | startIcon={<Download />} | ||||
| onClick={handleDownloadOnPackQr} | onClick={handleDownloadOnPackQr} | ||||
| disabled={loading || downloadingOnPack || jobOrders.length === 0} | |||||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||||
| > | > | ||||
| {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} | {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} | ||||
| </Button> | </Button> | ||||
| <Button | |||||
| variant="contained" | |||||
| color="secondary" | |||||
| startIcon={<Download />} | |||||
| onClick={handleDownloadOnPackTextQr} | |||||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||||
| > | |||||
| {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"} | |||||
| </Button> | |||||
| </Stack> | </Stack> | ||||
| </Paper> | </Paper> | ||||
| @@ -47,6 +47,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/stockIssue": "Stock Issue", | "/stockIssue": "Stock Issue", | ||||
| "/report": "Report", | "/report": "Report", | ||||
| "/bagPrint": "打袋機", | "/bagPrint": "打袋機", | ||||
| "/laserPrint": "檸檬機(激光機)", | |||||
| "/settings/itemPrice": "Price Inquiry", | "/settings/itemPrice": "Price Inquiry", | ||||
| }; | }; | ||||
| @@ -58,6 +58,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| const formProps = useForm<CreateConsoDoInput>({ | const formProps = useForm<CreateConsoDoInput>({ | ||||
| defaultValues: {}, | defaultValues: {}, | ||||
| }); | }); | ||||
| const { setValue } = formProps; | |||||
| const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
| @@ -68,8 +69,8 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| console.log("🔍 DoSearch - session:", session); | console.log("🔍 DoSearch - session:", session); | ||||
| console.log("🔍 DoSearch - currentUserId:", currentUserId); | console.log("🔍 DoSearch - currentUserId:", currentUserId); | ||||
| const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null); | const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null); | ||||
| const [rowSelectionModel, setRowSelectionModel] = | |||||
| useState<GridRowSelectionModel>([]); | |||||
| /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */ | |||||
| const [excludedRowIds, setExcludedRowIds] = useState<number[]>([]); | |||||
| const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | ||||
| const [totalCount, setTotalCount] = useState(0); | const [totalCount, setTotalCount] = useState(0); | ||||
| @@ -101,6 +102,37 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| const [hasSearched, setHasSearched] = useState(false); | const [hasSearched, setHasSearched] = useState(false); | ||||
| const [hasResults, setHasResults] = useState(false); | const [hasResults, setHasResults] = useState(false); | ||||
| const excludedIdSet = useMemo(() => new Set(excludedRowIds), [excludedRowIds]); | |||||
| const rowSelectionModel = useMemo<GridRowSelectionModel>(() => { | |||||
| return searchAllDos | |||||
| .map((r) => r.id) | |||||
| .filter((id) => !excludedIdSet.has(id)); | |||||
| }, [searchAllDos, excludedIdSet]); | |||||
| const applyRowSelectionChange = useCallback( | |||||
| (newModel: GridRowSelectionModel) => { | |||||
| const pageIds = searchAllDos.map((r) => r.id); | |||||
| const selectedSet = new Set( | |||||
| newModel.map((id) => (typeof id === "string" ? Number(id) : id)), | |||||
| ); | |||||
| setExcludedRowIds((prev) => { | |||||
| const next = new Set(prev); | |||||
| for (const id of pageIds) { | |||||
| next.delete(id); | |||||
| } | |||||
| for (const id of pageIds) { | |||||
| if (!selectedSet.has(id)) { | |||||
| next.add(id); | |||||
| } | |||||
| } | |||||
| return Array.from(next); | |||||
| }); | |||||
| setValue("ids", newModel); | |||||
| }, | |||||
| [searchAllDos, setValue], | |||||
| ); | |||||
| // 当搜索条件变化时,重置到第一页 | // 当搜索条件变化时,重置到第一页 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| setPagingController(p => ({ | setPagingController(p => ({ | ||||
| @@ -140,6 +172,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| setTotalCount(0); | setTotalCount(0); | ||||
| setHasSearched(false); | setHasSearched(false); | ||||
| setHasResults(false); | setHasResults(false); | ||||
| setExcludedRowIds([]); | |||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | setPagingController({ pageNum: 1, pageSize: 10 }); | ||||
| } | } | ||||
| catch (error) { | catch (error) { | ||||
| @@ -289,6 +322,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| setTotalCount(response.total); // 设置总记录数 | setTotalCount(response.total); // 设置总记录数 | ||||
| setHasSearched(true); | setHasSearched(true); | ||||
| setHasResults(response.records.length > 0); | setHasResults(response.records.length > 0); | ||||
| setExcludedRowIds([]); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error: ", error); | console.error("Error: ", error); | ||||
| @@ -296,6 +330,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| setTotalCount(0); | setTotalCount(0); | ||||
| setHasSearched(true); | setHasSearched(true); | ||||
| setHasResults(false); | setHasResults(false); | ||||
| setExcludedRowIds([]); | |||||
| } | } | ||||
| }, [pagingController]); | }, [pagingController]); | ||||
| @@ -494,6 +529,20 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| const idsToRelease = allMatchingDos | |||||
| .map((d) => d.id) | |||||
| .filter((id) => !excludedIdSet.has(id)); | |||||
| if (idsToRelease.length === 0) { | |||||
| await Swal.fire({ | |||||
| icon: "warning", | |||||
| title: t("No Records"), | |||||
| text: t("No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection."), | |||||
| confirmButtonText: t("OK"), | |||||
| }); | |||||
| return; | |||||
| } | |||||
| // 显示确认对话框 | // 显示确认对话框 | ||||
| const result = await Swal.fire({ | const result = await Swal.fire({ | ||||
| @@ -501,7 +550,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| title: t("Batch Release"), | title: t("Batch Release"), | ||||
| html: ` | html: ` | ||||
| <div> | <div> | ||||
| <p>${t("Selected Shop(s): ")}${allMatchingDos.length}</p> | |||||
| <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | |||||
| <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | ||||
| ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | ||||
| @@ -519,8 +568,6 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| }); | }); | ||||
| if (result.isConfirmed) { | if (result.isConfirmed) { | ||||
| const idsToRelease = allMatchingDos.map(d => d.id); | |||||
| try { | try { | ||||
| const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | ||||
| const jobId = startRes?.entity?.jobId; | const jobId = startRes?.entity?.jobId; | ||||
| @@ -595,7 +642,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| confirmButtonText: t("OK") | confirmButtonText: t("OK") | ||||
| }); | }); | ||||
| } | } | ||||
| }, [t, currentUserId, currentSearchParams, handleSearch]); | |||||
| }, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -629,10 +676,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| columns={columns} | columns={columns} | ||||
| checkboxSelection | checkboxSelection | ||||
| rowSelectionModel={rowSelectionModel} | rowSelectionModel={rowSelectionModel} | ||||
| onRowSelectionModelChange={(newRowSelectionModel) => { | |||||
| setRowSelectionModel(newRowSelectionModel); | |||||
| formProps.setValue("ids", newRowSelectionModel); | |||||
| }} | |||||
| onRowSelectionModelChange={applyRowSelectionChange} | |||||
| slots={{ | slots={{ | ||||
| footer: FooterToolbar, | footer: FooterToolbar, | ||||
| noRowsOverlay: NoRowsOverlay, | noRowsOverlay: NoRowsOverlay, | ||||
| @@ -65,7 +65,9 @@ const FGPickOrderTicketReleaseTable: React.FC = () => { | |||||
| const { t } = useTranslation("ticketReleaseTable"); | const { t } = useTranslation("ticketReleaseTable"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | const abilities = session?.abilities ?? session?.user?.abilities ?? []; | ||||
| const canManageDoPickOps = abilities.includes(AUTH.ADMIN); | |||||
| // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:仅 abilities 明確包含 ADMIN 才允許操作 | |||||
| // (避免 abilities 裡出現前後空白導致 includes 判斷失效) | |||||
| const canManageDoPickOps = abilities.some((a) => a.trim() === AUTH.ADMIN); | |||||
| const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs()); | const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs()); | ||||
| const [selectedFloor, setSelectedFloor] = useState<string>(""); | const [selectedFloor, setSelectedFloor] = useState<string>(""); | ||||
| @@ -80,6 +80,23 @@ interface Props { | |||||
| onSwitchToRecordTab?: () => void; | onSwitchToRecordTab?: () => void; | ||||
| onRefreshReleasedOrderCount?: () => void; | onRefreshReleasedOrderCount?: () => void; | ||||
| } | } | ||||
| /** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */ | |||||
| function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null { | |||||
| if (!activeSuggestedLots?.length) return null; | |||||
| const withLotNo = activeSuggestedLots.filter( | |||||
| (l) => l.lotNo != null && String(l.lotNo).trim() !== "" | |||||
| ); | |||||
| if (withLotNo.length === 1) return withLotNo[0]; | |||||
| if (withLotNo.length > 1) { | |||||
| const pending = withLotNo.find( | |||||
| (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending" | |||||
| ); | |||||
| return pending || withLotNo[0]; | |||||
| } | |||||
| return activeSuggestedLots[0]; | |||||
| } | |||||
| // QR Code Modal Component (from LotTable) | // QR Code Modal Component (from LotTable) | ||||
| const QrCodeModal: React.FC<{ | const QrCodeModal: React.FC<{ | ||||
| open: boolean; | open: boolean; | ||||
| @@ -513,6 +530,22 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]); | const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]); | ||||
| // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) | // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) | ||||
| const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | ||||
| const applyLocalStockOutLineUpdate = useCallback(( | |||||
| stockOutLineId: number, | |||||
| status: string, | |||||
| actualPickQty?: number | |||||
| ) => { | |||||
| setCombinedLotData(prev => prev.map((lot) => { | |||||
| if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot; | |||||
| return { | |||||
| ...lot, | |||||
| stockOutLineStatus: status, | |||||
| ...(typeof actualPickQty === "number" | |||||
| ? { actualPickQty, stockOutLineQty: actualPickQty } | |||||
| : {}), | |||||
| }; | |||||
| })); | |||||
| }, []); | |||||
| // 防止重复点击(Submit / Just Completed / Issue) | // 防止重复点击(Submit / Just Completed / Issue) | ||||
| const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | ||||
| @@ -571,12 +604,11 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); | |||||
| const lastProcessedQrRef = useRef<string>(''); | const lastProcessedQrRef = useRef<string>(''); | ||||
| // Store callbacks in refs to avoid useEffect dependency issues | // Store callbacks in refs to avoid useEffect dependency issues | ||||
| const processOutsideQrCodeRef = useRef<((latestQr: string) => Promise<void>) | null>(null); | |||||
| const processOutsideQrCodeRef = useRef< | |||||
| ((latestQr: string, qrScanCountAtInvoke?: number) => Promise<void>) | null | |||||
| >(null); | |||||
| const resetScanRef = useRef<(() => void) | null>(null); | const resetScanRef = useRef<(() => void) | null>(null); | ||||
| const lotConfirmOpenedQrCountRef = useRef<number>(0); | const lotConfirmOpenedQrCountRef = useRef<number>(0); | ||||
| const lotConfirmOpenedQrValueRef = useRef<string>(''); | |||||
| const lotConfirmInitialSameQrSkippedRef = useRef<boolean>(false); | |||||
| const autoConfirmInProgressRef = useRef<boolean>(false); | |||||
| @@ -651,11 +683,14 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| const handleLotMismatch = useCallback((expectedLot: any, scannedLot: any) => { | |||||
| const handleLotMismatch = useCallback((expectedLot: any, scannedLot: any, qrScanCountAtOpen?: number) => { | |||||
| const mismatchStartTime = performance.now(); | const mismatchStartTime = performance.now(); | ||||
| console.log(`⏱️ [HANDLE LOT MISMATCH START]`); | console.log(`⏱️ [HANDLE LOT MISMATCH START]`); | ||||
| console.log(`⏰ Start time: ${new Date().toISOString()}`); | console.log(`⏰ Start time: ${new Date().toISOString()}`); | ||||
| console.log("Lot mismatch detected:", { expectedLot, scannedLot }); | console.log("Lot mismatch detected:", { expectedLot, scannedLot }); | ||||
| lotConfirmOpenedQrCountRef.current = | |||||
| typeof qrScanCountAtOpen === "number" ? qrScanCountAtOpen : 1; | |||||
| // ✅ Use setTimeout to avoid flushSync warning - schedule modal update in next tick | // ✅ Use setTimeout to avoid flushSync warning - schedule modal update in next tick | ||||
| const setTimeoutStartTime = performance.now(); | const setTimeoutStartTime = performance.now(); | ||||
| @@ -1299,34 +1334,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| return false; | return false; | ||||
| }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState]); | }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState]); | ||||
| useEffect(() => { | |||||
| if (!lotConfirmationOpen || !expectedLotData || !scannedLotData || !selectedLotForQr) { | |||||
| autoConfirmInProgressRef.current = false; | |||||
| return; | |||||
| } | |||||
| if (autoConfirmInProgressRef.current || isConfirmingLot) { | |||||
| return; | |||||
| } | |||||
| autoConfirmInProgressRef.current = true; | |||||
| handleLotConfirmation() | |||||
| .catch((error) => { | |||||
| console.error("Auto confirm lot substitution failed:", error); | |||||
| }) | |||||
| .finally(() => { | |||||
| autoConfirmInProgressRef.current = false; | |||||
| }); | |||||
| }, [lotConfirmationOpen, expectedLotData, scannedLotData, selectedLotForQr, isConfirmingLot, handleLotConfirmation]); | |||||
| useEffect(() => { | |||||
| if (lotConfirmationOpen) { | |||||
| // 记录弹窗打开时的扫码数量,避免把“触发弹窗的同一次扫码”当作二次确认 | |||||
| lotConfirmOpenedQrCountRef.current = qrValues.length; | |||||
| lotConfirmOpenedQrValueRef.current = qrValues[qrValues.length - 1] || ''; | |||||
| lotConfirmInitialSameQrSkippedRef.current = true; | |||||
| } | |||||
| }, [lotConfirmationOpen, qrValues.length]); | |||||
| const handleQrCodeSubmit = useCallback(async (lotNo: string) => { | const handleQrCodeSubmit = useCallback(async (lotNo: string) => { | ||||
| console.log(` Processing QR Code for lot: ${lotNo}`); | console.log(` Processing QR Code for lot: ${lotNo}`); | ||||
| @@ -1624,7 +1631,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // Store resetScan in ref for immediate access (update on every render) | // Store resetScan in ref for immediate access (update on every render) | ||||
| resetScanRef.current = resetScan; | resetScanRef.current = resetScan; | ||||
| const processOutsideQrCode = useCallback(async (latestQr: string) => { | |||||
| const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { | |||||
| const totalStartTime = performance.now(); | const totalStartTime = performance.now(); | ||||
| console.log(`⏱️ [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); | console.log(`⏱️ [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); | ||||
| console.log(`⏰ Start time: ${new Date().toISOString()}`); | console.log(`⏰ Start time: ${new Date().toISOString()}`); | ||||
| @@ -1742,7 +1749,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| lot.lotAvailability === 'rejected' || | lot.lotAvailability === 'rejected' || | ||||
| lot.lotAvailability === 'status_unavailable' | lot.lotAvailability === 'status_unavailable' | ||||
| ); | ); | ||||
| const expectedLot = rejectedLot || allLotsForItem[0]; // Use rejected lot if exists, otherwise first lot | |||||
| const expectedLot = | |||||
| rejectedLot || | |||||
| pickExpectedLotForSubstitution( | |||||
| allLotsForItem.filter( | |||||
| (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" | |||||
| ) | |||||
| ) || | |||||
| allLotsForItem[0]; | |||||
| // ✅ Always open confirmation modal when no active lots (user needs to confirm switching) | // ✅ Always open confirmation modal when no active lots (user needs to confirm switching) | ||||
| // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed | // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed | ||||
| @@ -1760,7 +1774,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemName: expectedLot.itemName, | itemName: expectedLot.itemName, | ||||
| inventoryLotLineId: scannedLot?.lotId || null, | inventoryLotLineId: scannedLot?.lotId || null, | ||||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | ||||
| } | |||||
| }, | |||||
| qrScanCountAtInvoke | |||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1785,7 +1800,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) | // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) | ||||
| if (!exactMatch) { | if (!exactMatch) { | ||||
| // Scanned lot is not in active suggested lots, open confirmation modal | // Scanned lot is not in active suggested lots, open confirmation modal | ||||
| const expectedLot = activeSuggestedLots[0] || allLotsForItem[0]; // Use first active lot or first lot as expected | |||||
| const expectedLot = | |||||
| pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; | |||||
| if (expectedLot) { | if (expectedLot) { | ||||
| // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) | // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) | ||||
| const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); | const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); | ||||
| @@ -1804,7 +1820,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemName: expectedLot.itemName, | itemName: expectedLot.itemName, | ||||
| inventoryLotLineId: scannedLot?.lotId || null, | inventoryLotLineId: scannedLot?.lotId || null, | ||||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | ||||
| } | |||||
| }, | |||||
| qrScanCountAtInvoke | |||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1925,9 +1942,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | ||||
| console.log(`⏱️ [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); | console.log(`⏱️ [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); | ||||
| // 取第一个活跃的 lot 作为期望的 lot | |||||
| // 取应被替换的活跃行(同物料多行时优先有建议批次的行) | |||||
| const expectedLotStartTime = performance.now(); | const expectedLotStartTime = performance.now(); | ||||
| const expectedLot = activeSuggestedLots[0]; | |||||
| const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); | |||||
| if (!expectedLot) { | if (!expectedLot) { | ||||
| console.error("Could not determine expected lot for confirmation"); | console.error("Could not determine expected lot for confirmation"); | ||||
| startTransition(() => { | startTransition(() => { | ||||
| @@ -1963,7 +1980,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemName: expectedLot.itemName, | itemName: expectedLot.itemName, | ||||
| inventoryLotLineId: null, | inventoryLotLineId: null, | ||||
| stockInLineId: scannedStockInLineId // ✅ 传递 stockInLineId | stockInLineId: scannedStockInLineId // ✅ 传递 stockInLineId | ||||
| } | |||||
| }, | |||||
| qrScanCountAtInvoke | |||||
| ); | ); | ||||
| const handleMismatchTime = performance.now() - handleMismatchStartTime; | const handleMismatchTime = performance.now() - handleMismatchStartTime; | ||||
| console.log(`⏱️ [PERF] Handle mismatch call time: ${handleMismatchTime.toFixed(2)}ms`); | console.log(`⏱️ [PERF] Handle mismatch call time: ${handleMismatchTime.toFixed(2)}ms`); | ||||
| @@ -2048,7 +2066,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // ✅ Process immediately (bypass QR scanner delay) | // ✅ Process immediately (bypass QR scanner delay) | ||||
| if (processOutsideQrCodeRef.current) { | if (processOutsideQrCodeRef.current) { | ||||
| processOutsideQrCodeRef.current(simulatedQr).then(() => { | |||||
| processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { | |||||
| const testTime = performance.now() - testStartTime; | const testTime = performance.now() - testStartTime; | ||||
| console.log(`⏱️ [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); | console.log(`⏱️ [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); | ||||
| console.log(`⏱️ [TEST QR] End time: ${new Date().toISOString()}`); | console.log(`⏱️ [TEST QR] End time: ${new Date().toISOString()}`); | ||||
| @@ -2074,9 +2092,24 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| } | } | ||||
| } | } | ||||
| // lot confirm 弹窗打开时,允许通过“再次扫码”决定走向(切换或继续原 lot) | |||||
| // 批次确认弹窗:须第二次扫码选择沿用建议批次或切换(不再自动确认) | |||||
| if (lotConfirmationOpen) { | if (lotConfirmationOpen) { | ||||
| // 已改回自动确认:弹窗打开时不再等待二次扫码 | |||||
| if (isConfirmingLot) { | |||||
| return; | |||||
| } | |||||
| if (qrValues.length <= lotConfirmOpenedQrCountRef.current) { | |||||
| return; | |||||
| } | |||||
| void (async () => { | |||||
| try { | |||||
| const handled = await handleLotConfirmationByRescan(latestQr); | |||||
| if (handled && resetScanRef.current) { | |||||
| resetScanRef.current(); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("Lot confirmation rescan failed:", e); | |||||
| } | |||||
| })(); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -2171,7 +2204,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // Use ref to avoid dependency issues | // Use ref to avoid dependency issues | ||||
| const processCallStartTime = performance.now(); | const processCallStartTime = performance.now(); | ||||
| if (processOutsideQrCodeRef.current) { | if (processOutsideQrCodeRef.current) { | ||||
| processOutsideQrCodeRef.current(latestQr).then(() => { | |||||
| processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => { | |||||
| const processCallTime = performance.now() - processCallStartTime; | const processCallTime = performance.now() - processCallStartTime; | ||||
| const totalProcessingTime = performance.now() - processingStartTime; | const totalProcessingTime = performance.now() - processingStartTime; | ||||
| console.log(`⏱️ [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); | console.log(`⏱️ [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); | ||||
| @@ -2203,7 +2236,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| qrProcessingTimeoutRef.current = null; | qrProcessingTimeoutRef.current = null; | ||||
| } | } | ||||
| }; | }; | ||||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan]); | |||||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan, isConfirmingLot]); | |||||
| const renderCountRef = useRef(0); | const renderCountRef = useRef(0); | ||||
| const renderStartTimeRef = useRef<number | null>(null); | const renderStartTimeRef = useRef<number | null>(null); | ||||
| @@ -2550,16 +2583,16 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| try { | try { | ||||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | ||||
| // Special case: If submitQty is 0 and all values are 0, mark as completed with qty: 0 | |||||
| // Just Complete: mark checked only, real posting happens in batch submit | |||||
| if (submitQty === 0) { | if (submitQty === 0) { | ||||
| console.log(`=== SUBMITTING ALL ZEROS CASE ===`); | console.log(`=== SUBMITTING ALL ZEROS CASE ===`); | ||||
| console.log(`Lot: ${lot.lotNo}`); | console.log(`Lot: ${lot.lotNo}`); | ||||
| console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); | console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); | ||||
| console.log(`Setting status to 'completed' with qty: 0`); | |||||
| console.log(`Setting status to 'checked' with qty: 0`); | |||||
| const updateResult = await updateStockOutLineStatus({ | const updateResult = await updateStockOutLineStatus({ | ||||
| id: lot.stockOutLineId, | id: lot.stockOutLineId, | ||||
| status: 'completed', | |||||
| status: 'checked', | |||||
| qty: 0 | qty: 0 | ||||
| }); | }); | ||||
| @@ -2575,29 +2608,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| console.error('Failed to update stock out line status:', updateResult); | console.error('Failed to update stock out line status:', updateResult); | ||||
| throw new Error('Failed to update stock out line status'); | throw new Error('Failed to update stock out line status'); | ||||
| } | } | ||||
| applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), "checked", Number(lot.actualPickQty || 0)); | |||||
| // Check if pick order is completed | |||||
| if (lot.pickOrderConsoCode) { | |||||
| console.log(` Lot ${lot.lotNo} completed (all zeros), checking if pick order ${lot.pickOrderConsoCode} is complete...`); | |||||
| try { | |||||
| const completionResponse = await checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode); | |||||
| console.log(` Pick order completion check result:`, completionResponse); | |||||
| if (completionResponse.code === "SUCCESS") { | |||||
| console.log(` Pick order ${lot.pickOrderConsoCode} completed successfully!`); | |||||
| } else if (completionResponse.message === "not completed") { | |||||
| console.log(`⏳ Pick order not completed yet, more lines remaining`); | |||||
| } else { | |||||
| console.error(` Error checking completion: ${completionResponse.message}`); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error checking pick order completion:", error); | |||||
| } | |||||
| } | |||||
| await fetchAllCombinedLotData(); | |||||
| console.log("All zeros submission completed successfully!"); | |||||
| void fetchAllCombinedLotData(); | |||||
| console.log("Just Complete marked as checked successfully (waiting for batch submit)."); | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| checkAndAutoAssignNext(); | checkAndAutoAssignNext(); | ||||
| @@ -2635,6 +2649,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| status: newStatus, | status: newStatus, | ||||
| qty: cumulativeQty // Use cumulative quantity | qty: cumulativeQty // Use cumulative quantity | ||||
| }); | }); | ||||
| applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty); | |||||
| if (submitQty > 0) { | if (submitQty > 0) { | ||||
| await updateInventoryLotLineQuantities({ | await updateInventoryLotLineQuantities({ | ||||
| @@ -2665,7 +2680,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| } | } | ||||
| } | } | ||||
| await fetchAllCombinedLotData(); | |||||
| void fetchAllCombinedLotData(); | |||||
| console.log("Pick quantity submitted successfully!"); | console.log("Pick quantity submitted successfully!"); | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| @@ -2677,16 +2692,31 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| } finally { | } finally { | ||||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | ||||
| } | } | ||||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId]); | |||||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId, applyLocalStockOutLineUpdate]); | |||||
| const handleSkip = useCallback(async (lot: any) => { | const handleSkip = useCallback(async (lot: any) => { | ||||
| try { | try { | ||||
| console.log("Skip clicked, submit lot required qty for lot:", lot.lotNo); | |||||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty); | |||||
| console.log("Just Complete clicked, mark checked with 0 qty for lot:", lot.lotNo); | |||||
| await handleSubmitPickQtyWithQty(lot, 0); | |||||
| } catch (err) { | } catch (err) { | ||||
| console.error("Error in Skip:", err); | console.error("Error in Skip:", err); | ||||
| } | } | ||||
| }, [handleSubmitPickQtyWithQty]); | }, [handleSubmitPickQtyWithQty]); | ||||
| const hasPendingBatchSubmit = useMemo(() => { | |||||
| return combinedLotData.some((lot) => { | |||||
| const status = String(lot.stockOutLineStatus || "").toLowerCase(); | |||||
| return status === "checked" || status === "pending" || status === "partially_completed" || status === "partially_complete"; | |||||
| }); | |||||
| }, [combinedLotData]); | |||||
| useEffect(() => { | |||||
| if (!hasPendingBatchSubmit) return; | |||||
| const handler = (event: BeforeUnloadEvent) => { | |||||
| event.preventDefault(); | |||||
| event.returnValue = ""; | |||||
| }; | |||||
| window.addEventListener("beforeunload", handler); | |||||
| return () => window.removeEventListener("beforeunload", handler); | |||||
| }, [hasPendingBatchSubmit]); | |||||
| const handleStartScan = useCallback(() => { | const handleStartScan = useCallback(() => { | ||||
| const startTime = performance.now(); | const startTime = performance.now(); | ||||
| console.log(`⏱️ [START SCAN] Called at: ${new Date().toISOString()}`); | console.log(`⏱️ [START SCAN] Called at: ${new Date().toISOString()}`); | ||||
| @@ -2890,6 +2920,10 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| const scannedLots = combinedLotData.filter(lot => { | const scannedLots = combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| // ✅ noLot 情况:允许 checked / pending / partially_completed / PARTIALLY_COMPLETE | // ✅ noLot 情况:允许 checked / pending / partially_completed / PARTIALLY_COMPLETE | ||||
| if (lot.noLot === true) { | if (lot.noLot === true) { | ||||
| return status === 'checked' || | return status === 'checked' || | ||||
| @@ -3021,6 +3055,10 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| const scannedItemsCount = useMemo(() => { | const scannedItemsCount = useMemo(() => { | ||||
| const filtered = combinedLotData.filter(lot => { | const filtered = combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| // ✅ 与 handleSubmitAllScanned 完全保持一致 | // ✅ 与 handleSubmitAllScanned 完全保持一致 | ||||
| if (lot.noLot === true) { | if (lot.noLot === true) { | ||||
| return status === 'checked' || | return status === 'checked' || | ||||
| @@ -3528,6 +3566,9 @@ paginatedData.map((lot, index) => { | |||||
| onClick={() => handleSkip(lot)} | onClick={() => handleSkip(lot)} | ||||
| disabled={ | disabled={ | ||||
| lot.stockOutLineStatus === 'completed' || | lot.stockOutLineStatus === 'completed' || | ||||
| lot.stockOutLineStatus === 'checked' || | |||||
| lot.stockOutLineStatus === 'partially_completed' || | |||||
| // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) | // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) | ||||
| (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || | (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || | ||||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | ||||
| @@ -52,7 +52,7 @@ const LotConfirmationModal: React.FC<LotConfirmationModalProps> = ({ | |||||
| <DialogContent> | <DialogContent> | ||||
| <Stack spacing={3}> | <Stack spacing={3}> | ||||
| <Alert severity="warning"> | <Alert severity="warning"> | ||||
| {t("The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?")} | |||||
| {t("The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.")} | |||||
| </Alert> | </Alert> | ||||
| <Box> | <Box> | ||||
| @@ -92,13 +92,10 @@ const LotConfirmationModal: React.FC<LotConfirmationModalProps> = ({ | |||||
| </Box> | </Box> | ||||
| <Alert severity="info"> | <Alert severity="info"> | ||||
| {t("If you confirm, the system will:")} | |||||
| <ul style={{ margin: '8px 0 0 16px' }}> | |||||
| <li>{t("Update your suggested lot to the this scanned lot")}</li> | |||||
| </ul> | |||||
| {t("After you scan to choose, the system will update the pick line to the lot you confirmed.")} | |||||
| </Alert> | </Alert> | ||||
| <Alert severity="info"> | <Alert severity="info"> | ||||
| {t("You can also scan again to confirm: scan the scanned lot again to switch, or scan the expected lot to continue with current lot.")} | |||||
| {t("Or use the Confirm button below if you cannot scan again (same as scanning the other lot again).")} | |||||
| </Alert> | </Alert> | ||||
| </Stack> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| @@ -27,6 +27,10 @@ import { | |||||
| editBomClient, | editBomClient, | ||||
| fetchBomComboClient, | fetchBomComboClient, | ||||
| fetchBomDetailClient, | fetchBomDetailClient, | ||||
| fetchAllEquipmentsMasterClient, | |||||
| fetchAllProcessesMasterClient, | |||||
| type EquipmentMasterRow, | |||||
| type ProcessMasterRow, | |||||
| } from "@/app/api/bom/client"; | } from "@/app/api/bom/client"; | ||||
| import type { SelectChangeEvent } from "@mui/material/Select"; | import type { SelectChangeEvent } from "@mui/material/Select"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -37,6 +41,26 @@ import SaveIcon from "@mui/icons-material/Save"; | |||||
| import CancelIcon from "@mui/icons-material/Cancel"; | import CancelIcon from "@mui/icons-material/Cancel"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | import EditIcon from "@mui/icons-material/Edit"; | ||||
| /** 以 description + "-" + name 對應 code,或同一筆設備的 description+name。 */ | |||||
| function resolveEquipmentCode( | |||||
| list: EquipmentMasterRow[], | |||||
| description: string, | |||||
| name: string, | |||||
| ): string | null { | |||||
| const d = description.trim(); | |||||
| const n = name.trim(); | |||||
| if (!d && !n) return null; | |||||
| if (!d || !n) return null; | |||||
| const composite = `${d}-${n}`; | |||||
| const byCode = list.find((e) => e.code === composite); | |||||
| if (byCode) return byCode.code; | |||||
| const byPair = list.find( | |||||
| (e) => e.description === d && e.name === n, | |||||
| ); | |||||
| return byPair?.code ?? null; | |||||
| } | |||||
| const ImportBomDetailTab: React.FC = () => { | const ImportBomDetailTab: React.FC = () => { | ||||
| const { t } = useTranslation( "common" ); | const { t } = useTranslation( "common" ); | ||||
| const [bomList, setBomList] = useState<BomCombo[]>([]); | const [bomList, setBomList] = useState<BomCombo[]>([]); | ||||
| @@ -69,7 +93,9 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| processCode?: string; | processCode?: string; | ||||
| processName?: string; | processName?: string; | ||||
| description: string; | description: string; | ||||
| equipmentCode?: string; | |||||
| /** 設備主檔 description(下拉),與 equipmentName 一併解析為 equipment.code */ | |||||
| equipmentDescription: string; | |||||
| equipmentName: string; | |||||
| durationInMinute: number; | durationInMinute: number; | ||||
| prepTimeInMinute: number; | prepTimeInMinute: number; | ||||
| postProdTimeInMinute: number; | postProdTimeInMinute: number; | ||||
| @@ -96,17 +122,27 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]); | const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]); | ||||
| const [editProcesses, setEditProcesses] = useState<EditProcessRow[]>([]); | const [editProcesses, setEditProcesses] = useState<EditProcessRow[]>([]); | ||||
| // Process add form (uses dropdown selections). | |||||
| const [equipmentMasterList, setEquipmentMasterList] = useState< | |||||
| EquipmentMasterRow[] | |||||
| >([]); | |||||
| const [processMasterList, setProcessMasterList] = useState< | |||||
| ProcessMasterRow[] | |||||
| >([]); | |||||
| const [editMasterLoading, setEditMasterLoading] = useState(false); | |||||
| // Process add form (uses dropdown selections from master tables). | |||||
| const [processAddForm, setProcessAddForm] = useState<{ | const [processAddForm, setProcessAddForm] = useState<{ | ||||
| processCode: string; | processCode: string; | ||||
| equipmentCode: string; | |||||
| equipmentDescription: string; | |||||
| equipmentName: string; | |||||
| description: string; | description: string; | ||||
| durationInMinute: number; | durationInMinute: number; | ||||
| prepTimeInMinute: number; | prepTimeInMinute: number; | ||||
| postProdTimeInMinute: number; | postProdTimeInMinute: number; | ||||
| }>({ | }>({ | ||||
| processCode: "", | processCode: "", | ||||
| equipmentCode: "", | |||||
| equipmentDescription: "", | |||||
| equipmentName: "", | |||||
| description: "", | description: "", | ||||
| durationInMinute: 0, | durationInMinute: 0, | ||||
| prepTimeInMinute: 0, | prepTimeInMinute: 0, | ||||
| @@ -115,19 +151,27 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| const processCodeOptions = useMemo(() => { | const processCodeOptions = useMemo(() => { | ||||
| const codes = new Set<string>(); | const codes = new Set<string>(); | ||||
| (detail?.processes ?? []).forEach((p) => { | |||||
| if (p.processCode) codes.add(p.processCode); | |||||
| processMasterList.forEach((p) => { | |||||
| if (p.code) codes.add(p.code); | |||||
| }); | }); | ||||
| return Array.from(codes); | |||||
| }, [detail]); | |||||
| return Array.from(codes).sort(); | |||||
| }, [processMasterList]); | |||||
| const equipmentCodeOptions = useMemo(() => { | |||||
| const codes = new Set<string>(); | |||||
| (detail?.processes ?? []).forEach((p) => { | |||||
| if (p.equipmentCode) codes.add(p.equipmentCode); | |||||
| const equipmentDescriptionOptions = useMemo(() => { | |||||
| const s = new Set<string>(); | |||||
| equipmentMasterList.forEach((e) => { | |||||
| if (e.description) s.add(e.description); | |||||
| }); | }); | ||||
| return Array.from(codes); | |||||
| }, [detail]); | |||||
| return Array.from(s).sort(); | |||||
| }, [equipmentMasterList]); | |||||
| const equipmentNameOptions = useMemo(() => { | |||||
| const s = new Set<string>(); | |||||
| equipmentMasterList.forEach((e) => { | |||||
| if (e.name) s.add(e.name); | |||||
| }); | |||||
| return Array.from(s).sort(); | |||||
| }, [equipmentMasterList]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const loadList = async () => { | const loadList = async () => { | ||||
| @@ -242,57 +286,82 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| const genKey = () => Math.random().toString(36).slice(2); | const genKey = () => Math.random().toString(36).slice(2); | ||||
| const startEdit = useCallback(() => { | |||||
| const startEdit = useCallback(async () => { | |||||
| if (!detail) return; | if (!detail) return; | ||||
| setEditError(null); | setEditError(null); | ||||
| setEditBasic({ | |||||
| description: detail.description ?? "", | |||||
| outputQty: detail.outputQty ?? 0, | |||||
| outputQtyUom: detail.outputQtyUom ?? "", | |||||
| setEditMasterLoading(true); | |||||
| try { | |||||
| const [equipments, processes] = await Promise.all([ | |||||
| fetchAllEquipmentsMasterClient(), | |||||
| fetchAllProcessesMasterClient(), | |||||
| ]); | |||||
| setEquipmentMasterList(equipments); | |||||
| setProcessMasterList(processes); | |||||
| isDark: detail.isDark ?? 0, | |||||
| isFloat: detail.isFloat ?? 0, | |||||
| isDense: detail.isDense ?? 0, | |||||
| scrapRate: detail.scrapRate ?? 0, | |||||
| allergicSubstances: detail.allergicSubstances ?? 0, | |||||
| timeSequence: detail.timeSequence ?? 0, | |||||
| complexity: detail.complexity ?? 0, | |||||
| isDrink: detail.isDrink ?? false, | |||||
| }); | |||||
| setEditBasic({ | |||||
| description: detail.description ?? "", | |||||
| outputQty: detail.outputQty ?? 0, | |||||
| outputQtyUom: detail.outputQtyUom ?? "", | |||||
| setEditMaterials( | |||||
| (detail.materials ?? []).map((m) => ({ | |||||
| key: genKey(), | |||||
| id: undefined, | |||||
| itemCode: m.itemCode ?? "", | |||||
| itemName: m.itemName ?? "", | |||||
| qty: m.baseQty ?? 0, | |||||
| isConsumable: m.isConsumable ?? false, | |||||
| baseUom: m.baseUom, | |||||
| stockQty: m.stockQty, | |||||
| stockUom: m.stockUom, | |||||
| salesQty: m.salesQty, | |||||
| salesUom: m.salesUom, | |||||
| })), | |||||
| ); | |||||
| isDark: detail.isDark ?? 0, | |||||
| isFloat: detail.isFloat ?? 0, | |||||
| isDense: detail.isDense ?? 0, | |||||
| scrapRate: detail.scrapRate ?? 0, | |||||
| allergicSubstances: detail.allergicSubstances ?? 0, | |||||
| timeSequence: detail.timeSequence ?? 0, | |||||
| complexity: detail.complexity ?? 0, | |||||
| isDrink: detail.isDrink ?? false, | |||||
| }); | |||||
| setEditProcesses( | |||||
| (detail.processes ?? []).map((p) => ({ | |||||
| key: genKey(), | |||||
| id: undefined, | |||||
| seqNo: p.seqNo, | |||||
| processCode: p.processCode ?? "", | |||||
| processName: p.processName, | |||||
| description: p.processDescription ?? "", | |||||
| equipmentCode: p.equipmentCode ?? p.equipmentName ?? "", | |||||
| durationInMinute: p.durationInMinute ?? 0, | |||||
| prepTimeInMinute: p.prepTimeInMinute ?? 0, | |||||
| postProdTimeInMinute: p.postProdTimeInMinute ?? 0, | |||||
| })), | |||||
| ); | |||||
| setEditMaterials( | |||||
| (detail.materials ?? []).map((m) => ({ | |||||
| key: genKey(), | |||||
| id: undefined, | |||||
| itemCode: m.itemCode ?? "", | |||||
| itemName: m.itemName ?? "", | |||||
| qty: m.baseQty ?? 0, | |||||
| isConsumable: m.isConsumable ?? false, | |||||
| baseUom: m.baseUom, | |||||
| stockQty: m.stockQty, | |||||
| stockUom: m.stockUom, | |||||
| salesQty: m.salesQty, | |||||
| salesUom: m.salesUom, | |||||
| })), | |||||
| ); | |||||
| setEditProcesses( | |||||
| (detail.processes ?? []).map((p) => { | |||||
| const code = (p.equipmentCode ?? "").trim(); | |||||
| const eq = code | |||||
| ? equipments.find((e) => e.code === code) | |||||
| : undefined; | |||||
| return { | |||||
| key: genKey(), | |||||
| id: undefined, | |||||
| seqNo: p.seqNo, | |||||
| processCode: p.processCode ?? "", | |||||
| processName: p.processName, | |||||
| description: p.processDescription ?? "", | |||||
| equipmentDescription: eq?.description ?? "", | |||||
| equipmentName: eq?.name ?? "", | |||||
| durationInMinute: p.durationInMinute ?? 0, | |||||
| prepTimeInMinute: p.prepTimeInMinute ?? 0, | |||||
| postProdTimeInMinute: p.postProdTimeInMinute ?? 0, | |||||
| }; | |||||
| }), | |||||
| ); | |||||
| setIsEditing(true); | |||||
| setIsEditing(true); | |||||
| } catch (e: unknown) { | |||||
| const msg = | |||||
| e && typeof e === "object" && "message" in e | |||||
| ? String((e as { message?: string }).message) | |||||
| : "載入製程/設備主檔失敗"; | |||||
| setEditError(msg); | |||||
| } finally { | |||||
| setEditMasterLoading(false); | |||||
| } | |||||
| }, [detail]); | }, [detail]); | ||||
| const cancelEdit = useCallback(() => { | const cancelEdit = useCallback(() => { | ||||
| @@ -304,12 +373,15 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| setEditProcesses([]); | setEditProcesses([]); | ||||
| setProcessAddForm({ | setProcessAddForm({ | ||||
| processCode: "", | processCode: "", | ||||
| equipmentCode: "", | |||||
| equipmentDescription: "", | |||||
| equipmentName: "", | |||||
| description: "", | description: "", | ||||
| durationInMinute: 0, | durationInMinute: 0, | ||||
| prepTimeInMinute: 0, | prepTimeInMinute: 0, | ||||
| postProdTimeInMinute: 0, | postProdTimeInMinute: 0, | ||||
| }); | }); | ||||
| setEquipmentMasterList([]); | |||||
| setProcessMasterList([]); | |||||
| }, []); | }, []); | ||||
| const addMaterialRow = useCallback(() => { | const addMaterialRow = useCallback(() => { | ||||
| @@ -339,7 +411,8 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| processCode: "", | processCode: "", | ||||
| processName: "", | processName: "", | ||||
| description: "", | description: "", | ||||
| equipmentCode: "", | |||||
| equipmentDescription: "", | |||||
| equipmentName: "", | |||||
| durationInMinute: 0, | durationInMinute: 0, | ||||
| prepTimeInMinute: 0, | prepTimeInMinute: 0, | ||||
| postProdTimeInMinute: 0, | postProdTimeInMinute: 0, | ||||
| @@ -354,6 +427,22 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| return; | return; | ||||
| } | } | ||||
| const ed = processAddForm.equipmentDescription.trim(); | |||||
| const en = processAddForm.equipmentName.trim(); | |||||
| if ((ed && !en) || (!ed && en)) { | |||||
| setEditError("設備描述與名稱需同時選取,或同時留空(不適用)"); | |||||
| return; | |||||
| } | |||||
| if (ed && en) { | |||||
| const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); | |||||
| if (!resolved) { | |||||
| setEditError( | |||||
| `設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`, | |||||
| ); | |||||
| return; | |||||
| } | |||||
| } | |||||
| setEditProcesses((prev) => [ | setEditProcesses((prev) => [ | ||||
| ...prev, | ...prev, | ||||
| { | { | ||||
| @@ -362,7 +451,8 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| processCode: pCode, | processCode: pCode, | ||||
| processName: "", | processName: "", | ||||
| description: processAddForm.description ?? "", | description: processAddForm.description ?? "", | ||||
| equipmentCode: processAddForm.equipmentCode.trim(), | |||||
| equipmentDescription: ed, | |||||
| equipmentName: en, | |||||
| durationInMinute: processAddForm.durationInMinute ?? 0, | durationInMinute: processAddForm.durationInMinute ?? 0, | ||||
| prepTimeInMinute: processAddForm.prepTimeInMinute ?? 0, | prepTimeInMinute: processAddForm.prepTimeInMinute ?? 0, | ||||
| postProdTimeInMinute: processAddForm.postProdTimeInMinute ?? 0, | postProdTimeInMinute: processAddForm.postProdTimeInMinute ?? 0, | ||||
| @@ -371,14 +461,15 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| setProcessAddForm({ | setProcessAddForm({ | ||||
| processCode: "", | processCode: "", | ||||
| equipmentCode: "", | |||||
| equipmentDescription: "", | |||||
| equipmentName: "", | |||||
| description: "", | description: "", | ||||
| durationInMinute: 0, | durationInMinute: 0, | ||||
| prepTimeInMinute: 0, | prepTimeInMinute: 0, | ||||
| postProdTimeInMinute: 0, | postProdTimeInMinute: 0, | ||||
| }); | }); | ||||
| setEditError(null); | setEditError(null); | ||||
| }, [processAddForm]); | |||||
| }, [processAddForm, equipmentMasterList]); | |||||
| const deleteMaterialRow = useCallback((key: string) => { | const deleteMaterialRow = useCallback((key: string) => { | ||||
| setEditMaterials((prev) => prev.filter((r) => r.key !== key)); | setEditMaterials((prev) => prev.filter((r) => r.key !== key)); | ||||
| @@ -398,6 +489,19 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| if (!p.processCode?.trim()) { | if (!p.processCode?.trim()) { | ||||
| throw new Error("工序行 Process Code 不能为空"); | throw new Error("工序行 Process Code 不能为空"); | ||||
| } | } | ||||
| const ed = p.equipmentDescription.trim(); | |||||
| const en = p.equipmentName.trim(); | |||||
| if ((ed && !en) || (!ed && en)) { | |||||
| throw new Error("各製程行的設備描述與名稱需同時填寫或同時留空"); | |||||
| } | |||||
| if (ed && en) { | |||||
| const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); | |||||
| if (!resolved) { | |||||
| throw new Error( | |||||
| `設備「${ed}-${en}」在主檔中無對應設備代碼,請修正後再儲存`, | |||||
| ); | |||||
| } | |||||
| } | |||||
| } | } | ||||
| const payload: any = { | const payload: any = { | ||||
| @@ -413,16 +517,24 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| timeSequence: editBasic.timeSequence, | timeSequence: editBasic.timeSequence, | ||||
| complexity: editBasic.complexity, | complexity: editBasic.complexity, | ||||
| isDrink: editBasic.isDrink, | isDrink: editBasic.isDrink, | ||||
| processes: editProcesses.map((p) => ({ | |||||
| id: p.id, | |||||
| seqNo: p.seqNo, | |||||
| processCode: p.processCode?.trim() || undefined, | |||||
| equipmentCode: p.equipmentCode?.trim() || undefined, | |||||
| description: p.description || undefined, | |||||
| durationInMinute: p.durationInMinute, | |||||
| prepTimeInMinute: p.prepTimeInMinute, | |||||
| postProdTimeInMinute: p.postProdTimeInMinute, | |||||
| })), | |||||
| processes: editProcesses.map((p) => { | |||||
| const ed = p.equipmentDescription.trim(); | |||||
| const en = p.equipmentName.trim(); | |||||
| const equipmentCode = | |||||
| ed && en | |||||
| ? resolveEquipmentCode(equipmentMasterList, ed, en) ?? undefined | |||||
| : undefined; | |||||
| return { | |||||
| id: p.id, | |||||
| seqNo: p.seqNo, | |||||
| processCode: p.processCode?.trim() || undefined, | |||||
| equipmentCode, | |||||
| description: p.description || undefined, | |||||
| durationInMinute: p.durationInMinute, | |||||
| prepTimeInMinute: p.prepTimeInMinute, | |||||
| postProdTimeInMinute: p.postProdTimeInMinute, | |||||
| }; | |||||
| }), | |||||
| }; | }; | ||||
| const updated = await editBomClient(detail.id, payload); | const updated = await editBomClient(detail.id, payload); | ||||
| @@ -433,7 +545,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| } finally { | } finally { | ||||
| setEditLoading(false); | setEditLoading(false); | ||||
| } | } | ||||
| }, [detail, editBasic, editProcesses]); | |||||
| }, [detail, editBasic, editProcesses, equipmentMasterList]); | |||||
| return ( | return ( | ||||
| <Stack spacing={2}> | <Stack spacing={2}> | ||||
| @@ -480,11 +592,18 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| {!isEditing ? ( | {!isEditing ? ( | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| startIcon={<EditIcon />} | |||||
| startIcon={ | |||||
| editMasterLoading ? ( | |||||
| <CircularProgress size={16} /> | |||||
| ) : ( | |||||
| <EditIcon /> | |||||
| ) | |||||
| } | |||||
| variant="outlined" | variant="outlined" | ||||
| onClick={startEdit} | |||||
| onClick={() => void startEdit()} | |||||
| disabled={editMasterLoading} | |||||
| > | > | ||||
| {t("Edit")} | |||||
| {editMasterLoading ? t("Loading...") : t("Edit")} | |||||
| </Button> | </Button> | ||||
| ) : ( | ) : ( | ||||
| <Stack direction="row" spacing={1}> | <Stack direction="row" spacing={1}> | ||||
| @@ -770,6 +889,9 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| })) | })) | ||||
| } | } | ||||
| > | > | ||||
| <MenuItem value=""> | |||||
| <em>請選擇</em> | |||||
| </MenuItem> | |||||
| {processCodeOptions.map((c) => ( | {processCodeOptions.map((c) => ( | ||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| {c} | {c} | ||||
| @@ -779,19 +901,40 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| </FormControl> | </FormControl> | ||||
| <FormControl size="small" sx={{ minWidth: 200 }}> | <FormControl size="small" sx={{ minWidth: 200 }}> | ||||
| <InputLabel>{t("Equipment Code")}</InputLabel> | |||||
| <InputLabel>設備說明</InputLabel> | |||||
| <Select | |||||
| label="設備說明" | |||||
| value={processAddForm.equipmentDescription} | |||||
| onChange={(e) => | |||||
| setProcessAddForm((p) => ({ | |||||
| ...p, | |||||
| equipmentDescription: String(e.target.value), | |||||
| })) | |||||
| } | |||||
| > | |||||
| <MenuItem value="">不適用</MenuItem> | |||||
| {equipmentDescriptionOptions.map((c) => ( | |||||
| <MenuItem key={c} value={c}> | |||||
| {c} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| <FormControl size="small" sx={{ minWidth: 200 }}> | |||||
| <InputLabel>設備名稱</InputLabel> | |||||
| <Select | <Select | ||||
| label={t("Equipment Code")} | |||||
| value={processAddForm.equipmentCode} | |||||
| label="設備名稱" | |||||
| value={processAddForm.equipmentName} | |||||
| onChange={(e) => | onChange={(e) => | ||||
| setProcessAddForm((p) => ({ | setProcessAddForm((p) => ({ | ||||
| ...p, | ...p, | ||||
| equipmentCode: String(e.target.value), | |||||
| equipmentName: String(e.target.value), | |||||
| })) | })) | ||||
| } | } | ||||
| > | > | ||||
| <MenuItem value="">不適用</MenuItem> | <MenuItem value="">不適用</MenuItem> | ||||
| {equipmentCodeOptions.map((c) => ( | |||||
| {equipmentNameOptions.map((c) => ( | |||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| {c} | {c} | ||||
| </MenuItem> | </MenuItem> | ||||
| @@ -866,7 +1009,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| <TableCell> {t("Process Name")}</TableCell> | <TableCell> {t("Process Name")}</TableCell> | ||||
| <TableCell> {t("Process Description")}</TableCell> | <TableCell> {t("Process Description")}</TableCell> | ||||
| <TableCell> {t("Process Code")}</TableCell> | <TableCell> {t("Process Code")}</TableCell> | ||||
| <TableCell> {t("Equipment Code")}</TableCell> | |||||
| <TableCell>設備(說明/名稱)</TableCell> | |||||
| <TableCell align="right"> {t("Duration (Minutes)")}</TableCell> | <TableCell align="right"> {t("Duration (Minutes)")}</TableCell> | ||||
| <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | ||||
| <TableCell align="right"> {t("Post Prod Time (Minutes)")}</TableCell> | <TableCell align="right"> {t("Post Prod Time (Minutes)")}</TableCell> | ||||
| @@ -923,30 +1066,60 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| </FormControl> | </FormControl> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <FormControl size="small" fullWidth> | |||||
| <Select | |||||
| value={p.equipmentCode ?? ""} | |||||
| onChange={(e) => | |||||
| setEditProcesses((prev) => | |||||
| prev.map((x) => | |||||
| x.key === p.key | |||||
| ? { | |||||
| ...x, | |||||
| equipmentCode: String(e.target.value), | |||||
| } | |||||
| : x, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| > | |||||
| <MenuItem value="">不適用</MenuItem> | |||||
| {equipmentCodeOptions.map((c) => ( | |||||
| <MenuItem key={c} value={c}> | |||||
| {c} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| <Stack direction="row" spacing={0.5} flexWrap="wrap"> | |||||
| <FormControl size="small" sx={{ minWidth: 140 }}> | |||||
| <Select | |||||
| displayEmpty | |||||
| value={p.equipmentDescription} | |||||
| onChange={(e) => | |||||
| setEditProcesses((prev) => | |||||
| prev.map((x) => | |||||
| x.key === p.key | |||||
| ? { | |||||
| ...x, | |||||
| equipmentDescription: String( | |||||
| e.target.value, | |||||
| ), | |||||
| } | |||||
| : x, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| > | |||||
| <MenuItem value="">不適用</MenuItem> | |||||
| {equipmentDescriptionOptions.map((c) => ( | |||||
| <MenuItem key={c} value={c}> | |||||
| {c} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| <FormControl size="small" sx={{ minWidth: 140 }}> | |||||
| <Select | |||||
| displayEmpty | |||||
| value={p.equipmentName} | |||||
| onChange={(e) => | |||||
| setEditProcesses((prev) => | |||||
| prev.map((x) => | |||||
| x.key === p.key | |||||
| ? { | |||||
| ...x, | |||||
| equipmentName: String(e.target.value), | |||||
| } | |||||
| : x, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| > | |||||
| <MenuItem value="">不適用</MenuItem> | |||||
| {equipmentNameOptions.map((c) => ( | |||||
| <MenuItem key={c} value={c}> | |||||
| {c} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Stack> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <TextField | <TextField | ||||
| @@ -464,6 +464,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | ||||
| // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) | // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) | ||||
| const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | ||||
| const [localSolStatusById, setLocalSolStatusById] = useState<Record<number, string>>({}); | |||||
| // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 | // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 | ||||
| const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | ||||
| @@ -646,20 +647,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| // 前端覆盖:issue form/submit0 不会立刻改写后端 qty 时,用本地缓存让 UI 与 batch submit 计算一致 | // 前端覆盖:issue form/submit0 不会立刻改写后端 qty 时,用本地缓存让 UI 与 batch submit 计算一致 | ||||
| return lots.map((lot: any) => { | return lots.map((lot: any) => { | ||||
| const solId = Number(lot.stockOutLineId) || 0; | const solId = Number(lot.stockOutLineId) || 0; | ||||
| if (solId > 0 && Object.prototype.hasOwnProperty.call(issuePickedQtyBySolId, solId)) { | |||||
| const picked = Number(issuePickedQtyBySolId[solId] ?? 0); | |||||
| const status = String(lot.stockOutLineStatus || '').toLowerCase(); | |||||
| if (solId > 0) { | |||||
| const hasPickedOverride = Object.prototype.hasOwnProperty.call(issuePickedQtyBySolId, solId); | |||||
| const picked = Number(issuePickedQtyBySolId[solId] ?? lot.actualPickQty ?? 0); | |||||
| const statusRaw = localSolStatusById[solId] ?? lot.stockOutLineStatus ?? ""; | |||||
| const status = String(statusRaw).toLowerCase(); | |||||
| const isEnded = status === 'completed' || status === 'rejected'; | const isEnded = status === 'completed' || status === 'rejected'; | ||||
| return { | return { | ||||
| ...lot, | ...lot, | ||||
| actualPickQty: picked, | |||||
| stockOutLineQty: picked, | |||||
| stockOutLineStatus: isEnded ? lot.stockOutLineStatus : 'checked', | |||||
| actualPickQty: hasPickedOverride ? picked : lot.actualPickQty, | |||||
| stockOutLineQty: hasPickedOverride ? picked : lot.stockOutLineQty, | |||||
| stockOutLineStatus: isEnded ? statusRaw : (statusRaw || "checked"), | |||||
| }; | }; | ||||
| } | } | ||||
| return lot; | return lot; | ||||
| }); | }); | ||||
| }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId]); | |||||
| }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId, localSolStatusById]); | |||||
| const originalCombinedData = useMemo(() => { | const originalCombinedData = useMemo(() => { | ||||
| return getAllLotsFromHierarchical(jobOrderData); | return getAllLotsFromHierarchical(jobOrderData); | ||||
| @@ -1802,6 +1805,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| console.error("No stock out line found for this lot"); | console.error("No stock out line found for this lot"); | ||||
| return; | return; | ||||
| } | } | ||||
| const solId = Number(lot.stockOutLineId) || 0; | |||||
| try { | try { | ||||
| if (currentUserId && lot.pickOrderId && lot.itemId) { | if (currentUserId && lot.pickOrderId && lot.itemId) { | ||||
| @@ -1842,13 +1846,13 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| // 记录该 SOL 的“目标实际拣货量=0”,让 batch submit 走 onlyComplete(不补拣到 required) | // 记录该 SOL 的“目标实际拣货量=0”,让 batch submit 走 onlyComplete(不补拣到 required) | ||||
| const solId = Number(lot.stockOutLineId) || 0; | |||||
| if (solId > 0) { | if (solId > 0) { | ||||
| setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: 0 })); | setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: 0 })); | ||||
| setLocalSolStatusById(prev => ({ ...prev, [solId]: 'checked' })); | |||||
| } | } | ||||
| const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | ||||
| await fetchJobOrderData(pickOrderId); | |||||
| void fetchJobOrderData(pickOrderId); | |||||
| console.log("All zeros submission marked as checked successfully (waiting for batch submit)."); | console.log("All zeros submission marked as checked successfully (waiting for batch submit)."); | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| @@ -1887,6 +1891,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| status: newStatus, | status: newStatus, | ||||
| qty: cumulativeQty | qty: cumulativeQty | ||||
| }); | }); | ||||
| if (solId > 0) { | |||||
| setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: cumulativeQty })); | |||||
| setLocalSolStatusById(prev => ({ ...prev, [solId]: newStatus })); | |||||
| } | |||||
| if (submitQty > 0) { | if (submitQty > 0) { | ||||
| await updateInventoryLotLineQuantities({ | await updateInventoryLotLineQuantities({ | ||||
| @@ -1923,7 +1931,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| } | } | ||||
| const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | ||||
| await fetchJobOrderData(pickOrderId); | |||||
| void fetchJobOrderData(pickOrderId); | |||||
| console.log("Pick quantity submitted successfully!"); | console.log("Pick quantity submitted successfully!"); | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| @@ -1936,15 +1944,34 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| }, [fetchJobOrderData, checkAndAutoAssignNext, filterArgs]); | }, [fetchJobOrderData, checkAndAutoAssignNext, filterArgs]); | ||||
| const handleSkip = useCallback(async (lot: any) => { | const handleSkip = useCallback(async (lot: any) => { | ||||
| try { | try { | ||||
| console.log("Skip clicked, submit 0 qty for lot:", lot.lotNo); | |||||
| console.log("Just Complete clicked, mark checked with 0 qty for lot:", lot.lotNo); | |||||
| await handleSubmitPickQtyWithQty(lot, 0); | await handleSubmitPickQtyWithQty(lot, 0); | ||||
| } catch (err) { | } catch (err) { | ||||
| console.error("Error in Skip:", err); | console.error("Error in Skip:", err); | ||||
| } | } | ||||
| }, [handleSubmitPickQtyWithQty]); | }, [handleSubmitPickQtyWithQty]); | ||||
| const hasPendingBatchSubmit = useMemo(() => { | |||||
| return combinedLotData.some((lot) => { | |||||
| const status = String(lot.stockOutLineStatus || "").toLowerCase(); | |||||
| return status === "checked" || status === "pending" || status === "partially_completed" || status === "partially_complete"; | |||||
| }); | |||||
| }, [combinedLotData]); | |||||
| useEffect(() => { | |||||
| if (!hasPendingBatchSubmit) return; | |||||
| const handler = (event: BeforeUnloadEvent) => { | |||||
| event.preventDefault(); | |||||
| event.returnValue = ""; | |||||
| }; | |||||
| window.addEventListener("beforeunload", handler); | |||||
| return () => window.removeEventListener("beforeunload", handler); | |||||
| }, [hasPendingBatchSubmit]); | |||||
| const handleSubmitAllScanned = useCallback(async () => { | const handleSubmitAllScanned = useCallback(async () => { | ||||
| const scannedLots = combinedLotData.filter(lot => { | const scannedLots = combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| console.log("lot.noLot:", lot.noLot); | console.log("lot.noLot:", lot.noLot); | ||||
| console.log("lot.status:", lot.stockOutLineStatus); | console.log("lot.status:", lot.stockOutLineStatus); | ||||
| // ✅ no-lot:允許 pending / checked / partially_completed / PARTIALLY_COMPLETE | // ✅ no-lot:允許 pending / checked / partially_completed / PARTIALLY_COMPLETE | ||||
| @@ -2093,6 +2120,10 @@ if (onlyComplete) { | |||||
| const scannedItemsCount = useMemo(() => { | const scannedItemsCount = useMemo(() => { | ||||
| return combinedLotData.filter(lot => { | return combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| const isNoLot = lot.noLot === true || !lot.lotId; | const isNoLot = lot.noLot === true || !lot.lotId; | ||||
| if (isNoLot) { | if (isNoLot) { | ||||
| @@ -2722,7 +2753,7 @@ const sortedData = [...sourceData].sort((a, b) => { | |||||
| console.error("❌ Error updating handler (non-critical):", error); | console.error("❌ Error updating handler (non-critical):", error); | ||||
| } | } | ||||
| } | } | ||||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty || lot.pickOrderLineRequiredQty || 0); | |||||
| await handleSubmitPickQtyWithQty(lot, 0); | |||||
| } finally { | } finally { | ||||
| if (solId > 0) { | if (solId > 0) { | ||||
| setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | ||||
| @@ -2732,6 +2763,7 @@ const sortedData = [...sourceData].sort((a, b) => { | |||||
| disabled={ | disabled={ | ||||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) || | (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) || | ||||
| lot.stockOutLineStatus === 'completed' || | lot.stockOutLineStatus === 'completed' || | ||||
| lot.stockOutLineStatus === 'checked' || | |||||
| lot.noLot === true || | lot.noLot === true || | ||||
| !lot.lotId || | !lot.lotId || | ||||
| (Number(lot.stockOutLineId) > 0 && | (Number(lot.stockOutLineId) > 0 && | ||||
| @@ -0,0 +1,431 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useEffect, useState } from "react"; | |||||
| import { | |||||
| Alert, | |||||
| Box, | |||||
| Button, | |||||
| CircularProgress, | |||||
| Dialog, | |||||
| DialogActions, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| Paper, | |||||
| Snackbar, | |||||
| Stack, | |||||
| TextField, | |||||
| Typography, | |||||
| } 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 { | |||||
| checkPrinterStatus, | |||||
| fetchLaserJobOrders, | |||||
| fetchLaserBag2Settings, | |||||
| JobOrderListItem, | |||||
| patchSetting, | |||||
| sendLaserBag2Job, | |||||
| } from "@/app/api/laserPrint/actions"; | |||||
| import dayjs from "dayjs"; | |||||
| 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 REFRESH_MS = 60 * 1000; | |||||
| const PRINTER_CHECK_MS = 60 * 1000; | |||||
| const PRINTER_RETRY_MS = 30 * 1000; | |||||
| const LASER_SEND_COUNT = 3; | |||||
| const BETWEEN_SEND_MS = 3000; | |||||
| const SUCCESS_SIGNAL_MS = 3500; | |||||
| 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() || "—"; | |||||
| } | |||||
| function delay(ms: number): Promise<void> { | |||||
| return new Promise((resolve) => setTimeout(resolve, ms)); | |||||
| } | |||||
| const LaserPrintSearch: 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 [selectedId, setSelectedId] = useState<number | null>(null); | |||||
| const [sendingJobId, setSendingJobId] = useState<number | null>(null); | |||||
| const [settingsOpen, setSettingsOpen] = useState(false); | |||||
| const [errorSnackbar, setErrorSnackbar] = useState<{ open: boolean; message: string }>({ | |||||
| open: false, | |||||
| message: "", | |||||
| }); | |||||
| const [successSignal, setSuccessSignal] = useState<string | null>(null); | |||||
| const [laserHost, setLaserHost] = useState("192.168.18.77"); | |||||
| const [laserPort, setLaserPort] = useState("45678"); | |||||
| const [laserItemCodes, setLaserItemCodes] = useState("PP1175"); | |||||
| const [settingsLoaded, setSettingsLoaded] = useState(false); | |||||
| const [printerConnected, setPrinterConnected] = useState(false); | |||||
| const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); | |||||
| const loadSystemSettings = useCallback(async () => { | |||||
| try { | |||||
| const s = await fetchLaserBag2Settings(); | |||||
| setLaserHost(s.host); | |||||
| setLaserPort(String(s.port)); | |||||
| setLaserItemCodes(s.itemCodes ?? "PP1175"); | |||||
| setSettingsLoaded(true); | |||||
| } catch (e) { | |||||
| setErrorSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "無法載入系統設定", | |||||
| }); | |||||
| setSettingsLoaded(true); | |||||
| } | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| void loadSystemSettings(); | |||||
| }, [loadSystemSettings]); | |||||
| useEffect(() => { | |||||
| if (!successSignal) return; | |||||
| const t = setTimeout(() => setSuccessSignal(null), SUCCESS_SIGNAL_MS); | |||||
| return () => clearTimeout(t); | |||||
| }, [successSignal]); | |||||
| const loadJobOrders = useCallback( | |||||
| async (fromUserChange = false) => { | |||||
| setLoading(true); | |||||
| setError(null); | |||||
| try { | |||||
| const data = await fetchLaserJobOrders(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(() => { | |||||
| void loadJobOrders(true); | |||||
| }, [planDate]); | |||||
| useEffect(() => { | |||||
| if (!connected) return; | |||||
| const id = setInterval(() => void loadJobOrders(false), REFRESH_MS); | |||||
| return () => clearInterval(id); | |||||
| }, [connected, loadJobOrders]); | |||||
| const checkLaser = useCallback(async () => { | |||||
| const portNum = Number(laserPort || 45678); | |||||
| try { | |||||
| const result = await checkPrinterStatus({ | |||||
| printerType: "laser", | |||||
| printerIp: laserHost.trim(), | |||||
| printerPort: Number.isFinite(portNum) ? portNum : 45678, | |||||
| }); | |||||
| setPrinterConnected(result.connected); | |||||
| setPrinterMessage(result.message); | |||||
| } catch (e) { | |||||
| setPrinterConnected(false); | |||||
| setPrinterMessage(e instanceof Error ? e.message : "檸檬機(激光機)狀態檢查失敗"); | |||||
| } | |||||
| }, [laserHost, laserPort]); | |||||
| useEffect(() => { | |||||
| if (!settingsLoaded) return; | |||||
| void checkLaser(); | |||||
| }, [settingsLoaded, checkLaser]); | |||||
| useEffect(() => { | |||||
| if (!settingsLoaded) return; | |||||
| const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS; | |||||
| const id = setInterval(() => { | |||||
| void checkLaser(); | |||||
| }, intervalMs); | |||||
| return () => clearInterval(id); | |||||
| }, [printerConnected, checkLaser, settingsLoaded]); | |||||
| 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 sendOne = (jo: JobOrderListItem) => | |||||
| sendLaserBag2Job({ | |||||
| itemId: jo.itemId, | |||||
| stockInLineId: jo.stockInLineId, | |||||
| itemCode: jo.itemCode, | |||||
| itemName: jo.itemName, | |||||
| }); | |||||
| const handleRowClick = async (jo: JobOrderListItem) => { | |||||
| if (sendingJobId !== null) return; | |||||
| if (!laserHost.trim()) { | |||||
| setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" }); | |||||
| return; | |||||
| } | |||||
| setSelectedId(jo.id); | |||||
| setSendingJobId(jo.id); | |||||
| try { | |||||
| for (let i = 0; i < LASER_SEND_COUNT; i++) { | |||||
| const r = await sendOne(jo); | |||||
| if (!r.success) { | |||||
| setErrorSnackbar({ | |||||
| open: true, | |||||
| message: r.message || "檸檬機(激光機)未收到指令", | |||||
| }); | |||||
| return; | |||||
| } | |||||
| if (i < LASER_SEND_COUNT - 1) { | |||||
| await delay(BETWEEN_SEND_MS); | |||||
| } | |||||
| } | |||||
| setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)`); | |||||
| } catch (e) { | |||||
| setErrorSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "送出失敗", | |||||
| }); | |||||
| } finally { | |||||
| setSendingJobId(null); | |||||
| } | |||||
| }; | |||||
| const saveSettings = async () => { | |||||
| try { | |||||
| await patchSetting("LASER_PRINT.host", laserHost.trim()); | |||||
| await patchSetting("LASER_PRINT.port", laserPort.trim() || "45678"); | |||||
| await patchSetting("LASER_PRINT.itemCodes", laserItemCodes.trim()); | |||||
| setSuccessSignal("設定已儲存"); | |||||
| setSettingsOpen(false); | |||||
| void checkLaser(); | |||||
| await loadSystemSettings(); | |||||
| void loadJobOrders(false); | |||||
| } catch (e) { | |||||
| setErrorSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "儲存失敗", | |||||
| }); | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | |||||
| {successSignal && ( | |||||
| <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessSignal(null)}> | |||||
| {successSignal} | |||||
| </Alert> | |||||
| )} | |||||
| <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} disabled={sendingJobId !== null}> | |||||
| 前一天 | |||||
| </Button> | |||||
| <TextField | |||||
| type="date" | |||||
| value={planDate} | |||||
| onChange={(e) => setPlanDate(e.target.value)} | |||||
| size="small" | |||||
| sx={{ width: 160 }} | |||||
| InputLabelProps={{ shrink: true }} | |||||
| disabled={sendingJobId !== null} | |||||
| /> | |||||
| <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay} disabled={sendingJobId !== null}> | |||||
| 後一天 | |||||
| </Button> | |||||
| </Stack> | |||||
| <Stack direction="row" alignItems="center" spacing={2}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Settings />} | |||||
| onClick={() => setSettingsOpen(true)} | |||||
| disabled={sendingJobId !== null} | |||||
| > | |||||
| 設定(系統) | |||||
| </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> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </Paper> | |||||
| <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> | |||||
| ) : error ? ( | |||||
| <Box sx={{ py: 8, textAlign: "center" }}> | |||||
| <Typography color="error">{error}</Typography> | |||||
| </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; | |||||
| const isSending = sendingJobId === jo.id; | |||||
| return ( | |||||
| <Paper | |||||
| key={jo.id} | |||||
| elevation={1} | |||||
| sx={{ | |||||
| p: 2, | |||||
| display: "flex", | |||||
| alignItems: "flex-start", | |||||
| gap: 2, | |||||
| cursor: sendingJobId !== null ? "wait" : "pointer", | |||||
| backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW, | |||||
| "&:hover": { | |||||
| backgroundColor: | |||||
| sendingJobId !== null | |||||
| ? isSelected | |||||
| ? BG_ROW_SELECTED | |||||
| : BG_ROW | |||||
| : isSelected | |||||
| ? BG_ROW_SELECTED | |||||
| : "#b8d4eb", | |||||
| }, | |||||
| transition: "background-color 0.2s", | |||||
| opacity: sendingJobId !== null && !isSending ? 0.65 : 1, | |||||
| }} | |||||
| onClick={() => void 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> | |||||
| {isSending && <CircularProgress size={28} sx={{ alignSelf: "center", flexShrink: 0 }} />} | |||||
| </Paper> | |||||
| ); | |||||
| })} | |||||
| </Stack> | |||||
| </Box> | |||||
| )} | |||||
| </Paper> | |||||
| <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | |||||
| <DialogTitle>檸檬機(激光機)(系統設定)</DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 儲存後寫入資料庫,後端送出走此 IP/埠(預設 192.168.18.77:45678)。 | |||||
| </Typography> | |||||
| <TextField | |||||
| label="IP" | |||||
| size="small" | |||||
| value={laserHost} | |||||
| onChange={(e) => setLaserHost(e.target.value)} | |||||
| fullWidth | |||||
| /> | |||||
| <TextField | |||||
| label="Port" | |||||
| size="small" | |||||
| value={laserPort} | |||||
| onChange={(e) => setLaserPort(e.target.value)} | |||||
| fullWidth | |||||
| /> | |||||
| <TextField | |||||
| label="列表品號(逗號分隔)" | |||||
| size="small" | |||||
| value={laserItemCodes} | |||||
| onChange={(e) => setLaserItemCodes(e.target.value)} | |||||
| fullWidth | |||||
| placeholder="PP1175" | |||||
| helperText="預設 PP1175;可輸入多個品號,例如 PP1175,AB999。留空則列表顯示當日全部包裝工單。" | |||||
| /> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setSettingsOpen(false)}>取消</Button> | |||||
| <Button variant="contained" onClick={() => void saveSettings()}> | |||||
| 儲存 | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Snackbar | |||||
| open={errorSnackbar.open} | |||||
| autoHideDuration={6000} | |||||
| onClose={() => setErrorSnackbar((s) => ({ ...s, open: false }))} | |||||
| message={errorSnackbar.message} | |||||
| anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | |||||
| /> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default LaserPrintSearch; | |||||
| @@ -180,6 +180,13 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | ||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | |||||
| icon: <Print />, | |||||
| label: "檸檬機(激光機)", | |||||
| path: "/laserPrint", | |||||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | |||||
| isHidden: false, | |||||
| }, | |||||
| { | { | ||||
| icon: <Assessment />, | icon: <Assessment />, | ||||
| label: "報告管理", | label: "報告管理", | ||||
| @@ -497,8 +497,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| {/* Target Date - 只在第一个项目显示 */} | {/* Target Date - 只在第一个项目显示 */} | ||||
| <TableCell> | <TableCell> | ||||
| {index === 0 ? ( | {index === 0 ? ( | ||||
| arrayToDayjs(item.targetDate) | |||||
| .add(-1, "month") | |||||
| arrayToDayjs(item.targetDate) | |||||
| .format(OUTPUT_DATE_FORMAT) | .format(OUTPUT_DATE_FORMAT) | ||||
| ) : null} | ) : null} | ||||
| </TableCell> | </TableCell> | ||||
| @@ -397,8 +397,8 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const calculateRemainingRequiredQty = useCallback((lot: LotPickData) => { | const calculateRemainingRequiredQty = useCallback((lot: LotPickData) => { | ||||
| const requiredQty = lot.requiredQty || 0; | const requiredQty = lot.requiredQty || 0; | ||||
| const stockOutLineQty = lot.stockOutLineQty || 0; | |||||
| return Math.max(0, requiredQty - stockOutLineQty); | |||||
| const availableQty = lot.availableQty || 0; | |||||
| return Math.max(0, requiredQty + availableQty); | |||||
| }, []); | }, []); | ||||
| // Add QR scanner context | // Add QR scanner context | ||||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | ||||
| @@ -506,7 +506,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| const stockOutLineUpdate = await updateStockOutLineStatus({ | const stockOutLineUpdate = await updateStockOutLineStatus({ | ||||
| id: selectedLotForQr.stockOutLineId, | id: selectedLotForQr.stockOutLineId, | ||||
| status: 'checked', | status: 'checked', | ||||
| qty: selectedLotForQr.stockOutLineQty || 0 | |||||
| qty: 0 | |||||
| }); | }); | ||||
| console.log(" Stock out line updated to 'checked':", stockOutLineUpdate); | console.log(" Stock out line updated to 'checked':", stockOutLineUpdate); | ||||
| @@ -361,13 +361,9 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| try { | try { | ||||
| // FIXED: 计算累计拣货数量 | // FIXED: 计算累计拣货数量 | ||||
| const totalPickedForThisLot = (selectedLot.actualPickQty || 0) + qty; | const totalPickedForThisLot = (selectedLot.actualPickQty || 0) + qty; | ||||
| console.log(" DEBUG - Previous picked:", selectedLot.actualPickQty || 0); | |||||
| console.log(" DEBUG - Current submit:", qty); | |||||
| console.log(" DEBUG - Total picked:", totalPickedForThisLot); | |||||
| console.log("�� DEBUG - Required qty:", selectedLot.requiredQty); | |||||
| // FIXED: 状态应该基于累计拣货数量 | // FIXED: 状态应该基于累计拣货数量 | ||||
| let newStatus = 'partially_completed'; | |||||
| let newStatus = 'completed'; | |||||
| if (totalPickedForThisLot >= selectedLot.requiredQty) { | if (totalPickedForThisLot >= selectedLot.requiredQty) { | ||||
| newStatus = 'completed'; | newStatus = 'completed'; | ||||
| } | } | ||||
| @@ -388,16 +384,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| return; | return; | ||||
| } | } | ||||
| if (qty > 0) { | |||||
| const inventoryLotLineUpdate = await updateInventoryLotLineQuantities({ | |||||
| inventoryLotLineId: lotId, | |||||
| qty: qty, | |||||
| status: 'available', | |||||
| operation: 'pick' | |||||
| }); | |||||
| console.log("Inventory lot line updated:", inventoryLotLineUpdate); | |||||
| } | |||||
| // RE-ENABLE: Check if pick order should be completed | // RE-ENABLE: Check if pick order should be completed | ||||
| if (newStatus === 'completed') { | if (newStatus === 'completed') { | ||||
| @@ -32,6 +32,7 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | ||||
| import { AUTH } from "@/authorities"; | |||||
| import { | import { | ||||
| @@ -103,6 +104,9 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const [openModal, setOpenModal] = useState<boolean>(false); | const [openModal, setOpenModal] = useState<boolean>(false); | ||||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||||
| // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作 | |||||
| const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN); | |||||
| type ProcessFilter = "all" | "drink" | "other"; | type ProcessFilter = "all" | "drink" | "other"; | ||||
| const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null); | const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null); | ||||
| @@ -275,6 +279,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| fetchProcesses(); | fetchProcesses(); | ||||
| }, [fetchProcesses]); | }, [fetchProcesses]); | ||||
| const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { | const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { | ||||
| if (!canManageUpdateJo) return; | |||||
| if (!process.jobOrderId) { | if (!process.jobOrderId) { | ||||
| alert(t("Invalid Job Order Id")); | alert(t("Invalid Job Order Id")); | ||||
| return; | return; | ||||
| @@ -308,7 +313,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, [t, fetchProcesses]); | |||||
| }, [t, fetchProcesses, canManageUpdateJo]); | |||||
| const openConfirm = useCallback((message: string, action: () => Promise<void>) => { | const openConfirm = useCallback((message: string, action: () => Promise<void>) => { | ||||
| setConfirmMessage(message); | setConfirmMessage(message); | ||||
| @@ -590,13 +595,16 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| size="small" | size="small" | ||||
| disabled={!canManageUpdateJo} | |||||
| onClick={() => | onClick={() => | ||||
| openConfirm( | |||||
| t("Confirm to update this Job Order?"), | |||||
| async () => { | |||||
| await handleUpdateJo(process); | |||||
| } | |||||
| ) | |||||
| canManageUpdateJo | |||||
| ? openConfirm( | |||||
| t("Confirm to update this Job Order?"), | |||||
| async () => { | |||||
| await handleUpdateJo(process); | |||||
| } | |||||
| ) | |||||
| : undefined | |||||
| } | } | ||||
| > | > | ||||
| {t("Update Job Order")} | {t("Update Job Order")} | ||||
| @@ -317,12 +317,14 @@ useEffect(() => { | |||||
| try { | try { | ||||
| const parseStartTime = performance.now(); | const parseStartTime = performance.now(); | ||||
| const data: QrCodeInfo = JSON.parse(scannedValues); | |||||
| const normalizedScannedValues = scannedValues.replace(/\\"/g, '"'); | |||||
| const data: QrCodeInfo = JSON.parse(normalizedScannedValues); | |||||
| const parseTime = performance.now() - parseStartTime; | const parseTime = performance.now() - parseStartTime; | ||||
| // console.log(`%c Parsed scan data`, "color:green", data); | // console.log(`%c Parsed scan data`, "color:green", data); | ||||
| //console.log(`⏱️ [QR SCANNER PROCESS] JSON parse time: ${parseTime.toFixed(2)}ms`); | //console.log(`⏱️ [QR SCANNER PROCESS] JSON parse time: ${parseTime.toFixed(2)}ms`); | ||||
| const content = scannedValues.substring(1, scannedValues.length - 1); | |||||
| const content = normalizedScannedValues.substring(1, normalizedScannedValues.length - 1); | |||||
| data.value = content; | data.value = content; | ||||
| const setResultStartTime = performance.now(); | const setResultStartTime = performance.now(); | ||||
| @@ -31,6 +31,7 @@ type SearchQuery = { | |||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const SearchPage: React.FC<Props> = ({ dataList }) => { | const SearchPage: React.FC<Props> = ({ dataList }) => { | ||||
| const BATCH_CHUNK_SIZE = 20; | |||||
| const { t } = useTranslation("inventory"); | const { t } = useTranslation("inventory"); | ||||
| const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss"); | const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss"); | ||||
| const [search, setSearch] = useState<SearchQuery>({ lotNo: "" }); | const [search, setSearch] = useState<SearchQuery>({ lotNo: "" }); | ||||
| @@ -53,6 +54,7 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); | const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); | ||||
| const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | ||||
| const [batchSubmitting, setBatchSubmitting] = useState(false); | const [batchSubmitting, setBatchSubmitting] = useState(false); | ||||
| const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null); | |||||
| const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | ||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| @@ -113,7 +115,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| // setExpiryItems(prev => prev.filter(i => i.id !== id)); | // setExpiryItems(prev => prev.filter(i => i.id !== id)); | ||||
| window.location.reload(); | window.location.reload(); | ||||
| } catch (e) { | } catch (e) { | ||||
| alert(t("Failed to submit expiry item")); | |||||
| console.error("submitExpiryItem failed:", e); | |||||
| const errMsg = e instanceof Error ? e.message : t("Unknown error"); | |||||
| alert(`${t("Failed to submit expiry item")}: ${errMsg}`); | |||||
| } | } | ||||
| return; // 记得 return,避免再走到下面的 lotId/itemId 分支 | return; // 记得 return,避免再走到下面的 lotId/itemId 分支 | ||||
| } | } | ||||
| @@ -160,26 +164,40 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| if (allIds.length === 0) return; | if (allIds.length === 0) return; | ||||
| setBatchSubmitting(true); | setBatchSubmitting(true); | ||||
| setBatchProgress({ done: 0, total: allIds.length }); | |||||
| try { | try { | ||||
| if (tab === "miss") { | |||||
| await batchSubmitMissItem(allIds, currentUserId); | |||||
| setMissItems((prev) => prev.filter((i) => !allIds.includes(i.id))); | |||||
| } else if (tab === "bad") { | |||||
| await batchSubmitBadItem(allIds, currentUserId); | |||||
| setBadItems((prev) => prev.filter((i) => !allIds.includes(i.id))); | |||||
| } else { | |||||
| await batchSubmitExpiryItem(allIds, currentUserId); | |||||
| setExpiryItems((prev) => prev.filter((i) => !allIds.includes(i.id))); | |||||
| for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) { | |||||
| const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE); | |||||
| if (tab === "miss") { | |||||
| await batchSubmitMissItem(chunkIds, currentUserId); | |||||
| setMissItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||||
| } else if (tab === "bad") { | |||||
| await batchSubmitBadItem(chunkIds, currentUserId); | |||||
| setBadItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||||
| } else { | |||||
| await batchSubmitExpiryItem(chunkIds, currentUserId); | |||||
| setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||||
| } | |||||
| setBatchProgress({ | |||||
| done: Math.min(i + chunkIds.length, allIds.length), | |||||
| total: allIds.length, | |||||
| }); | |||||
| } | } | ||||
| setSelectedIds([]); | setSelectedIds([]); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to submit selected items:", error); | console.error("Failed to submit selected items:", error); | ||||
| alert(`Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`); | |||||
| const partialDone = batchProgress?.done ?? 0; | |||||
| alert( | |||||
| `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"} (${partialDone}/${allIds.length})` | |||||
| ); | |||||
| } finally { | } finally { | ||||
| setBatchSubmitting(false); | setBatchSubmitting(false); | ||||
| setBatchProgress(null); | |||||
| } | } | ||||
| }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch]); | |||||
| }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch, batchProgress, t]); | |||||
| const missColumns = useMemo<Column<StockIssueResult>[]>( | const missColumns = useMemo<Column<StockIssueResult>[]>( | ||||
| () => [ | () => [ | ||||
| @@ -375,7 +393,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| onClick={handleSubmitSelected} | onClick={handleSubmitSelected} | ||||
| disabled={batchSubmitting || !currentUserId} | disabled={batchSubmitting || !currentUserId} | ||||
| > | > | ||||
| {batchSubmitting ? t("Disposing...") : t("Batch Disposed All")} | |||||
| {batchSubmitting | |||||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||||
| : t("Batch Disposed All")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| )} | )} | ||||
| @@ -17,6 +17,7 @@ import { | |||||
| TextField, | TextField, | ||||
| Radio, | Radio, | ||||
| TablePagination, | TablePagination, | ||||
| TableSortLabel, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useCallback, useEffect, useMemo } from "react"; | import { useState, useCallback, useEffect, useMemo } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -45,6 +46,25 @@ interface ApproverStockTakeAllProps { | |||||
| type QtySelectionType = "first" | "second" | "approver"; | type QtySelectionType = "first" | "second" | "approver"; | ||||
| type ApprovedSortKey = | |||||
| | "stockTakeEndTime" | |||||
| | "stockTakeSection" | |||||
| | "item" | |||||
| | "stockTakerName" | |||||
| | "variance"; | |||||
| function parseDateTimeMs( | |||||
| v: string | string[] | null | undefined | |||||
| ): number { | |||||
| if (v == null) return 0; | |||||
| if (Array.isArray(v)) { | |||||
| const arr = v as unknown as number[]; | |||||
| const [y, m, d, h = 0, min = 0, s = 0] = arr; | |||||
| return dayjs(`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${h}:${min}:${s}`).valueOf(); | |||||
| } | |||||
| return dayjs(v as string).valueOf(); | |||||
| } | |||||
| const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | ||||
| selectedSession, | selectedSession, | ||||
| mode, | mode, | ||||
| @@ -66,6 +86,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| const [pageSize, setPageSize] = useState<number | string>(50); | const [pageSize, setPageSize] = useState<number | string>(50); | ||||
| const [total, setTotal] = useState(0); | const [total, setTotal] = useState(0); | ||||
| const [approvedSortKey, setApprovedSortKey] = useState<ApprovedSortKey | null>(null); | |||||
| const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc"); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| @@ -131,16 +153,76 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| loadDetails(page, pageSize); | loadDetails(page, pageSize); | ||||
| }, [page, pageSize, loadDetails]); | }, [page, pageSize, loadDetails]); | ||||
| // 切换模式时,清空用户先前的选择与输入,approved 模式需要以后端结果为准。 | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const newSelections: Record<number, QtySelectionType> = {}; | |||||
| inventoryLotDetails.forEach((detail) => { | |||||
| if (!qtySelection[detail.id]) { | |||||
| if (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0) { | |||||
| newSelections[detail.id] = "second"; | |||||
| } else { | |||||
| newSelections[detail.id] = "first"; | |||||
| setQtySelection({}); | |||||
| setApproverQty({}); | |||||
| setApproverBadQty({}); | |||||
| }, [mode, selectedSession.stockTakeId]); | |||||
| useEffect(() => { | |||||
| const inferSelection = ( | |||||
| detail: InventoryLotDetailResponse | |||||
| ): QtySelectionType => { | |||||
| // 优先使用后端记录的 lastSelect(1=First, 2=Second, 3=Approver Input) | |||||
| if (detail.lastSelect != null) { | |||||
| if (detail.lastSelect === 1) return "first"; | |||||
| if (detail.lastSelect === 2) return "second"; | |||||
| if (detail.lastSelect === 3) return "approver"; | |||||
| } | |||||
| // 目标:在 approved 模式下,即使后端把 approver 字段也回填了, | |||||
| // 只要 finalQty 来自 first/second(picker 结果),就优先勾选 first/second。 | |||||
| // 只有匹配不到 first/second 时,才推断为 approver。 | |||||
| if (detail.finalQty != null) { | |||||
| const eps = 1e-6; | |||||
| const firstAvailable = detail.firstStockTakeQty; | |||||
| const secondAvailable = detail.secondStockTakeQty; | |||||
| // 如果这一行确实有 approver 结果,那么 approved 时应该优先显示为 approver | |||||
| // (尤其是:picker first 后又手动改 approver input 的情况) | |||||
| if (detail.approverQty != null) { | |||||
| const approverAvailable = | |||||
| detail.approverQty - (detail.approverBadQty ?? 0); | |||||
| if (Math.abs(approverAvailable - detail.finalQty) <= eps) { | |||||
| return "approver"; | |||||
| } | |||||
| } | } | ||||
| if (secondAvailable != null && Math.abs(secondAvailable - detail.finalQty) <= eps) { | |||||
| return "second"; | |||||
| } | |||||
| if (firstAvailable != null && Math.abs(firstAvailable - detail.finalQty) <= eps) { | |||||
| return "first"; | |||||
| } | |||||
| // approver 字段口径可能是「available」或「total+bad」两种之一,这里同时尝试两种。 | |||||
| if (detail.approverQty != null) { | |||||
| const approverAvailable = detail.approverQty; | |||||
| const approverAvailable2 = | |||||
| detail.approverQty - (detail.approverBadQty ?? 0); | |||||
| if ( | |||||
| Math.abs(approverAvailable - detail.finalQty) <= eps || | |||||
| Math.abs(approverAvailable2 - detail.finalQty) <= eps | |||||
| ) { | |||||
| return "approver"; | |||||
| } | |||||
| } | |||||
| } | |||||
| // pending/无法反推时:second 存在则默认 second,否则 first | |||||
| if (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0) { | |||||
| return "second"; | |||||
| } | } | ||||
| return "first"; | |||||
| }; | |||||
| const newSelections: Record<number, QtySelectionType> = {}; | |||||
| inventoryLotDetails.forEach((detail) => { | |||||
| if (qtySelection[detail.id]) return; | |||||
| newSelections[detail.id] = inferSelection(detail); | |||||
| }); | }); | ||||
| if (Object.keys(newSelections).length > 0) { | if (Object.keys(newSelections).length > 0) { | ||||
| @@ -148,6 +230,33 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| } | } | ||||
| }, [inventoryLotDetails, qtySelection]); | }, [inventoryLotDetails, qtySelection]); | ||||
| // approved 模式下:把已保存的 approver 输入值回填到 TextField,避免“radio 显示了但输入框为空” | |||||
| useEffect(() => { | |||||
| if (mode !== "approved") return; | |||||
| const newApproverQty: Record<number, string> = {}; | |||||
| const newApproverBadQty: Record<number, string> = {}; | |||||
| inventoryLotDetails.forEach((detail) => { | |||||
| if (detail.approverQty != null && approverQty[detail.id] == null) { | |||||
| newApproverQty[detail.id] = String(detail.approverQty); | |||||
| } | |||||
| if ( | |||||
| detail.approverBadQty != null && | |||||
| approverBadQty[detail.id] == null | |||||
| ) { | |||||
| newApproverBadQty[detail.id] = String(detail.approverBadQty); | |||||
| } | |||||
| }); | |||||
| if (Object.keys(newApproverQty).length > 0) { | |||||
| setApproverQty((prev) => ({ ...prev, ...newApproverQty })); | |||||
| } | |||||
| if (Object.keys(newApproverBadQty).length > 0) { | |||||
| setApproverBadQty((prev) => ({ ...prev, ...newApproverBadQty })); | |||||
| } | |||||
| }, [mode, inventoryLotDetails, approverQty, approverBadQty]); | |||||
| const calculateDifference = useCallback( | const calculateDifference = useCallback( | ||||
| (detail: InventoryLotDetailResponse, selection: QtySelectionType): number => { | (detail: InventoryLotDetailResponse, selection: QtySelectionType): number => { | ||||
| let selectedQty = 0; | let selectedQty = 0; | ||||
| @@ -178,9 +287,13 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| } | } | ||||
| const selection = | const selection = | ||||
| qtySelection[detail.id] ?? | qtySelection[detail.id] ?? | ||||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 | |||||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0 | |||||
| ? "second" | ? "second" | ||||
| : "first"); | : "first"); | ||||
| // 避免 Approver 手动输入过程中被 variance 过滤掉,导致“输入后行消失无法提交” | |||||
| if (selection === "approver") { | |||||
| return true; | |||||
| } | |||||
| const difference = calculateDifference(detail, selection); | const difference = calculateDifference(detail, selection); | ||||
| const bookQty = | const bookQty = | ||||
| detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | ||||
| @@ -195,6 +308,64 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| calculateDifference, | calculateDifference, | ||||
| ]); | ]); | ||||
| const sortedDetails = useMemo(() => { | |||||
| const list = [...filteredDetails]; | |||||
| if (mode !== "approved") { | |||||
| return list.sort((a, b) => | |||||
| (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, { | |||||
| numeric: true, | |||||
| sensitivity: "base", | |||||
| }) | |||||
| ); | |||||
| } | |||||
| const key = approvedSortKey ?? "stockTakeSection"; | |||||
| const mul = approvedSortDir === "asc" ? 1 : -1; | |||||
| return list.sort((a, b) => { | |||||
| let cmp = 0; | |||||
| switch (key) { | |||||
| case "stockTakeEndTime": | |||||
| cmp = | |||||
| parseDateTimeMs(a.approverTime ?? a.stockTakeEndTime) - | |||||
| parseDateTimeMs(b.approverTime ?? b.stockTakeEndTime); | |||||
| break; | |||||
| case "stockTakeSection": | |||||
| cmp = (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, { | |||||
| numeric: true, | |||||
| sensitivity: "base", | |||||
| }); | |||||
| break; | |||||
| case "item": | |||||
| cmp = `${a.itemCode || ""} ${a.itemName || ""}`.localeCompare( | |||||
| `${b.itemCode || ""} ${b.itemName || ""}`, | |||||
| undefined, | |||||
| { numeric: true, sensitivity: "base" } | |||||
| ); | |||||
| break; | |||||
| case "stockTakerName": | |||||
| cmp = (a.stockTakerName || "").localeCompare(b.stockTakerName || "", undefined, { | |||||
| numeric: true, | |||||
| sensitivity: "base", | |||||
| }); | |||||
| break; | |||||
| case "variance": | |||||
| cmp = Number(a.varianceQty ?? 0) - Number(b.varianceQty ?? 0); | |||||
| break; | |||||
| default: | |||||
| cmp = 0; | |||||
| } | |||||
| return cmp * mul; | |||||
| }); | |||||
| }, [filteredDetails, mode, approvedSortKey, approvedSortDir]); | |||||
| const handleApprovedSort = useCallback((property: ApprovedSortKey) => { | |||||
| if (approvedSortKey === property) { | |||||
| setApprovedSortDir((d) => (d === "asc" ? "desc" : "asc")); | |||||
| } else { | |||||
| setApprovedSortKey(property); | |||||
| setApprovedSortDir("asc"); | |||||
| } | |||||
| }, [approvedSortKey]); | |||||
| const handleSaveApproverStockTake = useCallback( | const handleSaveApproverStockTake = useCallback( | ||||
| async (detail: InventoryLotDetailResponse) => { | async (detail: InventoryLotDetailResponse) => { | ||||
| if (mode === "approved") return; | if (mode === "approved") return; | ||||
| @@ -222,26 +393,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| finalQty = detail.secondStockTakeQty; | finalQty = detail.secondStockTakeQty; | ||||
| finalBadQty = detail.secondBadQty || 0; | finalBadQty = detail.secondBadQty || 0; | ||||
| } else { | } else { | ||||
| const approverQtyValue = approverQty[detail.id]; | |||||
| const approverBadQtyValue = approverBadQty[detail.id]; | |||||
| if ( | |||||
| approverQtyValue === undefined || | |||||
| approverQtyValue === null || | |||||
| approverQtyValue === "" | |||||
| ) { | |||||
| onSnackbar(t("Please enter Approver QTY"), "error"); | |||||
| return; | |||||
| } | |||||
| if ( | |||||
| approverBadQtyValue === undefined || | |||||
| approverBadQtyValue === null || | |||||
| approverBadQtyValue === "" | |||||
| ) { | |||||
| onSnackbar(t("Please enter Approver Bad QTY"), "error"); | |||||
| return; | |||||
| } | |||||
| // 与 Picker 逻辑一致:Approver 输入为空时按 0 处理 | |||||
| const approverQtyValue = approverQty[detail.id] || "0"; | |||||
| const approverBadQtyValue = approverBadQty[detail.id] || "0"; | |||||
| finalQty = parseFloat(approverQtyValue) || 0; | finalQty = parseFloat(approverQtyValue) || 0; | ||||
| finalBadQty = parseFloat(approverBadQtyValue) || 0; | finalBadQty = parseFloat(approverBadQtyValue) || 0; | ||||
| } | } | ||||
| @@ -255,6 +409,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| approverId: currentUserId, | approverId: currentUserId, | ||||
| approverQty: selection === "approver" ? finalQty : null, | approverQty: selection === "approver" ? finalQty : null, | ||||
| approverBadQty: selection === "approver" ? finalBadQty : null, | approverBadQty: selection === "approver" ? finalBadQty : null, | ||||
| // lastSelect: 1=First, 2=Second, 3=Approver Input | |||||
| lastSelect: selection === "first" ? 1 : selection === "second" ? 2 : 3, | |||||
| }; | }; | ||||
| await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); | await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); | ||||
| @@ -415,6 +571,12 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| [inventoryLotDetails] | [inventoryLotDetails] | ||||
| ); | ); | ||||
| const formatRecordEndTime = (detail: InventoryLotDetailResponse) => { | |||||
| const ms = parseDateTimeMs(detail.approverTime ?? detail.stockTakeEndTime); | |||||
| if (!ms) return "-"; | |||||
| return dayjs(ms).format("YYYY-MM-DD HH:mm"); | |||||
| }; | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| {onBack && ( | {onBack && ( | ||||
| @@ -487,28 +649,117 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||||
| {mode === "approved" && ( | |||||
| <TableCell | |||||
| sortDirection={ | |||||
| approvedSortKey === "stockTakeEndTime" ? approvedSortDir : false | |||||
| } | |||||
| > | |||||
| <TableSortLabel | |||||
| active={approvedSortKey === "stockTakeEndTime"} | |||||
| direction={ | |||||
| approvedSortKey === "stockTakeEndTime" ? approvedSortDir : "asc" | |||||
| } | |||||
| onClick={() => handleApprovedSort("stockTakeEndTime")} | |||||
| > | |||||
| {t("Approver Time")} | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| )} | |||||
| <TableCell | |||||
| sortDirection={ | |||||
| mode === "approved" && (approvedSortKey === "stockTakeSection" || approvedSortKey === null) | |||||
| ? approvedSortDir | |||||
| : false | |||||
| } | |||||
| > | |||||
| {mode === "approved" ? ( | |||||
| <TableSortLabel | |||||
| active={ | |||||
| approvedSortKey === "stockTakeSection" || approvedSortKey === null | |||||
| } | |||||
| direction={approvedSortDir} | |||||
| onClick={() => handleApprovedSort("stockTakeSection")} | |||||
| > | |||||
| {t("Warehouse Location")} | |||||
| </TableSortLabel> | |||||
| ) : ( | |||||
| t("Warehouse Location") | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| sortDirection={ | |||||
| mode === "approved" && approvedSortKey === "item" ? approvedSortDir : false | |||||
| } | |||||
| > | |||||
| {mode === "approved" ? ( | |||||
| <TableSortLabel | |||||
| active={approvedSortKey === "item"} | |||||
| direction={approvedSortKey === "item" ? approvedSortDir : "asc"} | |||||
| onClick={() => handleApprovedSort("item")} | |||||
| > | |||||
| {t("Item-lotNo-ExpiryDate")} | |||||
| </TableSortLabel> | |||||
| ) : ( | |||||
| t("Item-lotNo-ExpiryDate") | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | <TableCell>{t("UOM")}</TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| {t("Stock Take Qty(include Bad Qty)= Available Qty")} | {t("Stock Take Qty(include Bad Qty)= Available Qty")} | ||||
| </TableCell> | </TableCell> | ||||
| {mode === "approved" && ( | |||||
| <TableCell | |||||
| sortDirection={ | |||||
| approvedSortKey === "variance" ? approvedSortDir : false | |||||
| } | |||||
| > | |||||
| <TableSortLabel | |||||
| active={approvedSortKey === "variance"} | |||||
| direction={approvedSortKey === "variance" ? approvedSortDir : "asc"} | |||||
| onClick={() => handleApprovedSort("variance")} | |||||
| > | |||||
| {t("Variance")} | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| )} | |||||
| <TableCell>{t("Remark")}</TableCell> | <TableCell>{t("Remark")}</TableCell> | ||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell | |||||
| sortDirection={ | |||||
| mode === "approved" && approvedSortKey === "stockTakerName" | |||||
| ? approvedSortDir | |||||
| : false | |||||
| } | |||||
| > | |||||
| {mode === "approved" ? ( | |||||
| <TableSortLabel | |||||
| active={approvedSortKey === "stockTakerName"} | |||||
| direction={ | |||||
| approvedSortKey === "stockTakerName" ? approvedSortDir : "asc" | |||||
| } | |||||
| onClick={() => handleApprovedSort("stockTakerName")} | |||||
| > | |||||
| {t("Picker")} | |||||
| </TableSortLabel> | |||||
| ) : ( | |||||
| t("Picker") | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {filteredDetails.length === 0 ? ( | |||||
| {sortedDetails.length === 0 ? ( | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={7} align="center"> | |||||
| <TableCell colSpan={mode === "approved" ? 10 : 8} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No data")} | {t("No data")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| filteredDetails.map((detail) => { | |||||
| sortedDetails.map((detail) => { | |||||
| const hasFirst = | const hasFirst = | ||||
| detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0; | detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0; | ||||
| const hasSecond = | const hasSecond = | ||||
| @@ -516,11 +767,36 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const selection = | const selection = | ||||
| qtySelection[detail.id] || (hasSecond ? "second" : "first"); | qtySelection[detail.id] || (hasSecond ? "second" : "first"); | ||||
| // approved 视图下,只有存在已保存的 approver 结果才显示 approver 输入区块 | |||||
| const canApprover = | |||||
| mode === "pending" | |||||
| ? true | |||||
| : selection === "approver" && | |||||
| (detail.approverQty != null || | |||||
| detail.approverBadQty != null); | |||||
| // approved 模式下:即使 finalQty 已存在,也需要展示 radio 用于查看选择 | |||||
| const showRadioBlock = | |||||
| mode === "approved" || detail.finalQty == null; | |||||
| return ( | return ( | ||||
| <TableRow key={detail.id}> | <TableRow key={detail.id}> | ||||
| {mode === "approved" && ( | |||||
| <TableCell> | |||||
| <Stack spacing={0.5}> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {formatRecordEndTime(detail)} | |||||
| </Typography> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| )} | |||||
| <TableCell> | <TableCell> | ||||
| {detail.warehouseArea || "-"} | |||||
| {detail.warehouseSlot || "-"} | |||||
| <Stack spacing={0.5}> | |||||
| <Typography variant="body2"><strong>{detail.stockTakeSection || "-"} {detail.stockTakeSectionDescription || "-"}</strong></Typography> | |||||
| <Typography variant="body2">{detail.warehouseCode || "-"}</Typography> | |||||
| </Stack> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell | <TableCell | ||||
| sx={{ | sx={{ | ||||
| @@ -544,35 +820,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | <TableCell>{detail.uom || "-"}</TableCell> | ||||
| <TableCell sx={{ minWidth: 300 }}> | <TableCell sx={{ minWidth: 300 }}> | ||||
| {detail.finalQty != null ? ( | |||||
| <Stack spacing={0.5}> | |||||
| {(() => { | |||||
| const bookQtyToUse = | |||||
| detail.bookQty != null | |||||
| ? detail.bookQty | |||||
| : detail.availableQty || 0; | |||||
| const finalDifference = | |||||
| (detail.finalQty || 0) - bookQtyToUse; | |||||
| const differenceColor = | |||||
| detail.stockTakeRecordStatus === "completed" | |||||
| ? "text.secondary" | |||||
| : finalDifference !== 0 | |||||
| ? "error.main" | |||||
| : "success.main"; | |||||
| return ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ fontWeight: "bold", color: differenceColor }} | |||||
| > | |||||
| {t("Difference")}: {formatNumber(detail.finalQty)} -{" "} | |||||
| {formatNumber(bookQtyToUse)} ={" "} | |||||
| {formatNumber(finalDifference)} | |||||
| </Typography> | |||||
| ); | |||||
| })()} | |||||
| </Stack> | |||||
| ) : ( | |||||
| {showRadioBlock ? ( | |||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| {hasFirst && ( | {hasFirst && ( | ||||
| <Stack | <Stack | ||||
| @@ -585,7 +833,6 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| checked={selection === "first"} | checked={selection === "first"} | ||||
| disabled={mode === "approved"} | disabled={mode === "approved"} | ||||
| onChange={() => | onChange={() => | ||||
| setQtySelection({ | setQtySelection({ | ||||
| ...qtySelection, | ...qtySelection, | ||||
| [detail.id]: "first", | [detail.id]: "first", | ||||
| @@ -633,7 +880,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| {hasSecond && ( | |||||
| {canApprover && ( | |||||
| <Stack | <Stack | ||||
| direction="row" | direction="row" | ||||
| spacing={1} | spacing={1} | ||||
| @@ -674,7 +921,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| }, | }, | ||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty")} | placeholder={t("Stock Take Qty")} | ||||
| disabled={selection !== "approver"} | |||||
| disabled={mode === "approved" || selection !== "approver"} | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| /> | /> | ||||
| @@ -699,7 +946,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| }, | }, | ||||
| }} | }} | ||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| disabled={selection !== "approver"} | |||||
| disabled={mode === "approved" || selection !== "approver"} | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| /> | /> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| @@ -714,30 +961,98 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| {detail.finalQty != null ? ( | |||||
| <Stack spacing={0.5}> | |||||
| {(() => { | |||||
| const bookQtyToUse = | |||||
| detail.bookQty != null | |||||
| ? detail.bookQty | |||||
| : detail.availableQty || 0; | |||||
| const finalDifference = | |||||
| (detail.finalQty || 0) - bookQtyToUse; | |||||
| const differenceColor = | |||||
| detail.stockTakeRecordStatus === "completed" | |||||
| ? "text.secondary" | |||||
| : finalDifference !== 0 | |||||
| ? "error.main" | |||||
| : "success.main"; | |||||
| return ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ | |||||
| fontWeight: "bold", | |||||
| color: differenceColor, | |||||
| }} | |||||
| > | |||||
| {t("Difference")}:{" "} | |||||
| {formatNumber(detail.finalQty)} -{" "} | |||||
| {formatNumber(bookQtyToUse)} ={" "} | |||||
| {formatNumber(finalDifference)} | |||||
| </Typography> | |||||
| ); | |||||
| })()} | |||||
| </Stack> | |||||
| ) : ( | |||||
| (() => { | |||||
| let selectedQty = 0; | |||||
| if (selection === "first") { | |||||
| selectedQty = detail.firstStockTakeQty || 0; | |||||
| } else if (selection === "second") { | |||||
| selectedQty = detail.secondStockTakeQty || 0; | |||||
| } else if (selection === "approver") { | |||||
| selectedQty = | |||||
| (parseFloat(approverQty[detail.id] || "0") - | |||||
| parseFloat( | |||||
| approverBadQty[detail.id] || "0" | |||||
| )) || 0; | |||||
| } | |||||
| const bookQty = | |||||
| detail.bookQty != null | |||||
| ? detail.bookQty | |||||
| : detail.availableQty || 0; | |||||
| const difference = selectedQty - bookQty; | |||||
| const differenceColor = | |||||
| detail.stockTakeRecordStatus === "completed" | |||||
| ? "text.secondary" | |||||
| : difference !== 0 | |||||
| ? "error.main" | |||||
| : "success.main"; | |||||
| return ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ | |||||
| fontWeight: "bold", | |||||
| color: differenceColor, | |||||
| }} | |||||
| > | |||||
| {t("Difference")}:{" "} | |||||
| {t("selected stock take qty")}( | |||||
| {formatNumber(selectedQty)}) -{" "} | |||||
| {t("book qty")}( | |||||
| {formatNumber(bookQty)}) ={" "} | |||||
| {formatNumber(difference)} | |||||
| </Typography> | |||||
| ); | |||||
| })() | |||||
| )} | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Stack spacing={0.5}> | |||||
| {(() => { | {(() => { | ||||
| let selectedQty = 0; | |||||
| if (selection === "first") { | |||||
| selectedQty = detail.firstStockTakeQty || 0; | |||||
| } else if (selection === "second") { | |||||
| selectedQty = detail.secondStockTakeQty || 0; | |||||
| } else if (selection === "approver") { | |||||
| selectedQty = | |||||
| (parseFloat(approverQty[detail.id] || "0") - | |||||
| parseFloat( | |||||
| approverBadQty[detail.id] || "0" | |||||
| )) || 0; | |||||
| } | |||||
| const bookQty = | |||||
| const bookQtyToUse = | |||||
| detail.bookQty != null | detail.bookQty != null | ||||
| ? detail.bookQty | ? detail.bookQty | ||||
| : detail.availableQty || 0; | : detail.availableQty || 0; | ||||
| const difference = selectedQty - bookQty; | |||||
| const finalDifference = | |||||
| (detail.finalQty || 0) - bookQtyToUse; | |||||
| const differenceColor = | const differenceColor = | ||||
| detail.stockTakeRecordStatus === "completed" | detail.stockTakeRecordStatus === "completed" | ||||
| ? "text.secondary" | ? "text.secondary" | ||||
| : difference !== 0 | |||||
| : finalDifference !== 0 | |||||
| ? "error.main" | ? "error.main" | ||||
| : "success.main"; | : "success.main"; | ||||
| @@ -746,12 +1061,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| variant="body2" | variant="body2" | ||||
| sx={{ fontWeight: "bold", color: differenceColor }} | sx={{ fontWeight: "bold", color: differenceColor }} | ||||
| > | > | ||||
| {t("Difference")}:{" "} | |||||
| {t("selected stock take qty")}( | |||||
| {formatNumber(selectedQty)}) -{" "} | |||||
| {t("book qty")}( | |||||
| {formatNumber(bookQty)}) ={" "} | |||||
| {formatNumber(difference)} | |||||
| {t("Difference")}: {formatNumber(detail.finalQty)} -{" "} | |||||
| {formatNumber(bookQtyToUse)} ={" "} | |||||
| {formatNumber(finalDifference)} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| })()} | })()} | ||||
| @@ -759,6 +1071,14 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| {mode === "approved" && ( | |||||
| <TableCell> | |||||
| <Typography variant="body2"> | |||||
| {formatNumber(detail.varianceQty)} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| )} | |||||
| <TableCell> | <TableCell> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {detail.remarks || "-"} | {detail.remarks || "-"} | ||||
| @@ -792,6 +1112,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| /> | /> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.stockTakerName || "-"}</TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {mode === "pending" && detail.stockTakeRecordId && | {mode === "pending" && detail.stockTakeRecordId && | ||||
| detail.stockTakeRecordStatus !== "notMatch" && ( | detail.stockTakeRecordStatus !== "notMatch" && ( | ||||
| @@ -819,7 +1140,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| size="small" | size="small" | ||||
| variant="contained" | variant="contained" | ||||
| onClick={() => handleSaveApproverStockTake(detail)} | onClick={() => handleSaveApproverStockTake(detail)} | ||||
| disabled={saving} | |||||
| disabled={saving ||detail.stockTakeRecordStatus === "notMatch"} | |||||
| > | > | ||||
| {t("Save")} | {t("Save")} | ||||
| </Button> | </Button> | ||||
| @@ -37,20 +37,30 @@ import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| const PER_PAGE = 6; | const PER_PAGE = 6; | ||||
| interface PickerCardListProps { | interface PickerCardListProps { | ||||
| /** 由父層保存,從明細返回時仍回到同一頁 */ | |||||
| page: number; | |||||
| pageSize: number; | |||||
| onListPageChange: (page: number) => void; | |||||
| onCardClick: (session: AllPickedStockTakeListReponse) => void; | onCardClick: (session: AllPickedStockTakeListReponse) => void; | ||||
| onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void; | onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void; | ||||
| } | } | ||||
| const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockTakeClick }) => { | |||||
| const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| page, | |||||
| pageSize, | |||||
| onListPageChange, | |||||
| onCardClick, | |||||
| onReStockTakeClick, | |||||
| }) => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | const { t } = useTranslation(["inventory", "common"]); | ||||
| dayjs.extend(duration); | dayjs.extend(duration); | ||||
| const PER_PAGE = 6; | const PER_PAGE = 6; | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | ||||
| const [page, setPage] = useState(0); | |||||
| const [pageSize, setPageSize] = useState(6); // 每页 6 条 | |||||
| const [total, setTotal] = useState(0); | |||||
| const [total, setTotal] = useState(0); | |||||
| /** 建立盤點後若仍在 page 0,仍強制重新載入 */ | |||||
| const [listRefreshNonce, setListRefreshNonce] = useState(0); | |||||
| const [creating, setCreating] = useState(false); | const [creating, setCreating] = useState(false); | ||||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | ||||
| const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All"); | const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All"); | ||||
| @@ -106,41 +116,40 @@ const criteria: Criterion<PickerSearchKey>[] = [ | |||||
| const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => { | const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => { | ||||
| setFilterSectionDescription(inputs.sectionDescription || "All"); | setFilterSectionDescription(inputs.sectionDescription || "All"); | ||||
| setFilterStockTakeSession(inputs.stockTakeSession || ""); | setFilterStockTakeSession(inputs.stockTakeSession || ""); | ||||
| fetchStockTakeSessions(0, pageSize, { | |||||
| sectionDescription: inputs.sectionDescription || "All", | |||||
| stockTakeSections: inputs.stockTakeSession ?? "", | |||||
| }); | |||||
| onListPageChange(0); | |||||
| }; | }; | ||||
| const handleResetSearch = () => { | const handleResetSearch = () => { | ||||
| setFilterSectionDescription("All"); | setFilterSectionDescription("All"); | ||||
| setFilterStockTakeSession(""); | setFilterStockTakeSession(""); | ||||
| fetchStockTakeSessions(0, pageSize, { | |||||
| sectionDescription: "All", | |||||
| stockTakeSections: "", | |||||
| }); | |||||
| onListPageChange(0); | |||||
| }; | }; | ||||
| const fetchStockTakeSessions = useCallback( | |||||
| async (pageNum: number, size: number, filterOverrides?: { sectionDescription: string; stockTakeSections: string }) => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const res = await getStockTakeRecordsPaged(pageNum, size, filterOverrides); | |||||
| useEffect(() => { | |||||
| let cancelled = false; | |||||
| setLoading(true); | |||||
| getStockTakeRecordsPaged(page, pageSize, { | |||||
| sectionDescription: filterSectionDescription, | |||||
| stockTakeSections: filterStockTakeSession, | |||||
| }) | |||||
| .then((res) => { | |||||
| if (cancelled) return; | |||||
| setStockTakeSessions(Array.isArray(res.records) ? res.records : []); | setStockTakeSessions(Array.isArray(res.records) ? res.records : []); | ||||
| setTotal(res.total || 0); | setTotal(res.total || 0); | ||||
| setPage(pageNum); | |||||
| } catch (e) { | |||||
| }) | |||||
| .catch((e) => { | |||||
| console.error(e); | console.error(e); | ||||
| setStockTakeSessions([]); | |||||
| setTotal(0); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, | |||||
| [] | |||||
| ); | |||||
| useEffect(() => { | |||||
| fetchStockTakeSessions(0, pageSize); | |||||
| }, [fetchStockTakeSessions, pageSize]); | |||||
| if (!cancelled) { | |||||
| setStockTakeSessions([]); | |||||
| setTotal(0); | |||||
| } | |||||
| }) | |||||
| .finally(() => { | |||||
| if (!cancelled) setLoading(false); | |||||
| }); | |||||
| return () => { | |||||
| cancelled = true; | |||||
| }; | |||||
| }, [page, pageSize, filterSectionDescription, filterStockTakeSession, listRefreshNonce]); | |||||
| //const startIdx = page * PER_PAGE; | //const startIdx = page * PER_PAGE; | ||||
| //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | ||||
| @@ -161,13 +170,14 @@ const handleResetSearch = () => { | |||||
| console.log(message); | console.log(message); | ||||
| await fetchStockTakeSessions(0, pageSize); | |||||
| onListPageChange(0); | |||||
| setListRefreshNonce((n) => n + 1); | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error(e); | console.error(e); | ||||
| } finally { | } finally { | ||||
| setCreating(false); | setCreating(false); | ||||
| } | } | ||||
| }, [fetchStockTakeSessions, t]); | |||||
| }, [onListPageChange, t]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| fetchStockTakeSections() | fetchStockTakeSections() | ||||
| .then((sections) => { | .then((sections) => { | ||||
| @@ -376,7 +386,7 @@ const handleResetSearch = () => { | |||||
| page={page} | page={page} | ||||
| rowsPerPage={pageSize} | rowsPerPage={pageSize} | ||||
| onPageChange={(e, newPage) => { | onPageChange={(e, newPage) => { | ||||
| fetchStockTakeSessions(newPage, pageSize); | |||||
| onListPageChange(newPage); | |||||
| }} | }} | ||||
| rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死 | rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死 | ||||
| /> | /> | ||||
| @@ -599,13 +599,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| value={inputs.remark} | value={inputs.remark} | ||||
| onKeyDown={blockNonIntegerKeys} | |||||
| inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||||
| // onKeyDown={blockNonIntegerKeys} | |||||
| //inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const clean = sanitizeIntegerInput(e.target.value); | |||||
| // const clean = sanitizeIntegerInput(e.target.value); | |||||
| setRecordInputs(prev => ({ | setRecordInputs(prev => ({ | ||||
| ...prev, | ...prev, | ||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: clean } | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: e.target.value } | |||||
| })); | })); | ||||
| }} | }} | ||||
| sx={{ width: 150 }} | sx={{ width: 150 }} | ||||
| @@ -771,15 +771,15 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| value={recordInputs[detail.id]?.remark || ""} | value={recordInputs[detail.id]?.remark || ""} | ||||
| onKeyDown={blockNonIntegerKeys} | |||||
| inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||||
| // onKeyDown={blockNonIntegerKeys} | |||||
| //inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const clean = sanitizeIntegerInput(e.target.value); | |||||
| // const clean = sanitizeIntegerInput(e.target.value); | |||||
| setRecordInputs(prev => ({ | setRecordInputs(prev => ({ | ||||
| ...prev, | ...prev, | ||||
| [detail.id]: { | [detail.id]: { | ||||
| ...(prev[detail.id] ?? { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }), | ...(prev[detail.id] ?? { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }), | ||||
| remark: clean | |||||
| remark: e.target.value | |||||
| } | } | ||||
| })); | })); | ||||
| }} | }} | ||||
| @@ -19,6 +19,9 @@ const StockTakeTab: React.FC = () => { | |||||
| const [viewScope, setViewScope] = useState<ViewScope>("picker"); | const [viewScope, setViewScope] = useState<ViewScope>("picker"); | ||||
| const [approverSession, setApproverSession] = useState<AllPickedStockTakeListReponse | null>(null); | const [approverSession, setApproverSession] = useState<AllPickedStockTakeListReponse | null>(null); | ||||
| const [approverLoading, setApproverLoading] = useState(false); | const [approverLoading, setApproverLoading] = useState(false); | ||||
| /** 從卡片列表進入明細後返回時保留分頁 */ | |||||
| const [pickerListPage, setPickerListPage] = useState(0); | |||||
| const [pickerListPageSize] = useState(6); | |||||
| const [snackbar, setSnackbar] = useState<{ | const [snackbar, setSnackbar] = useState<{ | ||||
| open: boolean; | open: boolean; | ||||
| message: string; | message: string; | ||||
| @@ -120,7 +123,10 @@ const StockTakeTab: React.FC = () => { | |||||
| </Tabs> | </Tabs> | ||||
| {tabValue === 0 && ( | {tabValue === 0 && ( | ||||
| <PickerCardList | |||||
| <PickerCardList | |||||
| page={pickerListPage} | |||||
| pageSize={pickerListPageSize} | |||||
| onListPageChange={setPickerListPage} | |||||
| onCardClick={(session) => { | onCardClick={(session) => { | ||||
| setViewScope("picker"); | setViewScope("picker"); | ||||
| handleCardClick(session); | handleCardClick(session); | ||||
| @@ -8,6 +8,10 @@ | |||||
| "UoM": "單位", | "UoM": "單位", | ||||
| "Approver Pending": "審核待處理", | "Approver Pending": "審核待處理", | ||||
| "Approver Approved": "審核通過", | "Approver Approved": "審核通過", | ||||
| "Approver Time": "審核時間", | |||||
| "Total need stock take": "總需盤點數量", | |||||
| "Waiting for Approver": "待審核數量", | |||||
| "Total Approved": "已審核數量", | |||||
| "mat": "物料", | "mat": "物料", | ||||
| "variance": "差異", | "variance": "差異", | ||||
| "Plan Start Date": "計劃開始日期", | "Plan Start Date": "計劃開始日期", | ||||
| @@ -314,6 +314,7 @@ | |||||
| "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", | "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", | ||||
| "Lot Number Mismatch":"批次號碼不符", | "Lot Number Mismatch":"批次號碼不符", | ||||
| "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?", | "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?", | ||||
| "The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.":"掃描貨品相同但批次不同。請再掃描一次以確認:掃描「建議批次」的 QR 可沿用該批次;再掃描「另一批次」的 QR 則切換為該批次。", | |||||
| "Expected Lot:":"預期批次:", | "Expected Lot:":"預期批次:", | ||||
| "Scanned Lot:":"掃描批次:", | "Scanned Lot:":"掃描批次:", | ||||
| "Confirm":"確認", | "Confirm":"確認", | ||||
| @@ -324,6 +325,8 @@ | |||||
| "Print DN Label":"列印送貨單標籤", | "Print DN Label":"列印送貨單標籤", | ||||
| "Print All Draft" : "列印全部草稿", | "Print All Draft" : "列印全部草稿", | ||||
| "If you confirm, the system will:":"如果您確認,系統將:", | "If you confirm, the system will:":"如果您確認,系統將:", | ||||
| "After you scan to choose, the system will update the pick line to the lot you confirmed.":"確認後,系統會將您選擇的批次套用到對應提料行。", | |||||
| "Or use the Confirm button below if you cannot scan again (same as scanning the other lot again).":"若無法再掃描,可按下「確認」以切換為剛才掃描到的批次(與再掃一次該批次 QR 相同)。", | |||||
| "QR code verified.":"QR 碼驗證成功。", | "QR code verified.":"QR 碼驗證成功。", | ||||
| "Order Finished":"訂單完成", | "Order Finished":"訂單完成", | ||||
| "Submitted Status":"提交狀態", | "Submitted Status":"提交狀態", | ||||
| @@ -6,6 +6,7 @@ export const PRIVATE_ROUTES = [ | |||||
| "/po/workbench", | "/po/workbench", | ||||
| "/ps", | "/ps", | ||||
| "/bagPrint", | "/bagPrint", | ||||
| "/laserPrint", | |||||
| "/report", | "/report", | ||||
| "/invoice", | "/invoice", | ||||
| "/projects", | "/projects", | ||||