"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 (
{value === index && {children}}
);
}
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 }}
/>
}
onClick={handleDownloadGrnPreviewXlsx}
>
Download GRN Preview XLSX
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.host、
LASER_PRINT.port、
LASER_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)
);
}