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

page.tsx 15 KiB

4ヶ月前
4ヶ月前
2ヶ月前
4ヶ月前
4ヶ月前
4ヶ月前
4ヶ月前
4ヶ月前
4ヶ月前
2ヶ月前
4ヶ月前
4ヶ月前
4ヶ月前
4ヶ月前
2ヶ月前
4ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
4ヶ月前
4ヶ月前
4ヶ月前
2ヶ月前
4ヶ月前
4ヶ月前
2ヶ月前
4ヶ月前
2ヶ月前
4ヶ月前
2ヶ月前
4ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
4ヶ月前
4ヶ月前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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. }