|
|
|
@@ -0,0 +1,445 @@ |
|
|
|
"use client"; |
|
|
|
|
|
|
|
import React, { useCallback, useEffect, useState } from "react"; |
|
|
|
import { |
|
|
|
Box, |
|
|
|
Button, |
|
|
|
FormControl, |
|
|
|
InputLabel, |
|
|
|
MenuItem, |
|
|
|
Select, |
|
|
|
Stack, |
|
|
|
Typography, |
|
|
|
Paper, |
|
|
|
CircularProgress, |
|
|
|
SelectChangeEvent, |
|
|
|
Dialog, |
|
|
|
DialogTitle, |
|
|
|
DialogContent, |
|
|
|
DialogActions, |
|
|
|
TextField, |
|
|
|
Snackbar, |
|
|
|
} from "@mui/material"; |
|
|
|
import ChevronLeft from "@mui/icons-material/ChevronLeft"; |
|
|
|
import ChevronRight from "@mui/icons-material/ChevronRight"; |
|
|
|
import Settings from "@mui/icons-material/Settings"; |
|
|
|
import Print from "@mui/icons-material/Print"; |
|
|
|
import Download from "@mui/icons-material/Download"; |
|
|
|
import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions"; |
|
|
|
import dayjs from "dayjs"; |
|
|
|
|
|
|
|
// Light blue theme (matching Python Bag1) |
|
|
|
const BG_TOP = "#E8F4FC"; |
|
|
|
const BG_LIST = "#D4E8F7"; |
|
|
|
const BG_ROW = "#C5E1F5"; |
|
|
|
const BG_ROW_SELECTED = "#6BB5FF"; |
|
|
|
const BG_STATUS_ERROR = "#FFCCCB"; |
|
|
|
const BG_STATUS_OK = "#90EE90"; |
|
|
|
const FG_STATUS_ERROR = "#B22222"; |
|
|
|
const FG_STATUS_OK = "#006400"; |
|
|
|
|
|
|
|
const PRINTER_OPTIONS = [ |
|
|
|
{ value: "dataflex", label: "打袋機 DataFlex" }, |
|
|
|
{ value: "laser", label: "激光機" }, |
|
|
|
]; |
|
|
|
|
|
|
|
const REFRESH_MS = 60 * 1000; |
|
|
|
const PRINTER_CHECK_MS = 60 * 1000; |
|
|
|
const PRINTER_RETRY_MS = 30 * 1000; |
|
|
|
const SETTINGS_KEY = "bagPrint_settings"; |
|
|
|
|
|
|
|
const DEFAULT_SETTINGS = { |
|
|
|
dabag_ip: "", |
|
|
|
dabag_port: "3008", |
|
|
|
laser_ip: "192.168.17.10", |
|
|
|
laser_port: "45678", |
|
|
|
}; |
|
|
|
|
|
|
|
function loadSettings(): typeof DEFAULT_SETTINGS { |
|
|
|
if (typeof window === "undefined") return DEFAULT_SETTINGS; |
|
|
|
try { |
|
|
|
const s = localStorage.getItem(SETTINGS_KEY); |
|
|
|
if (s) return { ...DEFAULT_SETTINGS, ...JSON.parse(s) }; |
|
|
|
} catch {} |
|
|
|
return DEFAULT_SETTINGS; |
|
|
|
} |
|
|
|
|
|
|
|
function saveSettings(s: typeof DEFAULT_SETTINGS) { |
|
|
|
if (typeof window === "undefined") return; |
|
|
|
try { |
|
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); |
|
|
|
} catch {} |
|
|
|
} |
|
|
|
|
|
|
|
function formatQty(val: number | null | undefined): string { |
|
|
|
if (val == null) return "—"; |
|
|
|
try { |
|
|
|
const n = Number(val); |
|
|
|
if (Number.isInteger(n)) return n.toLocaleString(); |
|
|
|
return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }).replace(/\.?0+$/, ""); |
|
|
|
} catch { |
|
|
|
return String(val); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function getBatch(jo: JobOrderListItem): string { |
|
|
|
return (jo.lotNo || "—").trim() || "—"; |
|
|
|
} |
|
|
|
|
|
|
|
const BagPrintSearch: React.FC = () => { |
|
|
|
const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); |
|
|
|
const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); |
|
|
|
const [loading, setLoading] = useState(true); |
|
|
|
const [error, setError] = useState<string | null>(null); |
|
|
|
const [connected, setConnected] = useState(false); |
|
|
|
const [printer, setPrinter] = useState<string>("dataflex"); |
|
|
|
const [selectedId, setSelectedId] = useState<number | null>(null); |
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false); |
|
|
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); |
|
|
|
const [settings, setSettings] = useState(DEFAULT_SETTINGS); |
|
|
|
const [printerConnected, setPrinterConnected] = useState(false); |
|
|
|
const [printerMessage, setPrinterMessage] = useState("列印機未連接"); |
|
|
|
const [downloadingOnPack, setDownloadingOnPack] = useState(false); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
setSettings(loadSettings()); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const loadJobOrders = useCallback(async (fromUserChange = false) => { |
|
|
|
setLoading(true); |
|
|
|
setError(null); |
|
|
|
try { |
|
|
|
const data = await fetchJobOrders(planDate); |
|
|
|
setJobOrders(data); |
|
|
|
setConnected(true); |
|
|
|
if (fromUserChange) setSelectedId(null); |
|
|
|
} catch (e) { |
|
|
|
setError(e instanceof Error ? e.message : "連接不到服務器"); |
|
|
|
setConnected(false); |
|
|
|
setJobOrders([]); |
|
|
|
} finally { |
|
|
|
setLoading(false); |
|
|
|
} |
|
|
|
}, [planDate]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
loadJobOrders(true); |
|
|
|
}, [planDate]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!connected) return; |
|
|
|
const id = setInterval(() => loadJobOrders(false), REFRESH_MS); |
|
|
|
return () => clearInterval(id); |
|
|
|
}, [connected, loadJobOrders]); |
|
|
|
|
|
|
|
const checkCurrentPrinter = useCallback(async () => { |
|
|
|
try { |
|
|
|
const request = |
|
|
|
printer === "dataflex" |
|
|
|
? { |
|
|
|
printerType: "dataflex" as const, |
|
|
|
printerIp: settings.dabag_ip, |
|
|
|
printerPort: Number(settings.dabag_port || 3008), |
|
|
|
} |
|
|
|
: { |
|
|
|
printerType: "laser" as const, |
|
|
|
printerIp: settings.laser_ip, |
|
|
|
printerPort: Number(settings.laser_port || 45678), |
|
|
|
}; |
|
|
|
|
|
|
|
const result = await checkPrinterStatus(request); |
|
|
|
setPrinterConnected(result.connected); |
|
|
|
setPrinterMessage(result.message); |
|
|
|
} catch (e) { |
|
|
|
setPrinterConnected(false); |
|
|
|
setPrinterMessage(e instanceof Error ? e.message : "列印機狀態檢查失敗"); |
|
|
|
} |
|
|
|
}, [printer, settings]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
checkCurrentPrinter(); |
|
|
|
}, [checkCurrentPrinter]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS; |
|
|
|
const id = setInterval(() => { |
|
|
|
checkCurrentPrinter(); |
|
|
|
}, intervalMs); |
|
|
|
|
|
|
|
return () => clearInterval(id); |
|
|
|
}, [printerConnected, checkCurrentPrinter]); |
|
|
|
|
|
|
|
const goPrevDay = () => { |
|
|
|
setPlanDate((d) => dayjs(d).subtract(1, "day").format("YYYY-MM-DD")); |
|
|
|
}; |
|
|
|
|
|
|
|
const goNextDay = () => { |
|
|
|
setPlanDate((d) => dayjs(d).add(1, "day").format("YYYY-MM-DD")); |
|
|
|
}; |
|
|
|
|
|
|
|
const handlePrinterChange = (e: SelectChangeEvent<string>) => { |
|
|
|
setPrinter(e.target.value); |
|
|
|
}; |
|
|
|
|
|
|
|
const handleRowClick = (jo: JobOrderListItem) => { |
|
|
|
setSelectedId(jo.id); |
|
|
|
const batch = getBatch(jo); |
|
|
|
const itemCode = jo.itemCode || "—"; |
|
|
|
const itemName = jo.itemName || "—"; |
|
|
|
setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" }); |
|
|
|
// TODO: Actual printing would require backend API to proxy to printer (TCP/serial) |
|
|
|
// For now, show info. Backend could add /py/print-dataflex, /py/print-label, /py/print-laser |
|
|
|
}; |
|
|
|
|
|
|
|
const handleDownloadOnPackQr = async () => { |
|
|
|
const onPackJobOrders = jobOrders |
|
|
|
.map((jobOrder) => ({ |
|
|
|
jobOrderId: jobOrder.id, |
|
|
|
itemCode: jobOrder.itemCode?.trim() || "", |
|
|
|
})) |
|
|
|
.filter((jobOrder) => jobOrder.itemCode.length > 0); |
|
|
|
|
|
|
|
if (onPackJobOrders.length === 0) { |
|
|
|
setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
setDownloadingOnPack(true); |
|
|
|
try { |
|
|
|
const blob = await downloadOnPackQrZip({ |
|
|
|
jobOrders: onPackJobOrders, |
|
|
|
}); |
|
|
|
|
|
|
|
const url = window.URL.createObjectURL(blob); |
|
|
|
const link = document.createElement("a"); |
|
|
|
link.href = url; |
|
|
|
link.setAttribute("download", `onpack_qr_${planDate}.zip`); |
|
|
|
document.body.appendChild(link); |
|
|
|
link.click(); |
|
|
|
link.remove(); |
|
|
|
window.URL.revokeObjectURL(url); |
|
|
|
|
|
|
|
setSnackbar({ open: true, message: "OnPack QR code ZIP 已下載", severity: "success" }); |
|
|
|
} catch (e) { |
|
|
|
setSnackbar({ |
|
|
|
open: true, |
|
|
|
message: e instanceof Error ? e.message : "下載 OnPack QR code 失敗", |
|
|
|
severity: "error", |
|
|
|
}); |
|
|
|
} finally { |
|
|
|
setDownloadingOnPack(false); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
return ( |
|
|
|
<Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> |
|
|
|
{/* Top: date nav + printer + settings */} |
|
|
|
<Paper sx={{ p: 2, mb: 2, backgroundColor: BG_TOP }}> |
|
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}> |
|
|
|
<Stack direction="row" alignItems="center" spacing={2}> |
|
|
|
<Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}> |
|
|
|
前一天 |
|
|
|
</Button> |
|
|
|
<TextField |
|
|
|
type="date" |
|
|
|
value={planDate} |
|
|
|
onChange={(e) => setPlanDate(e.target.value)} |
|
|
|
size="small" |
|
|
|
sx={{ width: 160 }} |
|
|
|
InputLabelProps={{ shrink: true }} |
|
|
|
/> |
|
|
|
<Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}> |
|
|
|
後一天 |
|
|
|
</Button> |
|
|
|
</Stack> |
|
|
|
<Stack direction="row" alignItems="center" spacing={2}> |
|
|
|
<Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> |
|
|
|
設定 |
|
|
|
</Button> |
|
|
|
<Box |
|
|
|
sx={{ |
|
|
|
px: 1.5, |
|
|
|
py: 0.75, |
|
|
|
borderRadius: 1, |
|
|
|
backgroundColor: printerConnected ? BG_STATUS_OK : BG_STATUS_ERROR, |
|
|
|
color: printerConnected ? FG_STATUS_OK : FG_STATUS_ERROR, |
|
|
|
fontWeight: 600, |
|
|
|
whiteSpace: "nowrap", |
|
|
|
}} |
|
|
|
title={printerMessage} |
|
|
|
> |
|
|
|
列印機: |
|
|
|
</Box> |
|
|
|
<FormControl size="small" sx={{ minWidth: 180 }}> |
|
|
|
<InputLabel>列印機</InputLabel> |
|
|
|
<Select value={printer} label="列印機" onChange={handlePrinterChange}> |
|
|
|
{PRINTER_OPTIONS.map((opt) => ( |
|
|
|
<MenuItem key={opt.value} value={opt.value}> |
|
|
|
{opt.label} |
|
|
|
</MenuItem> |
|
|
|
))} |
|
|
|
</Select> |
|
|
|
</FormControl> |
|
|
|
</Stack> |
|
|
|
</Stack> |
|
|
|
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}> |
|
|
|
{printerMessage} |
|
|
|
</Typography> |
|
|
|
<Stack direction="row" sx={{ mt: 2 }}> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
startIcon={<Download />} |
|
|
|
onClick={handleDownloadOnPackQr} |
|
|
|
disabled={loading || downloadingOnPack || jobOrders.length === 0} |
|
|
|
> |
|
|
|
{downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} |
|
|
|
</Button> |
|
|
|
</Stack> |
|
|
|
</Paper> |
|
|
|
|
|
|
|
{/* Job orders list */} |
|
|
|
<Paper sx={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", backgroundColor: BG_LIST }}> |
|
|
|
{loading ? ( |
|
|
|
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", py: 8 }}> |
|
|
|
<CircularProgress /> |
|
|
|
</Box> |
|
|
|
) : jobOrders.length === 0 ? ( |
|
|
|
<Box sx={{ py: 8, textAlign: "center" }}> |
|
|
|
<Typography color="text.secondary">當日無工單</Typography> |
|
|
|
</Box> |
|
|
|
) : ( |
|
|
|
<Box sx={{ overflow: "auto", flex: 1, p: 2 }}> |
|
|
|
<Stack spacing={1}> |
|
|
|
{jobOrders.map((jo) => { |
|
|
|
const batch = getBatch(jo); |
|
|
|
const qtyStr = formatQty(jo.reqQty); |
|
|
|
const isSelected = selectedId === jo.id; |
|
|
|
return ( |
|
|
|
<Paper |
|
|
|
key={jo.id} |
|
|
|
elevation={1} |
|
|
|
sx={{ |
|
|
|
p: 2, |
|
|
|
display: "flex", |
|
|
|
alignItems: "flex-start", |
|
|
|
gap: 2, |
|
|
|
cursor: "pointer", |
|
|
|
backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW, |
|
|
|
"&:hover": { backgroundColor: isSelected ? BG_ROW_SELECTED : "#b8d4eb" }, |
|
|
|
transition: "background-color 0.2s", |
|
|
|
}} |
|
|
|
onClick={() => handleRowClick(jo)} |
|
|
|
> |
|
|
|
<Box sx={{ minWidth: 120, flexShrink: 0 }}> |
|
|
|
<Typography variant="h6" sx={{ fontSize: "1.1rem" }}> |
|
|
|
{batch} |
|
|
|
</Typography> |
|
|
|
{qtyStr !== "—" && ( |
|
|
|
<Typography variant="body2" color="text.secondary"> |
|
|
|
數量:{qtyStr} |
|
|
|
</Typography> |
|
|
|
)} |
|
|
|
</Box> |
|
|
|
<Box sx={{ minWidth: 140, flexShrink: 0 }}> |
|
|
|
<Typography variant="h6" sx={{ fontSize: "1.1rem" }}> |
|
|
|
{jo.code || "—"} |
|
|
|
</Typography> |
|
|
|
</Box> |
|
|
|
<Box sx={{ minWidth: 140, flexShrink: 0 }}> |
|
|
|
<Typography variant="h6" sx={{ fontSize: "1.35rem" }}> |
|
|
|
{jo.itemCode || "—"} |
|
|
|
</Typography> |
|
|
|
</Box> |
|
|
|
<Box sx={{ flex: 1, minWidth: 0 }}> |
|
|
|
<Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}> |
|
|
|
{jo.itemName || "—"} |
|
|
|
</Typography> |
|
|
|
</Box> |
|
|
|
<Button |
|
|
|
size="small" |
|
|
|
variant="contained" |
|
|
|
startIcon={<Print />} |
|
|
|
onClick={(e) => { |
|
|
|
e.stopPropagation(); |
|
|
|
handleRowClick(jo); |
|
|
|
}} |
|
|
|
> |
|
|
|
列印 |
|
|
|
</Button> |
|
|
|
</Paper> |
|
|
|
); |
|
|
|
})} |
|
|
|
</Stack> |
|
|
|
</Box> |
|
|
|
)} |
|
|
|
</Paper> |
|
|
|
|
|
|
|
{/* Settings dialog */} |
|
|
|
<Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> |
|
|
|
<DialogTitle>設定</DialogTitle> |
|
|
|
<DialogContent> |
|
|
|
<Stack spacing={2} sx={{ mt: 1 }}> |
|
|
|
<Typography variant="subtitle2" color="primary"> |
|
|
|
打袋機 DataFlex |
|
|
|
</Typography> |
|
|
|
<TextField |
|
|
|
label="IP" |
|
|
|
size="small" |
|
|
|
value={settings.dabag_ip} |
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, dabag_ip: e.target.value }))} |
|
|
|
fullWidth |
|
|
|
/> |
|
|
|
<TextField |
|
|
|
label="Port" |
|
|
|
size="small" |
|
|
|
value={settings.dabag_port} |
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, dabag_port: e.target.value }))} |
|
|
|
fullWidth |
|
|
|
/> |
|
|
|
<Typography variant="subtitle2" color="primary"> |
|
|
|
激光機 |
|
|
|
</Typography> |
|
|
|
<TextField |
|
|
|
label="IP" |
|
|
|
size="small" |
|
|
|
value={settings.laser_ip} |
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, laser_ip: e.target.value }))} |
|
|
|
fullWidth |
|
|
|
/> |
|
|
|
<TextField |
|
|
|
label="Port" |
|
|
|
size="small" |
|
|
|
value={settings.laser_port} |
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, laser_port: e.target.value }))} |
|
|
|
fullWidth |
|
|
|
/> |
|
|
|
</Stack> |
|
|
|
</DialogContent> |
|
|
|
<DialogActions> |
|
|
|
<Button onClick={() => setSettingsOpen(false)}>取消</Button> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
onClick={() => { |
|
|
|
saveSettings(settings); |
|
|
|
setSnackbar({ open: true, message: "設定已儲存", severity: "success" }); |
|
|
|
setSettingsOpen(false); |
|
|
|
checkCurrentPrinter(); |
|
|
|
}} |
|
|
|
> |
|
|
|
儲存 |
|
|
|
</Button> |
|
|
|
</DialogActions> |
|
|
|
</Dialog> |
|
|
|
|
|
|
|
<Snackbar |
|
|
|
open={snackbar.open} |
|
|
|
autoHideDuration={3000} |
|
|
|
onClose={() => setSnackbar((s) => ({ ...s, open: false }))} |
|
|
|
message={snackbar.message} |
|
|
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }} |
|
|
|
/> |
|
|
|
</Box> |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
export default BagPrintSearch; |