| @@ -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} | |||
| reportTitle={currentReport.title} | |||
| /> | |||
| ) : currentReport.id === 'rep-013' ? ( | |||
| ) : currentReport.id === 'rep-013' || currentReport.id === 'rep-009' ? ( | |||
| <> | |||
| <Button | |||
| variant="contained" | |||
| @@ -1,19 +1,12 @@ | |||
| "use client"; | |||
| 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 { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import * as XLSX from "xlsx"; | |||
| // Simple TabPanel component for conditional rendering | |||
| interface TabPanelProps { | |||
| children?: React.ReactNode; | |||
| index: number; | |||
| @@ -30,192 +23,29 @@ function TabPanel(props: TabPanelProps) { | |||
| aria-labelledby={`simple-tab-${index}`} | |||
| {...other} | |||
| > | |||
| {value === index && ( | |||
| <Box sx={{ p: 3 }}> | |||
| {children} | |||
| </Box> | |||
| )} | |||
| {value === index && <Box sx={{ p: 3 }}>{children}</Box>} | |||
| </div> | |||
| ); | |||
| } | |||
| export default function TestingPage() { | |||
| // Tab state | |||
| const [tabValue, setTabValue] = useState(0); | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| 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"); | |||
| // --- 7. M18 PO Sync by Code --- | |||
| // --- 2. M18 PO Sync by Code --- | |||
| const [m18PoCode, setM18PoCode] = useState(""); | |||
| const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); | |||
| 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 () => { | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| @@ -251,7 +81,6 @@ export default function TestingPage() { | |||
| } | |||
| }; | |||
| // M18 PO Sync By Code (Section 7) | |||
| const handleSyncM18PoByCode = async () => { | |||
| if (!m18PoCode.trim()) { | |||
| 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} | |||
| </Typography> | |||
| {children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>} | |||
| {children || <Typography color="textSecondary" sx={{ m: "auto" }}>Waiting for implementation...</Typography>} | |||
| </Paper> | |||
| ); | |||
| return ( | |||
| <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> | |||
| <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" }}> | |||
| <TextField | |||
| size="small" | |||
| @@ -555,8 +181,8 @@ export default function TestingPage() { | |||
| </Section> | |||
| </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" }}> | |||
| <TextField | |||
| size="small" | |||
| @@ -566,12 +192,7 @@ export default function TestingPage() { | |||
| placeholder="e.g. PFP002PO26030341" | |||
| 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"} | |||
| </Button> | |||
| </Stack> | |||
| @@ -592,22 +213,37 @@ export default function TestingPage() { | |||
| </Section> | |||
| </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> | |||
| </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> | |||
| ); | |||
| } | |||
| } | |||
| @@ -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. | |||
| * Client-side only; uses auth token from localStorage. | |||
| @@ -75,7 +98,25 @@ export async function downloadOnPackQrZip( | |||
| }); | |||
| 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(); | |||
| @@ -115,4 +115,44 @@ export async function fetchBomComboClient(): Promise<BomCombo[]> { | |||
| { params: { batchId } } | |||
| ); | |||
| 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; | |||
| } | |||
| /** 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 { | |||
| date: string; | |||
| inQty: number; | |||
| @@ -317,11 +392,13 @@ export async function fetchDeliveryOrderByDate( | |||
| } | |||
| export async function fetchPurchaseOrderByStatus( | |||
| targetDate?: string | |||
| targetDate?: string, | |||
| filters?: PurchaseOrderChartFilters | |||
| ): 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( | |||
| 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( | |||
| startDate?: 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 { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| //import { stockTakeDebugLog } from "@/components/StockTakeManagement/stockTakeDebugLog"; | |||
| export interface RecordsRes<T> { | |||
| records: T[]; | |||
| @@ -41,6 +42,39 @@ export interface InventoryLotDetailResponse { | |||
| approverBadQty: number | null; | |||
| finalQty: 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 ( | |||
| @@ -114,13 +148,13 @@ export const getApproverInventoryLotDetailsAll = async ( | |||
| } | |||
| const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`; | |||
| const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||
| const response = await serverFetchJson<ApproverInventoryLotDetailsRecordsRes>( | |||
| url, | |||
| { | |||
| method: "GET", | |||
| }, | |||
| ); | |||
| return response; | |||
| return normalizeApproverInventoryLotDetailsRes(response); | |||
| } | |||
| export const getApproverInventoryLotDetailsAllPending = async ( | |||
| stockTakeId?: number | null, | |||
| @@ -134,7 +168,8 @@ export const getApproverInventoryLotDetailsAllPending = async ( | |||
| params.append("stockTakeId", String(stockTakeId)); | |||
| } | |||
| 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 ( | |||
| stockTakeId?: number | null, | |||
| @@ -148,7 +183,8 @@ export const getApproverInventoryLotDetailsAllApproved = async ( | |||
| params.append("stockTakeId", String(stockTakeId)); | |||
| } | |||
| 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) => { | |||
| @@ -234,6 +270,7 @@ export const saveStockTakeRecord = async ( | |||
| console.log('saveStockTakeRecord: request:', request); | |||
| console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); | |||
| console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); | |||
| return result; | |||
| } catch (error: any) { | |||
| // 尝试从错误响应中提取消息 | |||
| @@ -263,12 +300,14 @@ export interface BatchSaveStockTakeRecordResponse { | |||
| errors: string[]; | |||
| } | |||
| 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", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }) | |||
| return r | |||
| }) | |||
| // Add these interfaces and functions | |||
| @@ -279,6 +318,7 @@ export interface SaveApproverStockTakeRecordRequest { | |||
| approverId?: number | null; | |||
| approverQty?: number | null; | |||
| approverBadQty?: number | null; | |||
| lastSelect?: number | null; | |||
| } | |||
| export interface BatchSaveApproverStockTakeRecordRequest { | |||
| @@ -316,6 +356,7 @@ export const saveApproverStockTakeRecord = async ( | |||
| body: JSON.stringify(request), | |||
| }, | |||
| ); | |||
| return result; | |||
| } catch (error: any) { | |||
| if (error?.response) { | |||
| @@ -345,7 +386,7 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp | |||
| ) | |||
| export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => { | |||
| return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>( | |||
| const r = await serverFetchJson<BatchSaveApproverStockTakeRecordResponse>( | |||
| `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`, | |||
| { | |||
| method: "POST", | |||
| @@ -353,6 +394,8 @@ export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSave | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ) | |||
| return r | |||
| }) | |||
| export const updateStockTakeRecordStatusToNotMatch = async ( | |||
| @@ -25,7 +25,13 @@ import ChevronRight from "@mui/icons-material/ChevronRight"; | |||
| import Settings from "@mui/icons-material/Settings"; | |||
| import Print from "@mui/icons-material/Print"; | |||
| import Download from "@mui/icons-material/Download"; | |||
| import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions"; | |||
| import { | |||
| checkPrinterStatus, | |||
| downloadOnPackQrZip, | |||
| downloadOnPackTextQrZip, | |||
| fetchJobOrders, | |||
| JobOrderListItem, | |||
| } from "@/app/api/bagPrint/actions"; | |||
| import dayjs from "dayjs"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| @@ -107,6 +113,7 @@ const BagPrintSearch: React.FC = () => { | |||
| const [printerConnected, setPrinterConnected] = useState(false); | |||
| const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | |||
| const [downloadingOnPack, setDownloadingOnPack] = useState(false); | |||
| const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); | |||
| useEffect(() => { | |||
| 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 ( | |||
| <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | |||
| {/* Top: date nav + printer + settings */} | |||
| @@ -360,15 +407,24 @@ const BagPrintSearch: React.FC = () => { | |||
| <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}> | |||
| {printerMessage} | |||
| </Typography> | |||
| <Stack direction="row" sx={{ mt: 2 }}> | |||
| <Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Download />} | |||
| onClick={handleDownloadOnPackQr} | |||
| disabled={loading || downloadingOnPack || jobOrders.length === 0} | |||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||
| > | |||
| {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="secondary" | |||
| startIcon={<Download />} | |||
| onClick={handleDownloadOnPackTextQr} | |||
| disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} | |||
| > | |||
| {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"} | |||
| </Button> | |||
| </Stack> | |||
| </Paper> | |||
| @@ -47,6 +47,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/stockIssue": "Stock Issue", | |||
| "/report": "Report", | |||
| "/bagPrint": "打袋機", | |||
| "/laserPrint": "檸檬機(激光機)", | |||
| "/settings/itemPrice": "Price Inquiry", | |||
| }; | |||
| @@ -58,6 +58,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| const formProps = useForm<CreateConsoDoInput>({ | |||
| defaultValues: {}, | |||
| }); | |||
| const { setValue } = formProps; | |||
| 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 - currentUserId:", currentUserId); | |||
| 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 [totalCount, setTotalCount] = useState(0); | |||
| @@ -101,6 +102,37 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| const [hasSearched, setHasSearched] = 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(() => { | |||
| setPagingController(p => ({ | |||
| @@ -140,6 +172,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| setTotalCount(0); | |||
| setHasSearched(false); | |||
| setHasResults(false); | |||
| setExcludedRowIds([]); | |||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||
| } | |||
| catch (error) { | |||
| @@ -289,6 +322,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| setTotalCount(response.total); // 设置总记录数 | |||
| setHasSearched(true); | |||
| setHasResults(response.records.length > 0); | |||
| setExcludedRowIds([]); | |||
| } catch (error) { | |||
| console.error("Error: ", error); | |||
| @@ -296,6 +330,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| setTotalCount(0); | |||
| setHasSearched(true); | |||
| setHasResults(false); | |||
| setExcludedRowIds([]); | |||
| } | |||
| }, [pagingController]); | |||
| @@ -494,6 +529,20 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| }); | |||
| 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({ | |||
| @@ -501,7 +550,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| title: t("Batch Release"), | |||
| html: ` | |||
| <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;"> | |||
| ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | |||
| @@ -519,8 +568,6 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| }); | |||
| if (result.isConfirmed) { | |||
| const idsToRelease = allMatchingDos.map(d => d.id); | |||
| try { | |||
| const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | |||
| const jobId = startRes?.entity?.jobId; | |||
| @@ -595,7 +642,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| confirmButtonText: t("OK") | |||
| }); | |||
| } | |||
| }, [t, currentUserId, currentSearchParams, handleSearch]); | |||
| }, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]); | |||
| return ( | |||
| <> | |||
| @@ -629,10 +676,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| columns={columns} | |||
| checkboxSelection | |||
| rowSelectionModel={rowSelectionModel} | |||
| onRowSelectionModelChange={(newRowSelectionModel) => { | |||
| setRowSelectionModel(newRowSelectionModel); | |||
| formProps.setValue("ids", newRowSelectionModel); | |||
| }} | |||
| onRowSelectionModelChange={applyRowSelectionChange} | |||
| slots={{ | |||
| footer: FooterToolbar, | |||
| noRowsOverlay: NoRowsOverlay, | |||
| @@ -65,7 +65,9 @@ const FGPickOrderTicketReleaseTable: React.FC = () => { | |||
| const { t } = useTranslation("ticketReleaseTable"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| 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 [selectedFloor, setSelectedFloor] = useState<string>(""); | |||
| @@ -80,6 +80,23 @@ interface Props { | |||
| onSwitchToRecordTab?: () => 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) | |||
| const QrCodeModal: React.FC<{ | |||
| open: boolean; | |||
| @@ -513,6 +530,22 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||
| const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]); | |||
| // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) | |||
| 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) | |||
| const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | |||
| @@ -571,12 +604,11 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); | |||
| const lastProcessedQrRef = useRef<string>(''); | |||
| // 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 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(); | |||
| console.log(`⏱️ [HANDLE LOT MISMATCH START]`); | |||
| console.log(`⏰ Start time: ${new Date().toISOString()}`); | |||
| 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 | |||
| const setTimeoutStartTime = performance.now(); | |||
| @@ -1299,34 +1334,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| return false; | |||
| }, [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) => { | |||
| 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) | |||
| resetScanRef.current = resetScan; | |||
| const processOutsideQrCode = useCallback(async (latestQr: string) => { | |||
| const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { | |||
| const totalStartTime = performance.now(); | |||
| console.log(`⏱️ [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); | |||
| console.log(`⏰ Start time: ${new Date().toISOString()}`); | |||
| @@ -1742,7 +1749,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| lot.lotAvailability === 'rejected' || | |||
| 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) | |||
| // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed | |||
| @@ -1760,7 +1774,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| itemName: expectedLot.itemName, | |||
| inventoryLotLineId: scannedLot?.lotId || null, | |||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | |||
| } | |||
| }, | |||
| qrScanCountAtInvoke | |||
| ); | |||
| 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) | |||
| if (!exactMatch) { | |||
| // 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) { | |||
| // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) | |||
| const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); | |||
| @@ -1804,7 +1820,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| itemName: expectedLot.itemName, | |||
| inventoryLotLineId: scannedLot?.lotId || null, | |||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | |||
| } | |||
| }, | |||
| qrScanCountAtInvoke | |||
| ); | |||
| return; | |||
| } | |||
| @@ -1925,9 +1942,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | |||
| console.log(`⏱️ [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); | |||
| // 取第一个活跃的 lot 作为期望的 lot | |||
| // 取应被替换的活跃行(同物料多行时优先有建议批次的行) | |||
| const expectedLotStartTime = performance.now(); | |||
| const expectedLot = activeSuggestedLots[0]; | |||
| const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); | |||
| if (!expectedLot) { | |||
| console.error("Could not determine expected lot for confirmation"); | |||
| startTransition(() => { | |||
| @@ -1963,7 +1980,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| itemName: expectedLot.itemName, | |||
| inventoryLotLineId: null, | |||
| stockInLineId: scannedStockInLineId // ✅ 传递 stockInLineId | |||
| } | |||
| }, | |||
| qrScanCountAtInvoke | |||
| ); | |||
| const handleMismatchTime = performance.now() - handleMismatchStartTime; | |||
| 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) | |||
| if (processOutsideQrCodeRef.current) { | |||
| processOutsideQrCodeRef.current(simulatedQr).then(() => { | |||
| processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { | |||
| 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] End time: ${new Date().toISOString()}`); | |||
| @@ -2074,9 +2092,24 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| } | |||
| } | |||
| // lot confirm 弹窗打开时,允许通过“再次扫码”决定走向(切换或继续原 lot) | |||
| // 批次确认弹窗:须第二次扫码选择沿用建议批次或切换(不再自动确认) | |||
| 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; | |||
| } | |||
| @@ -2171,7 +2204,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| // Use ref to avoid dependency issues | |||
| const processCallStartTime = performance.now(); | |||
| if (processOutsideQrCodeRef.current) { | |||
| processOutsideQrCodeRef.current(latestQr).then(() => { | |||
| processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => { | |||
| const processCallTime = performance.now() - processCallStartTime; | |||
| const totalProcessingTime = performance.now() - processingStartTime; | |||
| 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; | |||
| } | |||
| }; | |||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan]); | |||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan, isConfirmingLot]); | |||
| const renderCountRef = useRef(0); | |||
| const renderStartTimeRef = useRef<number | null>(null); | |||
| @@ -2550,16 +2583,16 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| try { | |||
| 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) { | |||
| console.log(`=== SUBMITTING ALL ZEROS CASE ===`); | |||
| console.log(`Lot: ${lot.lotNo}`); | |||
| 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({ | |||
| id: lot.stockOutLineId, | |||
| status: 'completed', | |||
| status: 'checked', | |||
| qty: 0 | |||
| }); | |||
| @@ -2575,29 +2608,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| console.error('Failed to update stock out line status:', updateResult); | |||
| 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(() => { | |||
| checkAndAutoAssignNext(); | |||
| @@ -2635,6 +2649,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| status: newStatus, | |||
| qty: cumulativeQty // Use cumulative quantity | |||
| }); | |||
| applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty); | |||
| if (submitQty > 0) { | |||
| await updateInventoryLotLineQuantities({ | |||
| @@ -2665,7 +2680,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| } | |||
| } | |||
| await fetchAllCombinedLotData(); | |||
| void fetchAllCombinedLotData(); | |||
| console.log("Pick quantity submitted successfully!"); | |||
| setTimeout(() => { | |||
| @@ -2677,16 +2692,31 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| } finally { | |||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | |||
| } | |||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId]); | |||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId, applyLocalStockOutLineUpdate]); | |||
| const handleSkip = useCallback(async (lot: any) => { | |||
| 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) { | |||
| console.error("Error in Skip:", err); | |||
| } | |||
| }, [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 startTime = performance.now(); | |||
| console.log(`⏱️ [START SCAN] Called at: ${new Date().toISOString()}`); | |||
| @@ -2890,6 +2920,10 @@ const handleSubmitAllScanned = useCallback(async () => { | |||
| const scannedLots = combinedLotData.filter(lot => { | |||
| const status = lot.stockOutLineStatus; | |||
| const statusLower = String(status || "").toLowerCase(); | |||
| if (statusLower === "completed" || statusLower === "complete") { | |||
| return false; | |||
| } | |||
| // ✅ noLot 情况:允许 checked / pending / partially_completed / PARTIALLY_COMPLETE | |||
| if (lot.noLot === true) { | |||
| return status === 'checked' || | |||
| @@ -3021,6 +3055,10 @@ const handleSubmitAllScanned = useCallback(async () => { | |||
| const scannedItemsCount = useMemo(() => { | |||
| const filtered = combinedLotData.filter(lot => { | |||
| const status = lot.stockOutLineStatus; | |||
| const statusLower = String(status || "").toLowerCase(); | |||
| if (statusLower === "completed" || statusLower === "complete") { | |||
| return false; | |||
| } | |||
| // ✅ 与 handleSubmitAllScanned 完全保持一致 | |||
| if (lot.noLot === true) { | |||
| return status === 'checked' || | |||
| @@ -3528,6 +3566,9 @@ paginatedData.map((lot, index) => { | |||
| onClick={() => handleSkip(lot)} | |||
| disabled={ | |||
| lot.stockOutLineStatus === 'completed' || | |||
| lot.stockOutLineStatus === 'checked' || | |||
| lot.stockOutLineStatus === 'partially_completed' || | |||
| // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) | |||
| (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | |||
| @@ -52,7 +52,7 @@ const LotConfirmationModal: React.FC<LotConfirmationModalProps> = ({ | |||
| <DialogContent> | |||
| <Stack spacing={3}> | |||
| <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> | |||
| <Box> | |||
| @@ -92,13 +92,10 @@ const LotConfirmationModal: React.FC<LotConfirmationModalProps> = ({ | |||
| </Box> | |||
| <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 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> | |||
| </Stack> | |||
| </DialogContent> | |||
| @@ -27,6 +27,10 @@ import { | |||
| editBomClient, | |||
| fetchBomComboClient, | |||
| fetchBomDetailClient, | |||
| fetchAllEquipmentsMasterClient, | |||
| fetchAllProcessesMasterClient, | |||
| type EquipmentMasterRow, | |||
| type ProcessMasterRow, | |||
| } from "@/app/api/bom/client"; | |||
| import type { SelectChangeEvent } from "@mui/material/Select"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -37,6 +41,26 @@ import SaveIcon from "@mui/icons-material/Save"; | |||
| import CancelIcon from "@mui/icons-material/Cancel"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| 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 { t } = useTranslation( "common" ); | |||
| const [bomList, setBomList] = useState<BomCombo[]>([]); | |||
| @@ -69,7 +93,9 @@ const ImportBomDetailTab: React.FC = () => { | |||
| processCode?: string; | |||
| processName?: string; | |||
| description: string; | |||
| equipmentCode?: string; | |||
| /** 設備主檔 description(下拉),與 equipmentName 一併解析為 equipment.code */ | |||
| equipmentDescription: string; | |||
| equipmentName: string; | |||
| durationInMinute: number; | |||
| prepTimeInMinute: number; | |||
| postProdTimeInMinute: number; | |||
| @@ -96,17 +122,27 @@ const ImportBomDetailTab: React.FC = () => { | |||
| const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]); | |||
| 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<{ | |||
| processCode: string; | |||
| equipmentCode: string; | |||
| equipmentDescription: string; | |||
| equipmentName: string; | |||
| description: string; | |||
| durationInMinute: number; | |||
| prepTimeInMinute: number; | |||
| postProdTimeInMinute: number; | |||
| }>({ | |||
| processCode: "", | |||
| equipmentCode: "", | |||
| equipmentDescription: "", | |||
| equipmentName: "", | |||
| description: "", | |||
| durationInMinute: 0, | |||
| prepTimeInMinute: 0, | |||
| @@ -115,19 +151,27 @@ const ImportBomDetailTab: React.FC = () => { | |||
| const processCodeOptions = useMemo(() => { | |||
| 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(() => { | |||
| const loadList = async () => { | |||
| @@ -242,57 +286,82 @@ const ImportBomDetailTab: React.FC = () => { | |||
| const genKey = () => Math.random().toString(36).slice(2); | |||
| const startEdit = useCallback(() => { | |||
| const startEdit = useCallback(async () => { | |||
| if (!detail) return; | |||
| 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]); | |||
| const cancelEdit = useCallback(() => { | |||
| @@ -304,12 +373,15 @@ const ImportBomDetailTab: React.FC = () => { | |||
| setEditProcesses([]); | |||
| setProcessAddForm({ | |||
| processCode: "", | |||
| equipmentCode: "", | |||
| equipmentDescription: "", | |||
| equipmentName: "", | |||
| description: "", | |||
| durationInMinute: 0, | |||
| prepTimeInMinute: 0, | |||
| postProdTimeInMinute: 0, | |||
| }); | |||
| setEquipmentMasterList([]); | |||
| setProcessMasterList([]); | |||
| }, []); | |||
| const addMaterialRow = useCallback(() => { | |||
| @@ -339,7 +411,8 @@ const ImportBomDetailTab: React.FC = () => { | |||
| processCode: "", | |||
| processName: "", | |||
| description: "", | |||
| equipmentCode: "", | |||
| equipmentDescription: "", | |||
| equipmentName: "", | |||
| durationInMinute: 0, | |||
| prepTimeInMinute: 0, | |||
| postProdTimeInMinute: 0, | |||
| @@ -354,6 +427,22 @@ const ImportBomDetailTab: React.FC = () => { | |||
| 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) => [ | |||
| ...prev, | |||
| { | |||
| @@ -362,7 +451,8 @@ const ImportBomDetailTab: React.FC = () => { | |||
| processCode: pCode, | |||
| processName: "", | |||
| description: processAddForm.description ?? "", | |||
| equipmentCode: processAddForm.equipmentCode.trim(), | |||
| equipmentDescription: ed, | |||
| equipmentName: en, | |||
| durationInMinute: processAddForm.durationInMinute ?? 0, | |||
| prepTimeInMinute: processAddForm.prepTimeInMinute ?? 0, | |||
| postProdTimeInMinute: processAddForm.postProdTimeInMinute ?? 0, | |||
| @@ -371,14 +461,15 @@ const ImportBomDetailTab: React.FC = () => { | |||
| setProcessAddForm({ | |||
| processCode: "", | |||
| equipmentCode: "", | |||
| equipmentDescription: "", | |||
| equipmentName: "", | |||
| description: "", | |||
| durationInMinute: 0, | |||
| prepTimeInMinute: 0, | |||
| postProdTimeInMinute: 0, | |||
| }); | |||
| setEditError(null); | |||
| }, [processAddForm]); | |||
| }, [processAddForm, equipmentMasterList]); | |||
| const deleteMaterialRow = useCallback((key: string) => { | |||
| setEditMaterials((prev) => prev.filter((r) => r.key !== key)); | |||
| @@ -398,6 +489,19 @@ const ImportBomDetailTab: React.FC = () => { | |||
| if (!p.processCode?.trim()) { | |||
| 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 = { | |||
| @@ -413,16 +517,24 @@ const ImportBomDetailTab: React.FC = () => { | |||
| timeSequence: editBasic.timeSequence, | |||
| complexity: editBasic.complexity, | |||
| 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); | |||
| @@ -433,7 +545,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| } finally { | |||
| setEditLoading(false); | |||
| } | |||
| }, [detail, editBasic, editProcesses]); | |||
| }, [detail, editBasic, editProcesses, equipmentMasterList]); | |||
| return ( | |||
| <Stack spacing={2}> | |||
| @@ -480,11 +592,18 @@ const ImportBomDetailTab: React.FC = () => { | |||
| {!isEditing ? ( | |||
| <Button | |||
| size="small" | |||
| startIcon={<EditIcon />} | |||
| startIcon={ | |||
| editMasterLoading ? ( | |||
| <CircularProgress size={16} /> | |||
| ) : ( | |||
| <EditIcon /> | |||
| ) | |||
| } | |||
| variant="outlined" | |||
| onClick={startEdit} | |||
| onClick={() => void startEdit()} | |||
| disabled={editMasterLoading} | |||
| > | |||
| {t("Edit")} | |||
| {editMasterLoading ? t("Loading...") : t("Edit")} | |||
| </Button> | |||
| ) : ( | |||
| <Stack direction="row" spacing={1}> | |||
| @@ -770,6 +889,9 @@ const ImportBomDetailTab: React.FC = () => { | |||
| })) | |||
| } | |||
| > | |||
| <MenuItem value=""> | |||
| <em>請選擇</em> | |||
| </MenuItem> | |||
| {processCodeOptions.map((c) => ( | |||
| <MenuItem key={c} value={c}> | |||
| {c} | |||
| @@ -779,19 +901,40 @@ const ImportBomDetailTab: React.FC = () => { | |||
| </FormControl> | |||
| <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 | |||
| label={t("Equipment Code")} | |||
| value={processAddForm.equipmentCode} | |||
| label="設備名稱" | |||
| value={processAddForm.equipmentName} | |||
| onChange={(e) => | |||
| setProcessAddForm((p) => ({ | |||
| ...p, | |||
| equipmentCode: String(e.target.value), | |||
| equipmentName: String(e.target.value), | |||
| })) | |||
| } | |||
| > | |||
| <MenuItem value="">不適用</MenuItem> | |||
| {equipmentCodeOptions.map((c) => ( | |||
| {equipmentNameOptions.map((c) => ( | |||
| <MenuItem key={c} value={c}> | |||
| {c} | |||
| </MenuItem> | |||
| @@ -866,7 +1009,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| <TableCell> {t("Process Name")}</TableCell> | |||
| <TableCell> {t("Process Description")}</TableCell> | |||
| <TableCell> {t("Process Code")}</TableCell> | |||
| <TableCell> {t("Equipment Code")}</TableCell> | |||
| <TableCell>設備(說明/名稱)</TableCell> | |||
| <TableCell align="right"> {t("Duration (Minutes)")}</TableCell> | |||
| <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | |||
| <TableCell align="right"> {t("Post Prod Time (Minutes)")}</TableCell> | |||
| @@ -923,30 +1066,60 @@ const ImportBomDetailTab: React.FC = () => { | |||
| </FormControl> | |||
| </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 align="right"> | |||
| <TextField | |||
| @@ -464,6 +464,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) | |||
| const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | |||
| const [localSolStatusById, setLocalSolStatusById] = useState<Record<number, string>>({}); | |||
| // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 | |||
| 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 计算一致 | |||
| return lots.map((lot: any) => { | |||
| 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'; | |||
| return { | |||
| ...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; | |||
| }); | |||
| }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId]); | |||
| }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId, localSolStatusById]); | |||
| const originalCombinedData = useMemo(() => { | |||
| return getAllLotsFromHierarchical(jobOrderData); | |||
| @@ -1802,6 +1805,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| console.error("No stock out line found for this lot"); | |||
| return; | |||
| } | |||
| const solId = Number(lot.stockOutLineId) || 0; | |||
| try { | |||
| if (currentUserId && lot.pickOrderId && lot.itemId) { | |||
| @@ -1842,13 +1846,13 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| // 记录该 SOL 的“目标实际拣货量=0”,让 batch submit 走 onlyComplete(不补拣到 required) | |||
| const solId = Number(lot.stockOutLineId) || 0; | |||
| if (solId > 0) { | |||
| setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: 0 })); | |||
| setLocalSolStatusById(prev => ({ ...prev, [solId]: 'checked' })); | |||
| } | |||
| 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)."); | |||
| setTimeout(() => { | |||
| @@ -1887,6 +1891,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| status: newStatus, | |||
| qty: cumulativeQty | |||
| }); | |||
| if (solId > 0) { | |||
| setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: cumulativeQty })); | |||
| setLocalSolStatusById(prev => ({ ...prev, [solId]: newStatus })); | |||
| } | |||
| if (submitQty > 0) { | |||
| await updateInventoryLotLineQuantities({ | |||
| @@ -1923,7 +1931,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| } | |||
| const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | |||
| await fetchJobOrderData(pickOrderId); | |||
| void fetchJobOrderData(pickOrderId); | |||
| console.log("Pick quantity submitted successfully!"); | |||
| setTimeout(() => { | |||
| @@ -1936,15 +1944,34 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| }, [fetchJobOrderData, checkAndAutoAssignNext, filterArgs]); | |||
| const handleSkip = useCallback(async (lot: any) => { | |||
| 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); | |||
| } catch (err) { | |||
| console.error("Error in Skip:", err); | |||
| } | |||
| }, [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 scannedLots = combinedLotData.filter(lot => { | |||
| 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.status:", lot.stockOutLineStatus); | |||
| // ✅ no-lot:允許 pending / checked / partially_completed / PARTIALLY_COMPLETE | |||
| @@ -2093,6 +2120,10 @@ if (onlyComplete) { | |||
| const scannedItemsCount = useMemo(() => { | |||
| return combinedLotData.filter(lot => { | |||
| const status = lot.stockOutLineStatus; | |||
| const statusLower = String(status || "").toLowerCase(); | |||
| if (statusLower === "completed" || statusLower === "complete") { | |||
| return false; | |||
| } | |||
| const isNoLot = lot.noLot === true || !lot.lotId; | |||
| if (isNoLot) { | |||
| @@ -2722,7 +2753,7 @@ const sortedData = [...sourceData].sort((a, b) => { | |||
| console.error("❌ Error updating handler (non-critical):", error); | |||
| } | |||
| } | |||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty || lot.pickOrderLineRequiredQty || 0); | |||
| await handleSubmitPickQtyWithQty(lot, 0); | |||
| } finally { | |||
| if (solId > 0) { | |||
| setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | |||
| @@ -2732,6 +2763,7 @@ const sortedData = [...sourceData].sort((a, b) => { | |||
| disabled={ | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) || | |||
| lot.stockOutLineStatus === 'completed' || | |||
| lot.stockOutLineStatus === 'checked' || | |||
| lot.noLot === true || | |||
| !lot.lotId || | |||
| (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], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <Print />, | |||
| label: "檸檬機(激光機)", | |||
| path: "/laserPrint", | |||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <Assessment />, | |||
| label: "報告管理", | |||
| @@ -497,8 +497,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| {/* Target Date - 只在第一个项目显示 */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| arrayToDayjs(item.targetDate) | |||
| .add(-1, "month") | |||
| arrayToDayjs(item.targetDate) | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ) : null} | |||
| </TableCell> | |||
| @@ -397,8 +397,8 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| const { t } = useTranslation("pickOrder"); | |||
| const calculateRemainingRequiredQty = useCallback((lot: LotPickData) => { | |||
| 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 | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| @@ -506,7 +506,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| const stockOutLineUpdate = await updateStockOutLineStatus({ | |||
| id: selectedLotForQr.stockOutLineId, | |||
| status: 'checked', | |||
| qty: selectedLotForQr.stockOutLineQty || 0 | |||
| qty: 0 | |||
| }); | |||
| console.log(" Stock out line updated to 'checked':", stockOutLineUpdate); | |||
| @@ -361,13 +361,9 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| try { | |||
| // FIXED: 计算累计拣货数量 | |||
| 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: 状态应该基于累计拣货数量 | |||
| let newStatus = 'partially_completed'; | |||
| let newStatus = 'completed'; | |||
| if (totalPickedForThisLot >= selectedLot.requiredQty) { | |||
| newStatus = 'completed'; | |||
| } | |||
| @@ -388,16 +384,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| 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 | |||
| if (newStatus === 'completed') { | |||
| @@ -32,6 +32,7 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { | |||
| @@ -103,6 +104,9 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| 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"; | |||
| const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null); | |||
| @@ -275,6 +279,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| fetchProcesses(); | |||
| }, [fetchProcesses]); | |||
| const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { | |||
| if (!canManageUpdateJo) return; | |||
| if (!process.jobOrderId) { | |||
| alert(t("Invalid Job Order Id")); | |||
| return; | |||
| @@ -308,7 +313,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [t, fetchProcesses]); | |||
| }, [t, fetchProcesses, canManageUpdateJo]); | |||
| const openConfirm = useCallback((message: string, action: () => Promise<void>) => { | |||
| setConfirmMessage(message); | |||
| @@ -590,13 +595,16 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| disabled={!canManageUpdateJo} | |||
| 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")} | |||
| @@ -317,12 +317,14 @@ useEffect(() => { | |||
| try { | |||
| 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; | |||
| // console.log(`%c Parsed scan data`, "color:green", data); | |||
| //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; | |||
| const setResultStartTime = performance.now(); | |||
| @@ -31,6 +31,7 @@ type SearchQuery = { | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| const BATCH_CHUNK_SIZE = 20; | |||
| const { t } = useTranslation("inventory"); | |||
| const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss"); | |||
| const [search, setSearch] = useState<SearchQuery>({ lotNo: "" }); | |||
| @@ -53,6 +54,7 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); | |||
| const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | |||
| const [batchSubmitting, setBatchSubmitting] = useState(false); | |||
| const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null); | |||
| const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| @@ -113,7 +115,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| // setExpiryItems(prev => prev.filter(i => i.id !== id)); | |||
| window.location.reload(); | |||
| } 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 分支 | |||
| } | |||
| @@ -160,26 +164,40 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| if (allIds.length === 0) return; | |||
| setBatchSubmitting(true); | |||
| setBatchProgress({ done: 0, total: allIds.length }); | |||
| 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([]); | |||
| } catch (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 { | |||
| setBatchSubmitting(false); | |||
| setBatchProgress(null); | |||
| } | |||
| }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch]); | |||
| }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch, batchProgress, t]); | |||
| const missColumns = useMemo<Column<StockIssueResult>[]>( | |||
| () => [ | |||
| @@ -375,7 +393,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| onClick={handleSubmitSelected} | |||
| disabled={batchSubmitting || !currentUserId} | |||
| > | |||
| {batchSubmitting ? t("Disposing...") : t("Batch Disposed All")} | |||
| {batchSubmitting | |||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||
| : t("Batch Disposed All")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| @@ -17,6 +17,7 @@ import { | |||
| TextField, | |||
| Radio, | |||
| TablePagination, | |||
| TableSortLabel, | |||
| } from "@mui/material"; | |||
| import { useState, useCallback, useEffect, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -45,6 +46,25 @@ interface ApproverStockTakeAllProps { | |||
| 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> = ({ | |||
| selectedSession, | |||
| mode, | |||
| @@ -66,6 +86,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const [page, setPage] = useState(0); | |||
| const [pageSize, setPageSize] = useState<number | string>(50); | |||
| 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; | |||
| @@ -131,16 +153,76 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| loadDetails(page, pageSize); | |||
| }, [page, pageSize, loadDetails]); | |||
| // 切换模式时,清空用户先前的选择与输入,approved 模式需要以后端结果为准。 | |||
| 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) { | |||
| @@ -148,6 +230,33 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| } | |||
| }, [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( | |||
| (detail: InventoryLotDetailResponse, selection: QtySelectionType): number => { | |||
| let selectedQty = 0; | |||
| @@ -178,9 +287,13 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| } | |||
| const selection = | |||
| qtySelection[detail.id] ?? | |||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 | |||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0 | |||
| ? "second" | |||
| : "first"); | |||
| // 避免 Approver 手动输入过程中被 variance 过滤掉,导致“输入后行消失无法提交” | |||
| if (selection === "approver") { | |||
| return true; | |||
| } | |||
| const difference = calculateDifference(detail, selection); | |||
| const bookQty = | |||
| detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||
| @@ -195,6 +308,64 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| 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( | |||
| async (detail: InventoryLotDetailResponse) => { | |||
| if (mode === "approved") return; | |||
| @@ -222,26 +393,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| finalQty = detail.secondStockTakeQty; | |||
| finalBadQty = detail.secondBadQty || 0; | |||
| } 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; | |||
| finalBadQty = parseFloat(approverBadQtyValue) || 0; | |||
| } | |||
| @@ -255,6 +409,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| approverId: currentUserId, | |||
| approverQty: selection === "approver" ? finalQty : 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); | |||
| @@ -415,6 +571,12 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| [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 ( | |||
| <Box> | |||
| {onBack && ( | |||
| @@ -487,28 +649,117 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| <Table> | |||
| <TableHead> | |||
| <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("Stock Take Qty(include Bad Qty)= Available Qty")} | |||
| </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("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> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {filteredDetails.length === 0 ? ( | |||
| {sortedDetails.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={7} align="center"> | |||
| <TableCell colSpan={mode === "approved" ? 10 : 8} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| filteredDetails.map((detail) => { | |||
| sortedDetails.map((detail) => { | |||
| const hasFirst = | |||
| detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0; | |||
| const hasSecond = | |||
| @@ -516,11 +767,36 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const selection = | |||
| 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 ( | |||
| <TableRow key={detail.id}> | |||
| {mode === "approved" && ( | |||
| <TableCell> | |||
| <Stack spacing={0.5}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {formatRecordEndTime(detail)} | |||
| </Typography> | |||
| </Stack> | |||
| </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 | |||
| sx={{ | |||
| @@ -544,35 +820,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <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}> | |||
| {hasFirst && ( | |||
| <Stack | |||
| @@ -585,7 +833,6 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| checked={selection === "first"} | |||
| disabled={mode === "approved"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "first", | |||
| @@ -633,7 +880,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </Stack> | |||
| )} | |||
| {hasSecond && ( | |||
| {canApprover && ( | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| @@ -674,7 +921,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| }, | |||
| }} | |||
| placeholder={t("Stock Take Qty")} | |||
| disabled={selection !== "approver"} | |||
| disabled={mode === "approved" || selection !== "approver"} | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| /> | |||
| @@ -699,7 +946,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| }, | |||
| }} | |||
| placeholder={t("Bad Qty")} | |||
| disabled={selection !== "approver"} | |||
| disabled={mode === "approved" || selection !== "approver"} | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| /> | |||
| <Typography variant="body2"> | |||
| @@ -714,30 +961,98 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </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 | |||
| : detail.availableQty || 0; | |||
| const difference = selectedQty - bookQty; | |||
| const finalDifference = | |||
| (detail.finalQty || 0) - bookQtyToUse; | |||
| const differenceColor = | |||
| detail.stockTakeRecordStatus === "completed" | |||
| ? "text.secondary" | |||
| : difference !== 0 | |||
| : finalDifference !== 0 | |||
| ? "error.main" | |||
| : "success.main"; | |||
| @@ -746,12 +1061,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| variant="body2" | |||
| 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> | |||
| ); | |||
| })()} | |||
| @@ -759,6 +1071,14 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| )} | |||
| </TableCell> | |||
| {mode === "approved" && ( | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {formatNumber(detail.varianceQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| )} | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {detail.remarks || "-"} | |||
| @@ -792,6 +1112,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| /> | |||
| )} | |||
| </TableCell> | |||
| <TableCell>{detail.stockTakerName || "-"}</TableCell> | |||
| <TableCell> | |||
| {mode === "pending" && detail.stockTakeRecordId && | |||
| detail.stockTakeRecordStatus !== "notMatch" && ( | |||
| @@ -819,7 +1140,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleSaveApproverStockTake(detail)} | |||
| disabled={saving} | |||
| disabled={saving ||detail.stockTakeRecordStatus === "notMatch"} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| @@ -37,20 +37,30 @@ import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| const PER_PAGE = 6; | |||
| interface PickerCardListProps { | |||
| /** 由父層保存,從明細返回時仍回到同一頁 */ | |||
| page: number; | |||
| pageSize: number; | |||
| onListPageChange: (page: number) => void; | |||
| onCardClick: (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"]); | |||
| dayjs.extend(duration); | |||
| const PER_PAGE = 6; | |||
| const [loading, setLoading] = useState(false); | |||
| 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 [openConfirmDialog, setOpenConfirmDialog] = useState(false); | |||
| const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All"); | |||
| @@ -106,41 +116,40 @@ const criteria: Criterion<PickerSearchKey>[] = [ | |||
| const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => { | |||
| setFilterSectionDescription(inputs.sectionDescription || "All"); | |||
| setFilterStockTakeSession(inputs.stockTakeSession || ""); | |||
| fetchStockTakeSessions(0, pageSize, { | |||
| sectionDescription: inputs.sectionDescription || "All", | |||
| stockTakeSections: inputs.stockTakeSession ?? "", | |||
| }); | |||
| onListPageChange(0); | |||
| }; | |||
| const handleResetSearch = () => { | |||
| setFilterSectionDescription("All"); | |||
| 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 : []); | |||
| setTotal(res.total || 0); | |||
| setPage(pageNum); | |||
| } catch (e) { | |||
| }) | |||
| .catch((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 paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | |||
| @@ -161,13 +170,14 @@ const handleResetSearch = () => { | |||
| console.log(message); | |||
| await fetchStockTakeSessions(0, pageSize); | |||
| onListPageChange(0); | |||
| setListRefreshNonce((n) => n + 1); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setCreating(false); | |||
| } | |||
| }, [fetchStockTakeSessions, t]); | |||
| }, [onListPageChange, t]); | |||
| useEffect(() => { | |||
| fetchStockTakeSections() | |||
| .then((sections) => { | |||
| @@ -376,7 +386,7 @@ const handleResetSearch = () => { | |||
| page={page} | |||
| rowsPerPage={pageSize} | |||
| onPageChange={(e, newPage) => { | |||
| fetchStockTakeSessions(newPage, pageSize); | |||
| onListPageChange(newPage); | |||
| }} | |||
| rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死 | |||
| /> | |||
| @@ -599,13 +599,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| <TextField | |||
| size="small" | |||
| value={inputs.remark} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||
| // onKeyDown={blockNonIntegerKeys} | |||
| //inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| // const clean = sanitizeIntegerInput(e.target.value); | |||
| setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: clean } | |||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: e.target.value } | |||
| })); | |||
| }} | |||
| sx={{ width: 150 }} | |||
| @@ -771,15 +771,15 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <TextField | |||
| size="small" | |||
| value={recordInputs[detail.id]?.remark || ""} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||
| // onKeyDown={blockNonIntegerKeys} | |||
| //inputProps={{ inputMode: "text", pattern: "[0-9]*" }} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| // const clean = sanitizeIntegerInput(e.target.value); | |||
| setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { | |||
| ...(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 [approverSession, setApproverSession] = useState<AllPickedStockTakeListReponse | null>(null); | |||
| const [approverLoading, setApproverLoading] = useState(false); | |||
| /** 從卡片列表進入明細後返回時保留分頁 */ | |||
| const [pickerListPage, setPickerListPage] = useState(0); | |||
| const [pickerListPageSize] = useState(6); | |||
| const [snackbar, setSnackbar] = useState<{ | |||
| open: boolean; | |||
| message: string; | |||
| @@ -120,7 +123,10 @@ const StockTakeTab: React.FC = () => { | |||
| </Tabs> | |||
| {tabValue === 0 && ( | |||
| <PickerCardList | |||
| <PickerCardList | |||
| page={pickerListPage} | |||
| pageSize={pickerListPageSize} | |||
| onListPageChange={setPickerListPage} | |||
| onCardClick={(session) => { | |||
| setViewScope("picker"); | |||
| handleCardClick(session); | |||
| @@ -8,6 +8,10 @@ | |||
| "UoM": "單位", | |||
| "Approver Pending": "審核待處理", | |||
| "Approver Approved": "審核通過", | |||
| "Approver Time": "審核時間", | |||
| "Total need stock take": "總需盤點數量", | |||
| "Waiting for Approver": "待審核數量", | |||
| "Total Approved": "已審核數量", | |||
| "mat": "物料", | |||
| "variance": "差異", | |||
| "Plan Start Date": "計劃開始日期", | |||
| @@ -314,6 +314,7 @@ | |||
| "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", | |||
| "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. 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:":"預期批次:", | |||
| "Scanned Lot:":"掃描批次:", | |||
| "Confirm":"確認", | |||
| @@ -324,6 +325,8 @@ | |||
| "Print DN Label":"列印送貨單標籤", | |||
| "Print All Draft" : "列印全部草稿", | |||
| "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 碼驗證成功。", | |||
| "Order Finished":"訂單完成", | |||
| "Submitted Status":"提交狀態", | |||
| @@ -6,6 +6,7 @@ export const PRIVATE_ROUTES = [ | |||
| "/po/workbench", | |||
| "/ps", | |||
| "/bagPrint", | |||
| "/laserPrint", | |||
| "/report", | |||
| "/invoice", | |||
| "/projects", | |||