FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 

416 行
15 KiB

  1. "use client";
  2. import React, { useRef, useState } from "react";
  3. import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
  4. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  5. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  6. interface TabPanelProps {
  7. children?: React.ReactNode;
  8. index: number;
  9. value: number;
  10. }
  11. function TabPanel(props: TabPanelProps) {
  12. const { children, value, index, ...other } = props;
  13. return (
  14. <div
  15. role="tabpanel"
  16. hidden={value !== index}
  17. id={`m18syn-tabpanel-${index}`}
  18. aria-labelledby={`m18syn-tab-${index}`}
  19. {...other}
  20. >
  21. {value === index && <Box sx={{ p: 3 }}>{children}</Box>}
  22. </div>
  23. );
  24. }
  25. export default function M18SynPage() {
  26. const [tabValue, setTabValue] = useState(0);
  27. type SyncResultDto = {
  28. totalProcessed: number;
  29. totalSuccess: number;
  30. totalFail: number;
  31. query?: string | null;
  32. };
  33. type ParsedSyncResult =
  34. | { kind: "exists"; message: string }
  35. | { kind: "not_found"; message: string }
  36. | { kind: "success"; message: string; totalSuccess: number }
  37. | { kind: "fail"; message: string; totalFail: number }
  38. | { kind: "mixed"; message: string; totalSuccess: number; totalFail: number }
  39. | { kind: "raw"; message: string };
  40. const parseSyncResult = (docName: string, rawText: string): ParsedSyncResult => {
  41. const text = rawText?.trim() ?? "";
  42. if (!text) return { kind: "raw", message: "未收到回應" };
  43. try {
  44. const obj = JSON.parse(text) as Partial<SyncResultDto>;
  45. const totalSuccess = Number(obj.totalSuccess ?? 0);
  46. const totalFail = Number(obj.totalFail ?? 0);
  47. const totalProcessed = Number(obj.totalProcessed ?? 0);
  48. const query = (obj.query ?? "").toString();
  49. // newOnly skip message from backend
  50. if (query.includes("skipped") && query.includes("already exists")) {
  51. return { kind: "exists", message: `${docName}已存在系統` };
  52. }
  53. // "not found in M18" (sync-by-code returns processed=1, fail=1)
  54. if (
  55. docName === "送貨訂單" &&
  56. totalProcessed === 1 &&
  57. totalSuccess === 0 &&
  58. totalFail === 1 &&
  59. query.includes("code=equal=")
  60. ) {
  61. return { kind: "not_found", message: `在M18找不到${docName}` };
  62. }
  63. if (totalSuccess > 0 && totalFail === 0) {
  64. return { kind: "success", totalSuccess, message: `成功同步: ${totalSuccess}張${docName}` };
  65. }
  66. if (totalSuccess === 0 && totalFail > 0) {
  67. return { kind: "fail", totalFail, message: `失敗: 無法同步${docName}` };
  68. }
  69. return { kind: "mixed", totalSuccess, totalFail, message: `完成: 成功 ${totalSuccess} / 失敗 ${totalFail} (${docName})` };
  70. } catch {
  71. // Non-JSON error body or plain text
  72. return { kind: "raw", message: text };
  73. }
  74. };
  75. const formatSyncResultText = (docName: string, rawText: string): string => parseSyncResult(docName, rawText).message;
  76. const [m18PoCode, setM18PoCode] = useState("");
  77. const [isSyncingM18Po, setIsSyncingM18Po] = useState(false);
  78. const [m18PoSyncResult, setM18PoSyncResult] = useState<string>("");
  79. const m18PoInFlightRef = useRef(false);
  80. const [m18DoCode, setM18DoCode] = useState("");
  81. const [isSyncingM18Do, setIsSyncingM18Do] = useState(false);
  82. const [m18DoSyncResult, setM18DoSyncResult] = useState<string>("");
  83. const m18DoInFlightRef = useRef(false);
  84. const [m18DoExtraCode, setM18DoExtraCode] = useState("");
  85. const [isSyncingM18DoExtra, setIsSyncingM18DoExtra] = useState(false);
  86. const [m18DoExtraSyncResult, setM18DoExtraSyncResult] = useState<string>("");
  87. const m18DoExtraInFlightRef = useRef(false);
  88. const [m18ProductCode, setM18ProductCode] = useState("");
  89. const [isSyncingM18Product, setIsSyncingM18Product] = useState(false);
  90. const [m18ProductSyncResult, setM18ProductSyncResult] = useState<string>("");
  91. const m18ProductInFlightRef = useRef(false);
  92. const handleSyncM18PoByCode = async () => {
  93. if (m18PoInFlightRef.current) return;
  94. if (!m18PoCode.trim()) {
  95. alert("Please enter PO code.");
  96. return;
  97. }
  98. m18PoInFlightRef.current = true;
  99. setIsSyncingM18Po(true);
  100. setM18PoSyncResult("");
  101. try {
  102. const response = await clientAuthFetch(
  103. `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`,
  104. { method: "GET" },
  105. );
  106. if (response.status === 401 || response.status === 403) return;
  107. const text = await response.text();
  108. setM18PoSyncResult(formatSyncResultText("採購單", text));
  109. if (!response.ok) {
  110. alert(`Sync failed: ${response.status}`);
  111. }
  112. } catch (e) {
  113. console.error("M18 PO Sync By Code Error:", e);
  114. alert("M18 PO sync failed. Check console/network.");
  115. } finally {
  116. setIsSyncingM18Po(false);
  117. m18PoInFlightRef.current = false;
  118. }
  119. };
  120. const handleSyncM18DoByCode = async () => {
  121. if (m18DoInFlightRef.current) return;
  122. if (!m18DoCode.trim()) {
  123. alert("Please enter DO / shop PO code.");
  124. return;
  125. }
  126. m18DoInFlightRef.current = true;
  127. setIsSyncingM18Do(true);
  128. setM18DoSyncResult("");
  129. try {
  130. const response = await clientAuthFetch(
  131. `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`,
  132. { method: "GET" },
  133. );
  134. if (response.status === 401 || response.status === 403) return;
  135. const text = await response.text();
  136. setM18DoSyncResult(formatSyncResultText("送貨訂單", text));
  137. if (!response.ok) {
  138. alert(`Sync failed: ${response.status}`);
  139. }
  140. } catch (e) {
  141. console.error("M18 DO Sync By Code Error:", e);
  142. alert("M18 DO sync failed. Check console/network.");
  143. } finally {
  144. setIsSyncingM18Do(false);
  145. m18DoInFlightRef.current = false;
  146. }
  147. };
  148. /** DO(加單):手動按 code 同步,並寫入本地 isExtra=true(可輸入多個 code,用逗號或換行分隔) */
  149. const handleSyncM18DoExtraByCode = async () => {
  150. if (m18DoExtraInFlightRef.current) return;
  151. const raw = m18DoExtraCode.trim();
  152. if (!raw) {
  153. alert("Please enter DO / shop PO code(s) (加單).");
  154. return;
  155. }
  156. const codes = raw
  157. .split(/[\n,]+/g)
  158. .map((s) => s.trim())
  159. .filter(Boolean);
  160. if (codes.length === 0) {
  161. alert("Please enter at least one code.");
  162. return;
  163. }
  164. m18DoExtraInFlightRef.current = true;
  165. setIsSyncingM18DoExtra(true);
  166. setM18DoExtraSyncResult("");
  167. try {
  168. const outputs: string[] = [];
  169. const okCodes: string[] = [];
  170. const existsCodes: string[] = [];
  171. const notFoundCodes: string[] = [];
  172. const otherFailCodes: string[] = [];
  173. for (const code of codes) {
  174. const response = await clientAuthFetch(
  175. `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code-extra?code=${encodeURIComponent(code)}`,
  176. { method: "GET" },
  177. );
  178. if (response.status === 401 || response.status === 403) return;
  179. const text = await response.text();
  180. const parsed = parseSyncResult("送貨訂單", text || "");
  181. if (parsed.kind === "success") okCodes.push(code);
  182. else if (parsed.kind === "exists") existsCodes.push(code);
  183. else if (parsed.kind === "not_found") notFoundCodes.push(code);
  184. else otherFailCodes.push(code);
  185. const perCodeMsg =
  186. parsed.kind === "success" ? "成功同步" :
  187. parsed.kind === "exists" ? `失敗: ${parsed.message}` :
  188. parsed.kind === "not_found" ? parsed.message :
  189. parsed.message;
  190. outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`);
  191. }
  192. // Summary
  193. outputs.push("");
  194. outputs.push(`共${okCodes.length}張送貨訂單成功`);
  195. if (notFoundCodes.length > 0) outputs.push(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`);
  196. if (existsCodes.length > 0) outputs.push(`共${existsCodes.length}張訂單已存在系統 (${existsCodes.join(", ")})`);
  197. if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`);
  198. setM18DoExtraSyncResult(outputs.join("\n"));
  199. } catch (e) {
  200. console.error("M18 DO (加單) Sync By Code Error:", e);
  201. alert("M18 DO (加單) sync failed. Check console/network.");
  202. } finally {
  203. setIsSyncingM18DoExtra(false);
  204. m18DoExtraInFlightRef.current = false;
  205. }
  206. };
  207. const handleSyncM18ProductByCode = async () => {
  208. if (m18ProductInFlightRef.current) return;
  209. if (!m18ProductCode.trim()) {
  210. alert("Please enter M18 item / product code.");
  211. return;
  212. }
  213. m18ProductInFlightRef.current = true;
  214. setIsSyncingM18Product(true);
  215. setM18ProductSyncResult("");
  216. try {
  217. const response = await clientAuthFetch(
  218. `${NEXT_PUBLIC_API_URL}/m18/test/product-by-code?code=${encodeURIComponent(m18ProductCode.trim())}`,
  219. { method: "GET" },
  220. );
  221. if (response.status === 401 || response.status === 403) return;
  222. const text = await response.text();
  223. setM18ProductSyncResult(formatSyncResultText("產品", text));
  224. if (!response.ok) {
  225. alert(`Sync failed: ${response.status}`);
  226. }
  227. } catch (e) {
  228. console.error("M18 Product Sync By Code Error:", e);
  229. alert("M18 product sync failed. Check console/network.");
  230. } finally {
  231. setIsSyncingM18Product(false);
  232. m18ProductInFlightRef.current = false;
  233. }
  234. };
  235. const Section = ({ title, children }: { title: string; children?: React.ReactNode }) => (
  236. <Paper sx={{ p: 3, minHeight: "450px", display: "flex", flexDirection: "column" }}>
  237. <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: "2px solid #f0f0f0", pb: 1, mb: 2 }}>
  238. {title}
  239. </Typography>
  240. {children || <Typography color="textSecondary" sx={{ m: "auto" }}>Waiting for implementation...</Typography>}
  241. </Paper>
  242. );
  243. return (
  244. <Box sx={{ p: 4 }}>
  245. <Typography variant="h4" sx={{ mb: 4, fontWeight: "bold" }}>
  246. M18 Sync (by code)
  247. </Typography>
  248. <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
  249. ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code.
  250. </Typography>
  251. <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth">
  252. <Tab label="1. 採購單" id="m18syn-tab-0" />
  253. <Tab label="2. 送貨訂單" id="m18syn-tab-1" />
  254. <Tab label="3. 送貨訂單 (加單)" id="m18syn-tab-2" />
  255. <Tab label="4. Product" id="m18syn-tab-3" />
  256. </Tabs>
  257. <TabPanel value={tabValue} index={0}>
  258. <Section title="M18 採購單 — sync by code">
  259. <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
  260. <TextField
  261. size="small"
  262. label="PO Code"
  263. value={m18PoCode}
  264. onChange={(e) => setM18PoCode(e.target.value)}
  265. placeholder="e.g. PFP002PO26030341"
  266. sx={{ minWidth: 320 }}
  267. />
  268. <Button variant="contained" color="primary" onClick={handleSyncM18PoByCode} disabled={isSyncingM18Po}>
  269. {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"}
  270. </Button>
  271. </Stack>
  272. {m18PoSyncResult ? (
  273. <TextField
  274. fullWidth
  275. multiline
  276. minRows={4}
  277. margin="normal"
  278. label="Sync Result"
  279. value={m18PoSyncResult}
  280. InputProps={{ readOnly: true }}
  281. />
  282. ) : null}
  283. </Section>
  284. </TabPanel>
  285. <TabPanel value={tabValue} index={1}>
  286. <Section title="M18 送貨訂單 — sync by code">
  287. <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
  288. <TextField
  289. size="small"
  290. label="DO / Shop PO Code"
  291. value={m18DoCode}
  292. onChange={(e) => setM18DoCode(e.target.value)}
  293. placeholder="e.g. same document code as M18 shop PO"
  294. sx={{ minWidth: 320 }}
  295. />
  296. <Button variant="contained" color="primary" onClick={handleSyncM18DoByCode} disabled={isSyncingM18Do}>
  297. {isSyncingM18Do ? "Syncing..." : "Sync DO from M18"}
  298. </Button>
  299. </Stack>
  300. {m18DoSyncResult ? (
  301. <TextField
  302. fullWidth
  303. multiline
  304. minRows={4}
  305. margin="normal"
  306. label="Sync Result"
  307. value={m18DoSyncResult}
  308. InputProps={{ readOnly: true }}
  309. />
  310. ) : null}
  311. </Section>
  312. </TabPanel>
  313. <TabPanel value={tabValue} index={2}>
  314. <Section title="M18 送貨訂單 — 加單 (isExtra)">
  315. <Stack spacing={2} sx={{ mb: 2 }}>
  316. <TextField
  317. label="DO / Shop PO Code(加單)"
  318. value={m18DoExtraCode}
  319. onChange={(e) => setM18DoExtraCode(e.target.value)}
  320. placeholder="可輸入多個 code,用逗號或換行分隔"
  321. fullWidth
  322. multiline
  323. minRows={6}
  324. maxRows={14}
  325. />
  326. <Box sx={{ display: "flex", justifyContent: "flex-end" }}>
  327. <Button
  328. variant="contained"
  329. color="primary"
  330. onClick={handleSyncM18DoExtraByCode}
  331. disabled={isSyncingM18DoExtra}
  332. >
  333. {isSyncingM18DoExtra ? "Syncing..." : "Sync 送貨訂單(加單)from M18"}
  334. </Button>
  335. </Box>
  336. </Stack>
  337. {m18DoExtraSyncResult ? (
  338. <TextField
  339. fullWidth
  340. multiline
  341. minRows={4}
  342. margin="normal"
  343. label="Sync Result"
  344. value={m18DoExtraSyncResult}
  345. InputProps={{ readOnly: true }}
  346. />
  347. ) : null}
  348. </Section>
  349. </TabPanel>
  350. <TabPanel value={tabValue} index={3}>
  351. <Section title="M18 Product / material — sync by code">
  352. <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
  353. <TextField
  354. size="small"
  355. label="Item / product code"
  356. value={m18ProductCode}
  357. onChange={(e) => setM18ProductCode(e.target.value)}
  358. placeholder="e.g. PP1175 (M18 item code)"
  359. sx={{ minWidth: 320 }}
  360. />
  361. <Button
  362. variant="contained"
  363. color="primary"
  364. onClick={handleSyncM18ProductByCode}
  365. disabled={isSyncingM18Product}
  366. >
  367. {isSyncingM18Product ? "Syncing..." : "Sync product from M18"}
  368. </Button>
  369. </Stack>
  370. {m18ProductSyncResult ? (
  371. <TextField
  372. fullWidth
  373. multiline
  374. minRows={4}
  375. margin="normal"
  376. label="Sync Result"
  377. value={m18ProductSyncResult}
  378. InputProps={{ readOnly: true }}
  379. />
  380. ) : null}
  381. </Section>
  382. </TabPanel>
  383. </Box>
  384. );
  385. }