FPSMS-frontend
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 

483 Zeilen
15 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import { useTranslation } from "react-i18next";
  4. import EditOutlined from "@mui/icons-material/EditOutlined";
  5. import DeleteOutline from "@mui/icons-material/DeleteOutline";
  6. import Add from "@mui/icons-material/Add";
  7. import {
  8. Alert,
  9. Box,
  10. Button,
  11. CircularProgress,
  12. Dialog,
  13. DialogActions,
  14. DialogContent,
  15. DialogTitle,
  16. FormControl,
  17. IconButton,
  18. InputLabel,
  19. MenuItem,
  20. Select,
  21. type SelectChangeEvent,
  22. Stack,
  23. Table,
  24. TableBody,
  25. TableCell,
  26. TableContainer,
  27. TableHead,
  28. TableRow,
  29. TextField,
  30. Typography,
  31. } from "@mui/material";
  32. import {
  33. fetchDoFloorSettingsClient,
  34. fetchSupplierComboClient,
  35. postSettingClient,
  36. type ShopComboRow,
  37. } from "@/app/api/settings/deliveryOrderFloor/client";
  38. import {
  39. SETTING_DO_FLOOR_SUPPLIERS_2F,
  40. SETTING_DO_FLOOR_SUPPLIERS_4F,
  41. } from "@/app/api/settings/deliveryOrderFloor/constants";
  42. function normalizeCodesCsv(raw: string): string {
  43. return raw
  44. .split(",")
  45. .map((s) => s.trim())
  46. .filter(Boolean)
  47. .join(",");
  48. }
  49. /** 顯示為 `[XXX, YYY]`;無代碼時為 `[]` */
  50. function formatBracketList(codesCsv: string): string {
  51. const n = normalizeCodesCsv(codesCsv);
  52. if (!n) return "[]";
  53. return `[${n.split(",").join(", ")}]`;
  54. }
  55. type EditFloor = "2F" | "4F";
  56. type FloorRow = { code: string; name: string };
  57. function findSupplierRow(combo: ShopComboRow[], raw: string): ShopComboRow | undefined {
  58. const t = raw.trim();
  59. if (!t) return undefined;
  60. const lower = t.toLowerCase();
  61. const exact = combo.find((r) => r.code?.trim() === t);
  62. if (exact) return exact;
  63. return combo.find((r) => (r.code?.trim().toLowerCase() ?? "") === lower);
  64. }
  65. function csvToFloorRows(csv: string, combo: ShopComboRow[]): FloorRow[] {
  66. const n = normalizeCodesCsv(csv);
  67. if (!n) return [];
  68. return n.split(",").map((code) => {
  69. const hit = findSupplierRow(combo, code);
  70. const canonical = hit?.code?.trim() || code;
  71. return { code: canonical, name: hit?.name?.trim() || "" };
  72. });
  73. }
  74. function floorRowsToCsv(rows: FloorRow[]): string {
  75. return rows.map((r) => r.code.trim()).filter(Boolean).join(",");
  76. }
  77. const DeliveryOrderFloorSettings: React.FC = () => {
  78. const { t } = useTranslation("deliveryOrderFloor");
  79. const saveInFlightRef = useRef(false);
  80. const addInFlightRef = useRef(false);
  81. const [loading, setLoading] = useState(true);
  82. const [error, setError] = useState<string | null>(null);
  83. const [success, setSuccess] = useState<string | null>(null);
  84. const [codes2F, setCodes2F] = useState("");
  85. const [codes4F, setCodes4F] = useState("");
  86. const [editOpen, setEditOpen] = useState(false);
  87. const [editFloor, setEditFloor] = useState<EditFloor>("2F");
  88. const [dialogSaving, setDialogSaving] = useState(false);
  89. const [comboLoading, setComboLoading] = useState(false);
  90. const [supplierCombo, setSupplierCombo] = useState<ShopComboRow[] | null>(null);
  91. const [draftRows2F, setDraftRows2F] = useState<FloorRow[]>([]);
  92. const [draftRows4F, setDraftRows4F] = useState<FloorRow[]>([]);
  93. const [addOpen, setAddOpen] = useState(false);
  94. const [addCodeInput, setAddCodeInput] = useState("");
  95. const [addError, setAddError] = useState<string | null>(null);
  96. const load = useCallback(async () => {
  97. setLoading(true);
  98. setError(null);
  99. setSuccess(null);
  100. try {
  101. const floor = await fetchDoFloorSettingsClient();
  102. setCodes2F(floor.suppliers2F);
  103. setCodes4F(floor.suppliers4F);
  104. } catch (e: unknown) {
  105. setError(e instanceof Error ? e.message : String(e));
  106. } finally {
  107. setLoading(false);
  108. }
  109. }, []);
  110. useEffect(() => {
  111. void load();
  112. }, [load]);
  113. const display2F = useMemo(() => formatBracketList(codes2F), [codes2F]);
  114. const display4F = useMemo(() => formatBracketList(codes4F), [codes4F]);
  115. const currentDraftRows = editFloor === "2F" ? draftRows2F : draftRows4F;
  116. const setCurrentDraftRows = editFloor === "2F" ? setDraftRows2F : setDraftRows4F;
  117. const openEdit = async (floor: EditFloor) => {
  118. setEditFloor(floor);
  119. setEditOpen(true);
  120. setError(null);
  121. setSuccess(null);
  122. setAddOpen(false);
  123. setAddCodeInput("");
  124. setAddError(null);
  125. setComboLoading(true);
  126. try {
  127. const combo = await fetchSupplierComboClient();
  128. setSupplierCombo(combo);
  129. setDraftRows2F(csvToFloorRows(codes2F, combo));
  130. setDraftRows4F(csvToFloorRows(codes4F, combo));
  131. } catch (e: unknown) {
  132. setError(e instanceof Error ? e.message : String(e));
  133. setDraftRows2F([]);
  134. setDraftRows4F([]);
  135. setSupplierCombo(null);
  136. } finally {
  137. setComboLoading(false);
  138. }
  139. };
  140. const closeEdit = () => {
  141. if (dialogSaving) return;
  142. setEditOpen(false);
  143. setAddOpen(false);
  144. setAddCodeInput("");
  145. setAddError(null);
  146. };
  147. const saveCurrentFloor = async () => {
  148. if (saveInFlightRef.current) return;
  149. saveInFlightRef.current = true;
  150. setDialogSaving(true);
  151. setError(null);
  152. setSuccess(null);
  153. try {
  154. const rows = editFloor === "2F" ? draftRows2F : draftRows4F;
  155. const normalized = normalizeCodesCsv(floorRowsToCsv(rows));
  156. const key =
  157. editFloor === "2F" ? SETTING_DO_FLOOR_SUPPLIERS_2F : SETTING_DO_FLOOR_SUPPLIERS_4F;
  158. await postSettingClient(key, normalized);
  159. if (editFloor === "2F") setCodes2F(normalized);
  160. else setCodes4F(normalized);
  161. setSuccess(t("Saved"));
  162. setEditOpen(false);
  163. setAddOpen(false);
  164. setAddCodeInput("");
  165. setAddError(null);
  166. } catch (e: unknown) {
  167. setError(e instanceof Error ? e.message : String(e));
  168. } finally {
  169. setDialogSaving(false);
  170. saveInFlightRef.current = false;
  171. }
  172. };
  173. const onFloorSelectChange = (e: SelectChangeEvent<EditFloor>) => {
  174. setEditFloor(e.target.value as EditFloor);
  175. setAddOpen(false);
  176. setAddCodeInput("");
  177. setAddError(null);
  178. };
  179. const removeRow = (code: string) => {
  180. const next = currentDraftRows.filter((r) => r.code !== code);
  181. setCurrentDraftRows(next);
  182. };
  183. const openAddMapping = () => {
  184. if (!supplierCombo?.length) {
  185. setError(t("Supplier list unavailable"));
  186. return;
  187. }
  188. setAddCodeInput("");
  189. setAddError(null);
  190. setAddOpen(true);
  191. };
  192. const closeAdd = () => {
  193. if (addInFlightRef.current) return;
  194. setAddOpen(false);
  195. setAddCodeInput("");
  196. setAddError(null);
  197. };
  198. const confirmAddMapping = () => {
  199. if (addInFlightRef.current) return;
  200. addInFlightRef.current = true;
  201. setAddError(null);
  202. try {
  203. const raw = addCodeInput.trim();
  204. if (!raw) {
  205. setAddError(t("Enter supplier code"));
  206. return;
  207. }
  208. const hit = findSupplierRow(supplierCombo ?? [], raw);
  209. if (!hit?.code) {
  210. setAddError(t("Supplier code not found"));
  211. return;
  212. }
  213. const canonical = hit.code.trim();
  214. const otherRows = editFloor === "2F" ? draftRows4F : draftRows2F;
  215. if (currentDraftRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) {
  216. setAddError(t("Duplicate in floor"));
  217. return;
  218. }
  219. if (otherRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) {
  220. setAddError(t("Duplicate in other floor"));
  221. return;
  222. }
  223. const name = hit.name?.trim() || "";
  224. setCurrentDraftRows([...currentDraftRows, { code: canonical, name }]);
  225. setAddOpen(false);
  226. setAddCodeInput("");
  227. } finally {
  228. addInFlightRef.current = false;
  229. }
  230. };
  231. if (loading) {
  232. return (
  233. <Stack alignItems="center" py={4}>
  234. <CircularProgress />
  235. </Stack>
  236. );
  237. }
  238. return (
  239. <Stack spacing={3}>
  240. <Typography variant="body2" color="text.secondary">
  241. {t("Intro")}
  242. </Typography>
  243. {error ? <Alert severity="error">{error}</Alert> : null}
  244. {success ? <Alert severity="success">{success}</Alert> : null}
  245. <Stack spacing={2}>
  246. <Box
  247. sx={{
  248. display: "flex",
  249. alignItems: "center",
  250. gap: 2,
  251. flexWrap: "wrap",
  252. py: 1.5,
  253. px: 0,
  254. borderBottom: 1,
  255. borderColor: "divider",
  256. }}
  257. >
  258. <Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
  259. {t("2F supplier")}
  260. </Typography>
  261. <Typography
  262. component="span"
  263. variant="body1"
  264. sx={{ fontFamily: "monospace", flex: 1, minWidth: 0, wordBreak: "break-all" }}
  265. >
  266. {display2F}
  267. </Typography>
  268. <IconButton
  269. aria-label={t("Edit 2F")}
  270. color="primary"
  271. edge="end"
  272. onClick={() => void openEdit("2F")}
  273. size="small"
  274. >
  275. <EditOutlined />
  276. </IconButton>
  277. </Box>
  278. <Box
  279. sx={{
  280. display: "flex",
  281. alignItems: "center",
  282. gap: 2,
  283. flexWrap: "wrap",
  284. py: 1.5,
  285. px: 0,
  286. borderBottom: 1,
  287. borderColor: "divider",
  288. }}
  289. >
  290. <Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
  291. {t("4F supplier")}
  292. </Typography>
  293. <Typography
  294. component="span"
  295. variant="body1"
  296. sx={{ fontFamily: "monospace", flex: 1, minWidth: 0, wordBreak: "break-all" }}
  297. >
  298. {display4F}
  299. </Typography>
  300. <IconButton
  301. aria-label={t("Edit 4F")}
  302. color="primary"
  303. edge="end"
  304. onClick={() => void openEdit("4F")}
  305. size="small"
  306. >
  307. <EditOutlined />
  308. </IconButton>
  309. </Box>
  310. </Stack>
  311. <Dialog open={editOpen} onClose={closeEdit} fullWidth maxWidth="md">
  312. <DialogTitle>{t("Edit dialog title")}</DialogTitle>
  313. <DialogContent>
  314. <Stack spacing={2} sx={{ pt: 1 }}>
  315. <Stack
  316. direction={{ xs: "column", sm: "row" }}
  317. spacing={2}
  318. alignItems={{ xs: "stretch", sm: "center" }}
  319. flexWrap="wrap"
  320. >
  321. <FormControl size="small" sx={{ minWidth: 160 }}>
  322. <InputLabel id="do-floor-edit-floor-label">{t("Floor label")}</InputLabel>
  323. <Select
  324. labelId="do-floor-edit-floor-label"
  325. label={t("Floor label")}
  326. value={editFloor}
  327. onChange={onFloorSelectChange}
  328. disabled={dialogSaving || comboLoading}
  329. >
  330. <MenuItem value="2F">2F</MenuItem>
  331. <MenuItem value="4F">4F</MenuItem>
  332. </Select>
  333. </FormControl>
  334. <Button
  335. variant="outlined"
  336. color="primary"
  337. onClick={() => void saveCurrentFloor()}
  338. disabled={dialogSaving || comboLoading}
  339. sx={{ alignSelf: { xs: "stretch", sm: "center" } }}
  340. >
  341. {dialogSaving ? <CircularProgress size={22} /> : t("Save")}
  342. </Button>
  343. <Box sx={{ flex: 1 }} />
  344. <Button
  345. variant="contained"
  346. color="primary"
  347. startIcon={<Add />}
  348. onClick={openAddMapping}
  349. disabled={dialogSaving || comboLoading || !supplierCombo?.length}
  350. sx={{ alignSelf: { xs: "stretch", sm: "center" } }}
  351. >
  352. {t("Add mapping")}
  353. </Button>
  354. </Stack>
  355. {comboLoading ? (
  356. <Stack alignItems="center" py={4}>
  357. <CircularProgress size={32} />
  358. </Stack>
  359. ) : (
  360. <TableContainer sx={{ maxHeight: 360, border: 1, borderColor: "divider", borderRadius: 1 }}>
  361. <Table size="small" stickyHeader>
  362. <TableHead>
  363. <TableRow>
  364. <TableCell sx={{ fontWeight: 700 }}>{t("Col code")}</TableCell>
  365. <TableCell sx={{ fontWeight: 700 }}>{t("Col name")}</TableCell>
  366. <TableCell sx={{ fontWeight: 700, width: 100 }}>{t("Col type")}</TableCell>
  367. <TableCell align="right" sx={{ fontWeight: 700, width: 88 }}>
  368. {t("Col actions")}
  369. </TableCell>
  370. </TableRow>
  371. </TableHead>
  372. <TableBody>
  373. {currentDraftRows.length === 0 ? (
  374. <TableRow>
  375. <TableCell colSpan={4}>
  376. <Typography variant="body2" color="text.secondary">
  377. {t("Empty floor list")}
  378. </Typography>
  379. </TableCell>
  380. </TableRow>
  381. ) : (
  382. currentDraftRows.map((row) => (
  383. <TableRow key={row.code} hover>
  384. <TableCell sx={{ fontFamily: "monospace" }}>{row.code}</TableCell>
  385. <TableCell>
  386. {row.name || (
  387. <Typography component="span" color="text.secondary">
  388. {t("Unknown supplier name")}
  389. </Typography>
  390. )}
  391. </TableCell>
  392. <TableCell>{editFloor}</TableCell>
  393. <TableCell align="right">
  394. <IconButton
  395. aria-label={t("Delete row")}
  396. color="error"
  397. size="small"
  398. onClick={() => removeRow(row.code)}
  399. disabled={dialogSaving}
  400. >
  401. <DeleteOutline fontSize="small" />
  402. </IconButton>
  403. </TableCell>
  404. </TableRow>
  405. ))
  406. )}
  407. </TableBody>
  408. </Table>
  409. </TableContainer>
  410. )}
  411. </Stack>
  412. </DialogContent>
  413. <DialogActions sx={{ px: 3, pb: 2 }}>
  414. <Button onClick={closeEdit} disabled={dialogSaving}>
  415. {t("Cancel")}
  416. </Button>
  417. </DialogActions>
  418. </Dialog>
  419. <Dialog open={addOpen} onClose={closeAdd} fullWidth maxWidth="xs">
  420. <DialogTitle>{t("Add mapping title")}</DialogTitle>
  421. <DialogContent>
  422. <Stack spacing={2} sx={{ pt: 1 }}>
  423. <TextField
  424. autoFocus
  425. fullWidth
  426. size="small"
  427. label={t("Col code")}
  428. value={addCodeInput}
  429. onChange={(e) => {
  430. setAddCodeInput(e.target.value);
  431. setAddError(null);
  432. }}
  433. placeholder={t("Add code placeholder")}
  434. />
  435. {addError ? <Alert severity="error">{addError}</Alert> : null}
  436. </Stack>
  437. </DialogContent>
  438. <DialogActions>
  439. <Button onClick={closeAdd}>{t("Cancel")}</Button>
  440. <Button variant="contained" onClick={confirmAddMapping}>
  441. {t("Add confirm")}
  442. </Button>
  443. </DialogActions>
  444. </Dialog>
  445. </Stack>
  446. );
  447. };
  448. export default DeliveryOrderFloorSettings;