"use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, Button, CircularProgress, Paper, Stack, Tab, Table, TableBody, TableCell, TableHead, TableRow, Tabs, TextField, Typography, } from "@mui/material"; import { FileDownload } from "@mui/icons-material"; import dayjs from "dayjs"; import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import LotLabelPrintModal from "@/components/InventorySearch/LotLabelPrintModal"; import { buildOnPackJobOrdersPayload, downloadOnPackTextQrZip, fetchJobOrders, pushOnPackTextQrZipToNgpcl, type JobOrderListItem, } from "@/app/api/bagPrint/actions"; import { fetchLaserBag2Settings, runLaserBag2AutoSend, type LaserBag2AutoSendReport, type LaserLastReceiveSuccess, } from "@/app/api/laserPrint/actions"; import * as XLSX from "xlsx"; interface TabPanelProps { children?: React.ReactNode; index: number; value: number; } function TabPanel(props: TabPanelProps) { const { children, value, index, ...other } = props; return ( ); } export default function TestingPage() { const [tabValue, setTabValue] = useState(0); const [lotLabelModalOpen, setLotLabelModalOpen] = useState(false); const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); }; // --- 1. GRN Preview (M18) --- const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); // --- 2. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) --- const [onpackPlanDate, setOnpackPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"), ); const [onpackJobOrders, setOnpackJobOrders] = useState( [], ); const [onpackLoading, setOnpackLoading] = useState(false); const [onpackLoadError, setOnpackLoadError] = useState(null); const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false); const [onpackPushLoading, setOnpackPushLoading] = useState(false); const [onpackPushResult, setOnpackPushResult] = useState(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(null); const [laserAutoError, setLaserAutoError] = useState(null); const [laserLastReceive, setLaserLastReceive] = useState(null); const bomShopSyncInFlightRef = useRef(false); const bomShopSyncAllInFlightRef = useRef(false); const [bomShopSyncBomId, setBomShopSyncBomId] = useState("78"); const [bomShopM18HeaderId, setBomShopM18HeaderId] = useState(""); const [bomShopSyncLoading, setBomShopSyncLoading] = useState(false); const [bomShopSyncResult, setBomShopSyncResult] = useState( null, ); const [bomShopSyncAllLoading, setBomShopSyncAllLoading] = useState(false); const [bomShopSyncAllResult, setBomShopSyncAllResult] = useState< string | null >(null); const bomByItemCodeInFlightRef = useRef(false); const [bomByItemCodeInput, setBomByItemCodeInput] = useState(""); const [bomByItemCodeLoading, setBomByItemCodeLoading] = useState(false); const [bomByItemCodeResult, setBomByItemCodeResult] = useState( null, ); const whatsAppTestInFlightRef = useRef(false); const [whatsAppVar1, setWhatsAppVar1] = useState("12/1"); const [whatsAppVar2, setWhatsAppVar2] = useState("3pm"); const [whatsAppTestLoading, setWhatsAppTestLoading] = useState(false); const [whatsAppTestResult, setWhatsAppTestResult] = useState( null, ); const emailTestInFlightRef = useRef(false); const [emailTestSubject, setEmailTestSubject] = useState( "FPSMS M18 sync alert [TEST]", ); const [emailTestMessage, setEmailTestMessage] = useState( "FPSMS sync alert test message from /testing page.", ); const [emailTestLoading, setEmailTestLoading] = useState(false); const [emailTestResult, setEmailTestResult] = useState(null); const onpackPayload = useMemo( () => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders], ); useEffect(() => { if (tabValue !== 1) return; let cancelled = false; (async () => { setOnpackLoading(true); setOnpackLoadError(null); try { const data = await fetchJobOrders(onpackPlanDate); if (!cancelled) setOnpackJobOrders(data); } catch (e) { if (!cancelled) { setOnpackLoadError( e instanceof Error ? e.message : "Failed to load job orders", ); setOnpackJobOrders([]); } } finally { if (!cancelled) setOnpackLoading(false); } })(); return () => { cancelled = true; }; }, [tabValue, onpackPlanDate]); useEffect(() => { if (tabValue !== 2) return; let cancelled = false; (async () => { try { const s = await fetchLaserBag2Settings(); if (!cancelled) setLaserLastReceive(s.lastReceiveSuccess ?? null); } catch { if (!cancelled) setLaserLastReceive(null); } })(); return () => { cancelled = true; }; }, [tabValue]); const handleDownloadGrnPreviewXlsx = async () => { try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent( grnPreviewReceiptDate, )}`, { method: "GET" }, ); if (response.status === 401 || response.status === 403) return; if (!response.ok) throw new Error(`Download failed: ${response.status}`); const data = await response.json(); const rows = Array.isArray(data?.rows) ? data.rows : []; const ws = XLSX.utils.json_to_sheet(rows); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "GRN Preview"); const xlsxArrayBuffer = XLSX.write(wb, { bookType: "xlsx", type: "array", }); const blob = new Blob([xlsxArrayBuffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.setAttribute( "download", `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`, ); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); } catch (e) { console.error("GRN Preview XLSX Download Error:", e); alert("GRN Preview XLSX download failed. Check console/network."); } }; const downloadBlob = (blob: Blob, filename: string) => { const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.setAttribute("download", filename); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); }; const handleOnpackDownloadLemonZip = async () => { if (onpackPayload.length === 0) { alert( "No job orders with item code for this plan date (same rule as Bag Print).", ); return; } setOnpackLemonDownloading(true); try { const blob = await downloadOnPackTextQrZip({ jobOrders: onpackPayload }); downloadBlob(blob, `onpack2023_lemon_qr_${onpackPlanDate}.zip`); } catch (e) { console.error("Lemon OnPack ZIP download error:", e); alert(e instanceof Error ? e.message : "Lemon OnPack ZIP failed"); } finally { setOnpackLemonDownloading(false); } }; 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 : 1, }); setLaserAutoReport(report); try { const s = await fetchLaserBag2Settings(); setLaserLastReceive(s.lastReceiveSuccess ?? null); } catch { /* ignore */ } } 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."); return; } setOnpackPushLoading(true); setOnpackPushResult(null); try { const r = await pushOnPackTextQrZipToNgpcl({ jobOrders: onpackPayload }); setOnpackPushResult( `${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`, ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setOnpackPushResult(`Error: ${msg}`); alert(msg); } finally { setOnpackPushLoading(false); } }; const handleBomShopSyncM18 = async () => { if (bomShopSyncInFlightRef.current) return; const id = parseInt(bomShopSyncBomId.trim(), 10); if (!Number.isFinite(id) || id <= 0) { alert("Enter a valid BOM id (positive integer)."); return; } bomShopSyncInFlightRef.current = true; setBomShopSyncLoading(true); setBomShopSyncResult(null); try { const m18H = bomShopM18HeaderId.trim(); const qs = m18H && /^\d+$/.test(m18H) ? `?m18HeaderId=${encodeURIComponent(m18H)}` : ""; const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/m18/test/bom-shop-sync/${id}${qs}`, { method: "POST" }, ); if (response.status === 401 || response.status === 403) return; const text = await response.text(); let display = text; try { const parsed: unknown = JSON.parse(text); display = JSON.stringify(parsed, null, 2); } catch { /* keep raw */ } if (!response.ok) { setBomShopSyncResult(`HTTP ${response.status}\n\n${display}`); return; } setBomShopSyncResult(display); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setBomShopSyncResult(`Error: ${msg}`); } finally { setBomShopSyncLoading(false); bomShopSyncInFlightRef.current = false; } }; const handleBomShopSyncAllM18 = async () => { if (bomShopSyncAllInFlightRef.current) return; bomShopSyncAllInFlightRef.current = true; setBomShopSyncAllLoading(true); setBomShopSyncAllResult(null); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/scheduler/trigger/bom-shop-sync-all`, { method: "GET" }, ); if (response.status === 401 || response.status === 403) return; const text = await response.text(); let display = text; try { const parsed: unknown = JSON.parse(text); display = JSON.stringify(parsed, null, 2); } catch { /* plain string from backend is fine */ } if (!response.ok) { setBomShopSyncAllResult(`HTTP ${response.status}\n\n${display}`); return; } setBomShopSyncAllResult(display); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setBomShopSyncAllResult(`Error: ${msg}`); } finally { setBomShopSyncAllLoading(false); bomShopSyncAllInFlightRef.current = false; } }; const handleWhatsAppSyncAlertTest = async () => { if (whatsAppTestInFlightRef.current) return; whatsAppTestInFlightRef.current = true; setWhatsAppTestLoading(true); setWhatsAppTestResult(null); try { const params = new URLSearchParams(); const v1 = whatsAppVar1.trim(); const v2 = whatsAppVar2.trim(); if (v1) params.set("var1", v1); if (v2) params.set("var2", v2); const qs = params.toString(); const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/scheduler/trigger/sync-alert-test-whatsapp${qs ? `?${qs}` : ""}`, { method: "GET" }, ); if (response.status === 401 || response.status === 403) return; const text = await response.text(); if (!response.ok) { setWhatsAppTestResult(`HTTP ${response.status}\n\n${text}`); return; } setWhatsAppTestResult(text); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setWhatsAppTestResult(`Error: ${msg}`); } finally { setWhatsAppTestLoading(false); whatsAppTestInFlightRef.current = false; } }; const handleSyncAlertTestEmail = async () => { if (emailTestInFlightRef.current) return; emailTestInFlightRef.current = true; setEmailTestLoading(true); setEmailTestResult(null); try { const params = new URLSearchParams(); const subj = emailTestSubject.trim(); const msg = emailTestMessage.trim(); if (subj) params.set("subject", subj); if (msg) params.set("message", msg); const qs = params.toString(); const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/scheduler/trigger/sync-alert-test-email${qs ? `?${qs}` : ""}`, { method: "GET" }, ); if (response.status === 401 || response.status === 403) return; const text = await response.text(); if (!response.ok) { setEmailTestResult(`HTTP ${response.status}\n\n${text}`); return; } setEmailTestResult(text); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setEmailTestResult(`Error: ${msg}`); } finally { setEmailTestLoading(false); emailTestInFlightRef.current = false; } }; const handleBomLookupByItemCode = async () => { if (bomByItemCodeInFlightRef.current) return; const code = bomByItemCodeInput.trim(); if (!code) { alert("Enter an item code."); return; } bomByItemCodeInFlightRef.current = true; setBomByItemCodeLoading(true); setBomByItemCodeResult(null); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/bom/by-item-code?code=${encodeURIComponent(code)}`, { method: "GET" }, ); if (response.status === 401 || response.status === 403) return; const text = await response.text(); let display = text; try { const parsed: unknown = JSON.parse(text); display = JSON.stringify(parsed, null, 2); } catch { /* keep raw */ } setBomByItemCodeResult(display); if (!response.ok) { alert(`Lookup failed: HTTP ${response.status}`); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); setBomByItemCodeResult(`Error: ${msg}`); alert(msg); } finally { setBomByItemCodeLoading(false); bomByItemCodeInFlightRef.current = false; } }; const Section = ({ title, children, }: { title: string; children?: React.ReactNode; }) => ( {title} {children || ( Waiting for implementation... )} ); return ( Testing
setGrnPreviewReceiptDate(e.target.value)} InputLabelProps={{ shrink: true }} /> Backend endpoint:{" "} /report/grn-preview-m18?receiptDate=YYYY-MM-DD
Uses GET /py/job-orders?planStart= for the day, then the same jobOrders payload as{" "} Bag Print → 下載 OnPack2023檸檬機. The ZIP contains loose .job / .image / BMPs — extract before sending to NGE; the ZIP itself is only a transport bundle. Distinct item codes in the list produce one label set each (backend groups by code). Configure ngpcl.push-url on the server to POST the same lemon ZIP bytes to your NGPCL HTTP gateway; otherwise use download only. setOnpackPlanDate(e.target.value)} InputLabelProps={{ shrink: true }} /> {onpackLoading ? ( <> Loading job orders… ) : ( `${onpackJobOrders.length} job order(s), ${onpackPayload.length} row(s) with item code → ZIP` )} {onpackLoadError ? ( {onpackLoadError} ) : null} JO id Code Item code Lot {onpackJobOrders.length === 0 && !onpackLoading ? ( No rows for this date (or still loading). ) : ( onpackJobOrders.map((jo) => ( {jo.id} {jo.code ?? "—"} {jo.itemCode ?? "—"} {jo.lotNo ?? "—"} )) )}
{onpackPushResult ? ( ) : null} POST /plastic/download-onpack-qr-text ·{" "} POST /plastic/ngpcl/push-onpack-qr-text (same body)
{laserLastReceive ? ( 上次印表機已確認(receive)的工單(資料庫) 工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot: {laserLastReceive.lotNo ?? "—"} JSON:{" "} {laserLastReceive.itemId != null && laserLastReceive.stockInLineId != null ? JSON.stringify({ itemId: laserLastReceive.itemId, stockInLineId: laserLastReceive.stockInLineId, }) : "—"} {formatHongKongDateTime(laserLastReceive.sentAt)} {laserLastReceive.source ?? ""} ) : null} 依資料庫 LASER_PRINT.hostLASER_PRINT.portLASER_PRINT.itemCodes 查當日包裝工單並送 TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。 排程預設關閉;啟用請設{" "} laser.bag2.auto-send.enabled=true(後端 application.yml)。 setLaserAutoPlanDate(e.target.value)} InputLabelProps={{ shrink: true }} /> setLaserAutoLimit(e.target.value)} sx={{ width: 200 }} helperText="目前後端會限制為第一筆;此欄位保留給未來調整" /> {laserAutoError ? ( {laserAutoError} ) : null} {laserAutoReport ? ( ) : null} POST /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&limitPerRun=N
此工具會呼叫後端 /inventoryLotLine/analyze-qr-code{" "} 找同品可用批號,再用 /inventoryLotLine/print-label(需 printerId)送出列印。 掃碼格式:{'{"itemId":16431,"stockInLineId":10381'} setLotLabelModalOpen(false)} />
Requires setting M18.bom.shop.sync.enabled=true. Use{" "} m18HeaderId query param (or the field below) so M18{" "} updates the existing udfBomForShop header instead of creating a duplicate. Lookup BOM id by item code (like PO-by-code) Uses GET /bom/by-item-code?code=… — item is the BOM header product (bom.item), same as FPSMS finished-good items.code. setBomByItemCodeInput(e.target.value)} sx={{ width: 220 }} /> {bomByItemCodeResult ? ( ) : null} M18 udfBomForShop sync GET /scheduler/trigger/bom-shop-sync-all: push{" "} all non-deleted BOMs (same nightly job path; respects M18.bom.shop.sync.enabled). Check{" "} scheduler_sync_log (M18_BOM_SHOP) for row counts. {bomShopSyncAllResult ? ( ) : null} POST /m18/test/bom-shop-sync/:bomId with optional{" "} ?m18HeaderId= (M18 udfBomForShop header id for{" "} update; from lookup bomM18Id or{" "} Bom.m18Id in DB). If omitted, backend uses{" "} bom.m18Id when set; otherwise creates a new M18 row. setBomShopSyncBomId(e.target.value)} sx={{ width: 160 }} /> setBomShopM18HeaderId(e.target.value)} sx={{ width: 220 }} helperText="e.g. 255 from bomM18Id — forces main.id in payload" /> {bomShopSyncResult ? ( ) : null}
Production sync errors email{" "} vluk@2fi-solutions.com.hk and{" "} kelvin.yau@2fi-solutions.com.hk (see{" "} scheduler.sync-alert.email.to-addresses). WhatsApp/Twilio is disabled. SMTP from DB MAIL.smtp.* (e.g. Gmail{" "} vinluk95@gmail.com + app password). setEmailTestSubject(e.target.value)} fullWidth /> setEmailTestMessage(e.target.value)} multiline minRows={3} fullWidth /> {emailTestResult ? ( ) : null} GET /scheduler/trigger/sync-alert-test-email?subject=…&message=…
GET /scheduler/trigger/sync-alert-check — run alert rules now (empty = OK)
); }