| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| import React, { useEffect, useMemo, useState } from "react"; | |||
| import React, { useEffect, useMemo, useRef, useState } from "react"; | |||
| import { | |||
| Alert, | |||
| Box, | |||
| @@ -94,6 +94,19 @@ export default function TestingPage() { | |||
| const [laserAutoError, setLaserAutoError] = useState<string | null>(null); | |||
| const [laserLastReceive, setLaserLastReceive] = | |||
| useState<LaserLastReceiveSuccess | null>(null); | |||
| const bomShopSyncInFlightRef = useRef(false); | |||
| const [bomShopSyncBomId, setBomShopSyncBomId] = useState("78"); | |||
| const [bomShopM18HeaderId, setBomShopM18HeaderId] = useState(""); | |||
| const [bomShopSyncLoading, setBomShopSyncLoading] = useState(false); | |||
| const [bomShopSyncResult, setBomShopSyncResult] = useState<string | null>( | |||
| null, | |||
| ); | |||
| const bomByItemCodeInFlightRef = useRef(false); | |||
| const [bomByItemCodeInput, setBomByItemCodeInput] = useState(""); | |||
| const [bomByItemCodeLoading, setBomByItemCodeLoading] = useState(false); | |||
| const [bomByItemCodeResult, setBomByItemCodeResult] = useState<string | null>( | |||
| null, | |||
| ); | |||
| const onpackPayload = useMemo( | |||
| () => buildOnPackJobOrdersPayload(onpackJobOrders), | |||
| @@ -259,6 +272,87 @@ export default function TestingPage() { | |||
| } | |||
| }; | |||
| 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 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, | |||
| @@ -307,6 +401,7 @@ export default function TestingPage() { | |||
| <Tab label="2. OnPack NGPCL" /> | |||
| <Tab label="3. Laser Bag2 自動送" /> | |||
| <Tab label="4. 批號標籤列印" /> | |||
| <Tab label="5. M18 BOM shop" /> | |||
| </Tabs> | |||
| <TabPanel value={tabValue} index={0}> | |||
| @@ -601,6 +696,127 @@ export default function TestingPage() { | |||
| /> | |||
| </Section> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={4}> | |||
| <Section title="5. M18 BOM shop sync (udfBomForShop)"> | |||
| <Alert severity="info" sx={{ mb: 2 }}> | |||
| Requires setting <code>M18.bom.shop.sync.enabled=true</code>. Use{" "} | |||
| <code>m18HeaderId</code> query param (or the field below) so M18{" "} | |||
| <strong>updates</strong> the existing udfBomForShop header instead of creating a duplicate. | |||
| </Alert> | |||
| <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}> | |||
| Lookup BOM id by item code (like PO-by-code) | |||
| </Typography> | |||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}> | |||
| Uses <code>GET /bom/by-item-code?code=…</code> — item is the BOM | |||
| header product (<code>bom.item</code>), same as FPSMS finished-good | |||
| <code> items.code</code>. | |||
| </Typography> | |||
| <Stack | |||
| direction={{ xs: "column", sm: "row" }} | |||
| spacing={2} | |||
| sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }} | |||
| > | |||
| <TextField | |||
| size="small" | |||
| label="Item code" | |||
| value={bomByItemCodeInput} | |||
| onChange={(e) => setBomByItemCodeInput(e.target.value)} | |||
| sx={{ width: 220 }} | |||
| /> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={() => void handleBomLookupByItemCode()} | |||
| disabled={bomByItemCodeLoading} | |||
| > | |||
| {bomByItemCodeLoading ? "Looking up…" : "Lookup BOM id"} | |||
| </Button> | |||
| </Stack> | |||
| {bomByItemCodeResult ? ( | |||
| <Stack spacing={1} sx={{ mb: 3 }}> | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={4} | |||
| label="Lookup response (BomIdByItemCodeResponse)" | |||
| value={bomByItemCodeResult} | |||
| InputProps={{ readOnly: true }} | |||
| sx={{ fontFamily: "monospace" }} | |||
| /> | |||
| <Button | |||
| size="small" | |||
| variant="text" | |||
| onClick={() => { | |||
| try { | |||
| const o = JSON.parse(bomByItemCodeResult) as { | |||
| bomId?: number; | |||
| bomM18Id?: number; | |||
| }; | |||
| if (o?.bomId != null) | |||
| setBomShopSyncBomId(String(o.bomId)); | |||
| if (o?.bomM18Id != null) | |||
| setBomShopM18HeaderId(String(o.bomM18Id)); | |||
| } catch { | |||
| /* ignore */ | |||
| } | |||
| }} | |||
| > | |||
| Copy bomId + bomM18Id to sync fields below | |||
| </Button> | |||
| </Stack> | |||
| ) : null} | |||
| <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}> | |||
| M18 udfBomForShop sync | |||
| </Typography> | |||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}> | |||
| POST <code>/m18/test/bom-shop-sync/:bomId</code> with optional{" "} | |||
| <code>?m18HeaderId=</code> (M18 udfBomForShop header id for{" "} | |||
| <strong>update</strong>; from lookup <code>bomM18Id</code> or{" "} | |||
| <code>Bom.m18Id</code> in DB). If omitted, backend uses{" "} | |||
| <code>bom.m18Id</code> when set; otherwise creates a new M18 row. | |||
| </Typography> | |||
| <Stack | |||
| direction={{ xs: "column", sm: "row" }} | |||
| spacing={2} | |||
| sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }} | |||
| > | |||
| <TextField | |||
| size="small" | |||
| label="BOM id" | |||
| value={bomShopSyncBomId} | |||
| onChange={(e) => setBomShopSyncBomId(e.target.value)} | |||
| sx={{ width: 160 }} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label="M18 header id (optional, update)" | |||
| value={bomShopM18HeaderId} | |||
| onChange={(e) => setBomShopM18HeaderId(e.target.value)} | |||
| sx={{ width: 220 }} | |||
| helperText="e.g. 255 from bomM18Id — forces main.id in payload" | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => void handleBomShopSyncM18()} | |||
| disabled={bomShopSyncLoading} | |||
| > | |||
| {bomShopSyncLoading ? "Syncing…" : "Sync BOM to M18"} | |||
| </Button> | |||
| </Stack> | |||
| {bomShopSyncResult ? ( | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={10} | |||
| label="Response (M18BomShopSyncTriggerResult)" | |||
| value={bomShopSyncResult} | |||
| InputProps={{ readOnly: true }} | |||
| sx={{ fontFamily: "monospace" }} | |||
| /> | |||
| ) : null} | |||
| </Section> | |||
| </TabPanel> | |||
| </Box> | |||
| ); | |||
| } | |||