Procházet zdrojové kódy

Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1

MergeProblem1
kelvin.yau před 4 dny
rodič
revize
6bea17fdd0
34 změnil soubory, kde provedl 3348 přidání a 874 odebrání
  1. +54
    -0
      src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts
  2. +1080
    -23
      src/app/(main)/chart/purchase/page.tsx
  3. +23
    -0
      src/app/(main)/laserPrint/page.tsx
  4. +1
    -1
      src/app/(main)/report/page.tsx
  5. +82
    -446
      src/app/(main)/testing/page.tsx
  6. +42
    -1
      src/app/api/bagPrint/actions.ts
  7. +41
    -1
      src/app/api/bom/client.ts
  8. +304
    -4
      src/app/api/chart/client.ts
  9. +135
    -0
      src/app/api/laserPrint/actions.ts
  10. +49
    -6
      src/app/api/stockTake/actions.ts
  11. +59
    -3
      src/components/BagPrint/BagPrintSearch.tsx
  12. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  13. +54
    -10
      src/components/DoSearch/DoSearch.tsx
  14. +3
    -1
      src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx
  15. +116
    -75
      src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
  16. +3
    -6
      src/components/FinishedGoodSearch/LotConfirmationModal.tsx
  17. +280
    -107
      src/components/ImportBom/ImportBomDetailTab.tsx
  18. +44
    -12
      src/components/Jodetail/newJobPickExecution.tsx
  19. +431
    -0
      src/components/LaserPrint/LaserPrintSearch.tsx
  20. +7
    -0
      src/components/NavigationContent/NavigationContent.tsx
  21. +1
    -2
      src/components/PickOrderSearch/AssignAndRelease.tsx
  22. +3
    -3
      src/components/PickOrderSearch/LotTable.tsx
  23. +3
    -16
      src/components/PickOrderSearch/PickExecution.tsx
  24. +15
    -7
      src/components/ProductionProcess/ProductionProcessList.tsx
  25. +4
    -2
      src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx
  26. +33
    -13
      src/components/StockIssue/SearchPage.tsx
  27. +413
    -92
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  28. +44
    -34
      src/components/StockTakeManagement/PickerCardList.tsx
  29. +4
    -4
      src/components/StockTakeManagement/PickerReStockTake.tsx
  30. +4
    -4
      src/components/StockTakeManagement/PickerStockTake.tsx
  31. +7
    -1
      src/components/StockTakeManagement/StockTakeTab.tsx
  32. +4
    -0
      src/i18n/zh/inventory.json
  33. +3
    -0
      src/i18n/zh/pickOrder.json
  34. +1
    -0
      src/routes.ts

+ 54
- 0
src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts Zobrazit soubor

@@ -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);
}

+ 1080
- 23
src/app/(main)/chart/purchase/page.tsx
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 23
- 0
src/app/(main)/laserPrint/page.tsx Zobrazit soubor

@@ -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;

+ 1
- 1
src/app/(main)/report/page.tsx Zobrazit soubor

@@ -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"


+ 82
- 446
src/app/(main)/testing/page.tsx Zobrazit soubor

@@ -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>
);
}
}

+ 42
- 1
src/app/api/bagPrint/actions.ts Zobrazit soubor

@@ -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();


+ 41
- 1
src/app/api/bom/client.ts Zobrazit soubor

@@ -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(),
}));
}

+ 304
- 4
src/app/api/chart/client.ts Zobrazit soubor

@@ -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


+ 135
- 0
src/app/api/laserPrint/actions.ts Zobrazit soubor

@@ -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}`);
}
}

+ 49
- 6
src/app/api/stockTake/actions.ts Zobrazit soubor

@@ -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 (


+ 59
- 3
src/components/BagPrint/BagPrintSearch.tsx Zobrazit soubor

@@ -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>



+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Zobrazit soubor

@@ -47,6 +47,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/stockIssue": "Stock Issue",
"/report": "Report",
"/bagPrint": "打袋機",
"/laserPrint": "檸檬機(激光機)",
"/settings/itemPrice": "Price Inquiry",
};



+ 54
- 10
src/components/DoSearch/DoSearch.tsx Zobrazit soubor

@@ -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,


+ 3
- 1
src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx Zobrazit soubor

@@ -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>("");


+ 116
- 75
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx Zobrazit soubor

@@ -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)


+ 3
- 6
src/components/FinishedGoodSearch/LotConfirmationModal.tsx Zobrazit soubor

@@ -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>


+ 280
- 107
src/components/ImportBom/ImportBomDetailTab.tsx Zobrazit soubor

@@ -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


+ 44
- 12
src/components/Jodetail/newJobPickExecution.tsx Zobrazit soubor

@@ -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 &&


+ 431
- 0
src/components/LaserPrint/LaserPrintSearch.tsx Zobrazit soubor

@@ -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;

+ 7
- 0
src/components/NavigationContent/NavigationContent.tsx Zobrazit soubor

@@ -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: "報告管理",


+ 1
- 2
src/components/PickOrderSearch/AssignAndRelease.tsx Zobrazit soubor

@@ -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>


+ 3
- 3
src/components/PickOrderSearch/LotTable.tsx Zobrazit soubor

@@ -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);


+ 3
- 16
src/components/PickOrderSearch/PickExecution.tsx Zobrazit soubor

@@ -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') {


+ 15
- 7
src/components/ProductionProcess/ProductionProcessList.tsx Zobrazit soubor

@@ -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")}


+ 4
- 2
src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx Zobrazit soubor

@@ -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();


+ 33
- 13
src/components/StockIssue/SearchPage.tsx Zobrazit soubor

@@ -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>
)}


+ 413
- 92
src/components/StockTakeManagement/ApproverStockTakeAll.tsx Zobrazit soubor

@@ -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>


+ 44
- 34
src/components/StockTakeManagement/PickerCardList.tsx Zobrazit soubor

@@ -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,就写死
/>


+ 4
- 4
src/components/StockTakeManagement/PickerReStockTake.tsx Zobrazit soubor

@@ -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 }}


+ 4
- 4
src/components/StockTakeManagement/PickerStockTake.tsx Zobrazit soubor

@@ -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
}
}));
}}


+ 7
- 1
src/components/StockTakeManagement/StockTakeTab.tsx Zobrazit soubor

@@ -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);


+ 4
- 0
src/i18n/zh/inventory.json Zobrazit soubor

@@ -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": "計劃開始日期",


+ 3
- 0
src/i18n/zh/pickOrder.json Zobrazit soubor

@@ -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":"提交狀態",


+ 1
- 0
src/routes.ts Zobrazit soubor

@@ -6,6 +6,7 @@ export const PRIVATE_ROUTES = [
"/po/workbench",
"/ps",
"/bagPrint",
"/laserPrint",
"/report",
"/invoice",
"/projects",


Načítá se…
Zrušit
Uložit