| @@ -0,0 +1,22 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerSession } from "next-auth"; | |||
| import { redirect } from "next/navigation"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { AUTH } from "@/authorities"; | |||
| export const metadata: Metadata = { | |||
| title: "M18 Sync", | |||
| }; | |||
| export default async function M18SynLayout({ | |||
| children, | |||
| }: { | |||
| children: React.ReactNode; | |||
| }) { | |||
| const session = await getServerSession(authOptions); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| if (!abilities.includes(AUTH.ADMIN)) { | |||
| redirect("/dashboard"); | |||
| } | |||
| return <>{children}</>; | |||
| } | |||
| @@ -0,0 +1,239 @@ | |||
| "use client"; | |||
| import React, { useState } from "react"; | |||
| import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| interface TabPanelProps { | |||
| children?: React.ReactNode; | |||
| index: number; | |||
| value: number; | |||
| } | |||
| function TabPanel(props: TabPanelProps) { | |||
| const { children, value, index, ...other } = props; | |||
| return ( | |||
| <div | |||
| role="tabpanel" | |||
| hidden={value !== index} | |||
| id={`m18syn-tabpanel-${index}`} | |||
| aria-labelledby={`m18syn-tab-${index}`} | |||
| {...other} | |||
| > | |||
| {value === index && <Box sx={{ p: 3 }}>{children}</Box>} | |||
| </div> | |||
| ); | |||
| } | |||
| export default function M18SynPage() { | |||
| const [tabValue, setTabValue] = useState(0); | |||
| const [m18PoCode, setM18PoCode] = useState(""); | |||
| const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); | |||
| const [m18PoSyncResult, setM18PoSyncResult] = useState<string>(""); | |||
| const [m18DoCode, setM18DoCode] = useState(""); | |||
| const [isSyncingM18Do, setIsSyncingM18Do] = useState(false); | |||
| const [m18DoSyncResult, setM18DoSyncResult] = useState<string>(""); | |||
| const [m18ProductCode, setM18ProductCode] = useState(""); | |||
| const [isSyncingM18Product, setIsSyncingM18Product] = useState(false); | |||
| const [m18ProductSyncResult, setM18ProductSyncResult] = useState<string>(""); | |||
| const handleSyncM18PoByCode = async () => { | |||
| if (!m18PoCode.trim()) { | |||
| alert("Please enter PO code."); | |||
| return; | |||
| } | |||
| setIsSyncingM18Po(true); | |||
| setM18PoSyncResult(""); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| const text = await response.text(); | |||
| setM18PoSyncResult(text); | |||
| if (!response.ok) { | |||
| alert(`Sync failed: ${response.status}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("M18 PO Sync By Code Error:", e); | |||
| alert("M18 PO sync failed. Check console/network."); | |||
| } finally { | |||
| setIsSyncingM18Po(false); | |||
| } | |||
| }; | |||
| 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 handleSyncM18ProductByCode = async () => { | |||
| if (!m18ProductCode.trim()) { | |||
| alert("Please enter M18 item / product code."); | |||
| return; | |||
| } | |||
| setIsSyncingM18Product(true); | |||
| setM18ProductSyncResult(""); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/product-by-code?code=${encodeURIComponent(m18ProductCode.trim())}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| const text = await response.text(); | |||
| setM18ProductSyncResult(text); | |||
| if (!response.ok) { | |||
| alert(`Sync failed: ${response.status}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("M18 Product Sync By Code Error:", e); | |||
| alert("M18 product sync failed. Check console/network."); | |||
| } finally { | |||
| setIsSyncingM18Product(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>} | |||
| </Paper> | |||
| ); | |||
| return ( | |||
| <Box sx={{ p: 4 }}> | |||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: "bold" }}> | |||
| M18 Sync (by code) | |||
| </Typography> | |||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | |||
| ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||
| </Typography> | |||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | |||
| <Tab label="1. PO" id="m18syn-tab-0" /> | |||
| <Tab label="2. DO" id="m18syn-tab-1" /> | |||
| <Tab label="3. Product" id="m18syn-tab-2" /> | |||
| </Tabs> | |||
| <TabPanel value={tabValue} index={0}> | |||
| <Section title="M18 Purchase Order — sync by code"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||
| <TextField | |||
| size="small" | |||
| label="PO Code" | |||
| value={m18PoCode} | |||
| onChange={(e) => setM18PoCode(e.target.value)} | |||
| placeholder="e.g. PFP002PO26030341" | |||
| sx={{ minWidth: 320 }} | |||
| /> | |||
| <Button variant="contained" color="primary" onClick={handleSyncM18PoByCode} disabled={isSyncingM18Po}> | |||
| {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | |||
| </Button> | |||
| </Stack> | |||
| {m18PoSyncResult ? ( | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={4} | |||
| margin="normal" | |||
| label="Sync Result" | |||
| value={m18PoSyncResult} | |||
| InputProps={{ readOnly: true }} | |||
| /> | |||
| ) : null} | |||
| </Section> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={1}> | |||
| <Section title="M18 Delivery Order — 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> | |||
| {m18DoSyncResult ? ( | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={4} | |||
| margin="normal" | |||
| label="Sync Result" | |||
| value={m18DoSyncResult} | |||
| InputProps={{ readOnly: true }} | |||
| /> | |||
| ) : null} | |||
| </Section> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={2}> | |||
| <Section title="M18 Product / material — sync by code"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||
| <TextField | |||
| size="small" | |||
| label="Item / product code" | |||
| value={m18ProductCode} | |||
| onChange={(e) => setM18ProductCode(e.target.value)} | |||
| placeholder="e.g. PP1175 (M18 item code)" | |||
| sx={{ minWidth: 320 }} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleSyncM18ProductByCode} | |||
| disabled={isSyncingM18Product} | |||
| > | |||
| {isSyncingM18Product ? "Syncing..." : "Sync product from M18"} | |||
| </Button> | |||
| </Stack> | |||
| {m18ProductSyncResult ? ( | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={4} | |||
| margin="normal" | |||
| label="Sync Result" | |||
| value={m18ProductSyncResult} | |||
| InputProps={{ readOnly: true }} | |||
| /> | |||
| ) : null} | |||
| </Section> | |||
| </TabPanel> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -29,6 +29,10 @@ import { | |||
| pushOnPackTextQrZipToNgpcl, | |||
| type JobOrderListItem, | |||
| } from "@/app/api/bagPrint/actions"; | |||
| import { | |||
| runLaserBag2AutoSend, | |||
| type LaserBag2AutoSendReport, | |||
| } from "@/app/api/laserPrint/actions"; | |||
| import * as XLSX from "xlsx"; | |||
| interface TabPanelProps { | |||
| @@ -61,15 +65,7 @@ export default function TestingPage() { | |||
| // --- 1. GRN Preview (M18) --- | |||
| const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); | |||
| // --- 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>(""); | |||
| // --- 4. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) --- | |||
| // --- 2. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) --- | |||
| const [onpackPlanDate, setOnpackPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | |||
| const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>([]); | |||
| const [onpackLoading, setOnpackLoading] = useState(false); | |||
| @@ -77,11 +73,17 @@ export default function TestingPage() { | |||
| const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false); | |||
| const [onpackPushLoading, setOnpackPushLoading] = useState(false); | |||
| const [onpackPushResult, setOnpackPushResult] = useState<string | null>(null); | |||
| // --- 3. Laser Bag2 auto-send (same as /laserPrint + DB LASER_PRINT.*) --- | |||
| const [laserAutoPlanDate, setLaserAutoPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | |||
| const [laserAutoLimit, setLaserAutoLimit] = useState("1"); | |||
| const [laserAutoLoading, setLaserAutoLoading] = useState(false); | |||
| const [laserAutoReport, setLaserAutoReport] = useState<LaserBag2AutoSendReport | null>(null); | |||
| const [laserAutoError, setLaserAutoError] = useState<string | null>(null); | |||
| const onpackPayload = useMemo(() => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders]); | |||
| useEffect(() => { | |||
| if (tabValue !== 3) return; | |||
| if (tabValue !== 1) return; | |||
| let cancelled = false; | |||
| (async () => { | |||
| setOnpackLoading(true); | |||
| @@ -138,58 +140,6 @@ export default function TestingPage() { | |||
| } | |||
| }; | |||
| const handleSyncM18PoByCode = async () => { | |||
| if (!m18PoCode.trim()) { | |||
| alert("Please enter PO code."); | |||
| return; | |||
| } | |||
| setIsSyncingM18Po(true); | |||
| setM18PoSyncResult(""); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| const text = await response.text(); | |||
| setM18PoSyncResult(text); | |||
| if (!response.ok) { | |||
| alert(`Sync failed: ${response.status}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("M18 PO Sync By Code Error:", e); | |||
| alert("M18 PO sync failed. Check console/network."); | |||
| } finally { | |||
| setIsSyncingM18Po(false); | |||
| } | |||
| }; | |||
| 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 downloadBlob = (blob: Blob, filename: string) => { | |||
| const url = window.URL.createObjectURL(blob); | |||
| const link = document.createElement("a"); | |||
| @@ -218,6 +168,24 @@ export default function TestingPage() { | |||
| } | |||
| }; | |||
| const handleLaserBag2AutoSend = async () => { | |||
| setLaserAutoLoading(true); | |||
| setLaserAutoError(null); | |||
| setLaserAutoReport(null); | |||
| try { | |||
| const lim = parseInt(laserAutoLimit.trim(), 10); | |||
| const report = await runLaserBag2AutoSend({ | |||
| planStart: laserAutoPlanDate, | |||
| limitPerRun: Number.isFinite(lim) ? lim : 0, | |||
| }); | |||
| setLaserAutoReport(report); | |||
| } catch (e) { | |||
| setLaserAutoError(e instanceof Error ? e.message : String(e)); | |||
| } finally { | |||
| setLaserAutoLoading(false); | |||
| } | |||
| }; | |||
| const handleOnpackPushNgpcl = async () => { | |||
| if (onpackPayload.length === 0) { | |||
| alert("No job orders with item code for this plan date."); | |||
| @@ -254,9 +222,8 @@ export default function TestingPage() { | |||
| <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" /> | |||
| <Tab label="4. OnPack NGPCL" /> | |||
| <Tab label="2. OnPack NGPCL" /> | |||
| <Tab label="3. Laser Bag2 自動送" /> | |||
| </Tabs> | |||
| <TabPanel value={tabValue} index={0}> | |||
| @@ -287,71 +254,7 @@ export default function TestingPage() { | |||
| </TabPanel> | |||
| <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" | |||
| label="PO Code" | |||
| value={m18PoCode} | |||
| onChange={(e) => setM18PoCode(e.target.value)} | |||
| placeholder="e.g. PFP002PO26030341" | |||
| sx={{ minWidth: 320 }} | |||
| /> | |||
| <Button variant="contained" color="primary" onClick={handleSyncM18PoByCode} disabled={isSyncingM18Po}> | |||
| {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | |||
| </Button> | |||
| </Stack> | |||
| <Typography variant="body2" color="textSecondary"> | |||
| Backend endpoint: <code>/m18/test/po-by-code?code=YOUR_CODE</code> | |||
| </Typography> | |||
| {m18PoSyncResult ? ( | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={4} | |||
| margin="normal" | |||
| label="Sync Result" | |||
| value={m18PoSyncResult} | |||
| InputProps={{ readOnly: true }} | |||
| /> | |||
| ) : null} | |||
| </Section> | |||
| </TabPanel> | |||
| <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> | |||
| <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> | |||
| <TabPanel value={tabValue} index={3}> | |||
| <Section title="4. OnPack NGPCL (same logic as /bagPrint)"> | |||
| <Section title="2. OnPack NGPCL (same logic as /bagPrint)"> | |||
| <Alert severity="info" sx={{ mb: 2 }}> | |||
| Uses <strong>GET /py/job-orders?planStart=</strong> for the day, then the same <code>jobOrders</code> payload as{" "} | |||
| <strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains loose <code>.job</code> / <code>.image</code> / BMPs — extract | |||
| @@ -445,6 +348,55 @@ export default function TestingPage() { | |||
| </Typography> | |||
| </Section> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={2}> | |||
| <Section title="3. Laser Bag2 自動送(與 /laserPrint 相同邏輯)"> | |||
| <Alert severity="warning" sx={{ mb: 2 }}> | |||
| 依資料庫 <strong>LASER_PRINT.host</strong>、<strong>LASER_PRINT.port</strong>、<strong>LASER_PRINT.itemCodes</strong> 查當日包裝工單並送 TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。 | |||
| 排程預設關閉;啟用請設 <code>laser.bag2.auto-send.enabled=true</code>(後端 application.yml)。 | |||
| </Alert> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}> | |||
| <TextField | |||
| size="small" | |||
| label="Plan date (planStart)" | |||
| type="date" | |||
| value={laserAutoPlanDate} | |||
| onChange={(e) => setLaserAutoPlanDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label="limitPerRun(0=全部符合)" | |||
| value={laserAutoLimit} | |||
| onChange={(e) => setLaserAutoLimit(e.target.value)} | |||
| sx={{ width: 200 }} | |||
| helperText="手動測試建議 1;排程預設每分鐘最多 1 筆" | |||
| /> | |||
| <Button variant="contained" color="primary" onClick={() => void handleLaserBag2AutoSend()} disabled={laserAutoLoading}> | |||
| {laserAutoLoading ? "送出中…" : "執行 POST /plastic/laser-bag2-auto-send"} | |||
| </Button> | |||
| </Stack> | |||
| {laserAutoError ? ( | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {laserAutoError} | |||
| </Alert> | |||
| ) : null} | |||
| {laserAutoReport ? ( | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={8} | |||
| label="回應(LaserBag2AutoSendReport)" | |||
| value={JSON.stringify(laserAutoReport, null, 2)} | |||
| InputProps={{ readOnly: true }} | |||
| sx={{ fontFamily: "monospace" }} | |||
| /> | |||
| ) : null} | |||
| <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}> | |||
| <code>POST /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&limitPerRun=N</code> | |||
| </Typography> | |||
| </Section> | |||
| </TabPanel> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -128,6 +128,45 @@ export async function checkPrinterStatus(request: PrinterStatusRequest): Promise | |||
| return data; | |||
| } | |||
| export interface LaserBag2JobSendResult { | |||
| jobOrderId: number; | |||
| itemCode: string | null; | |||
| success: boolean; | |||
| message: string; | |||
| } | |||
| export interface LaserBag2AutoSendReport { | |||
| planStart: string; | |||
| jobOrdersFound: number; | |||
| jobOrdersProcessed: number; | |||
| results: LaserBag2JobSendResult[]; | |||
| } | |||
| /** | |||
| * Same workflow as /laserPrint row click: list job orders (LASER_PRINT.itemCodes) for planStart, | |||
| * then TCP send(s) using DB LASER_PRINT.host / port. limitPerRun 0 = all matches. | |||
| */ | |||
| export async function runLaserBag2AutoSend(params?: { | |||
| planStart?: string; | |||
| limitPerRun?: number; | |||
| }): Promise<LaserBag2AutoSendReport> { | |||
| const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); | |||
| if (!base) { | |||
| throw new Error("NEXT_PUBLIC_API_URL is not set."); | |||
| } | |||
| const sp = new URLSearchParams(); | |||
| if (params?.planStart) sp.set("planStart", params.planStart); | |||
| if (params?.limitPerRun != null) sp.set("limitPerRun", String(params.limitPerRun)); | |||
| const q = sp.toString(); | |||
| const url = `${base}/plastic/laser-bag2-auto-send${q ? `?${q}` : ""}`; | |||
| const res = await clientAuthFetch(url, { method: "POST" }); | |||
| if (!res.ok) { | |||
| const t = await res.text().catch(() => ""); | |||
| throw new Error(t || `HTTP ${res.status}`); | |||
| } | |||
| return res.json() as Promise<LaserBag2AutoSendReport>; | |||
| } | |||
| export async function patchSetting(name: string, value: string): Promise<void> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`; | |||
| const res = await clientAuthFetch(url, { | |||
| @@ -46,6 +46,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/putAway": "Put Away", | |||
| "/stockIssue": "Stock Issue", | |||
| "/report": "Report", | |||
| "/m18Syn": "M18 Sync", | |||
| "/bagPrint": "打袋機", | |||
| "/laserPrint": "檸檬機(激光機)", | |||
| "/settings/itemPrice": "Price Inquiry", | |||
| @@ -37,6 +37,7 @@ import Label from "@mui/icons-material/Label"; | |||
| import Checklist from "@mui/icons-material/Checklist"; | |||
| import Science from "@mui/icons-material/Science"; | |||
| import UploadFile from "@mui/icons-material/UploadFile"; | |||
| import Sync from "@mui/icons-material/Sync"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { usePathname } from "next/navigation"; | |||
| import Link from "next/link"; | |||
| @@ -194,6 +195,13 @@ const NavigationContent: React.FC = () => { | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <Sync />, | |||
| label: "M18 Sync", | |||
| path: "/m18Syn", | |||
| requiredAbility: [AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <ShowChart />, | |||
| label: "圖表報告", | |||
| @@ -1,6 +1,7 @@ | |||
| export const PRIVATE_ROUTES = [ | |||
| "/analytics", | |||
| "/dashboard", | |||
| "/m18Syn", | |||
| "/testing", | |||
| "/jo/testing", | |||
| "/po/workbench", | |||