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