|
- "use client";
-
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import { useTranslation } from "react-i18next";
- import EditOutlined from "@mui/icons-material/EditOutlined";
- import DeleteOutline from "@mui/icons-material/DeleteOutline";
- import Add from "@mui/icons-material/Add";
- import {
- Alert,
- Box,
- Button,
- CircularProgress,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- FormControl,
- IconButton,
- InputLabel,
- MenuItem,
- Select,
- type SelectChangeEvent,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- TextField,
- Typography,
- } from "@mui/material";
- import {
- fetchDoFloorSettingsClient,
- fetchSupplierComboClient,
- postSettingClient,
- type ShopComboRow,
- } from "@/app/api/settings/deliveryOrderFloor/client";
- import {
- SETTING_DO_FLOOR_SUPPLIERS_2F,
- SETTING_DO_FLOOR_SUPPLIERS_4F,
- } from "@/app/api/settings/deliveryOrderFloor/constants";
-
- function normalizeCodesCsv(raw: string): string {
- return raw
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean)
- .join(",");
- }
-
- /** 顯示為 `[XXX, YYY]`;無代碼時為 `[]` */
- function formatBracketList(codesCsv: string): string {
- const n = normalizeCodesCsv(codesCsv);
- if (!n) return "[]";
- return `[${n.split(",").join(", ")}]`;
- }
-
- type EditFloor = "2F" | "4F";
-
- type FloorRow = { code: string; name: string };
-
- function findSupplierRow(combo: ShopComboRow[], raw: string): ShopComboRow | undefined {
- const t = raw.trim();
- if (!t) return undefined;
- const lower = t.toLowerCase();
- const exact = combo.find((r) => r.code?.trim() === t);
- if (exact) return exact;
- return combo.find((r) => (r.code?.trim().toLowerCase() ?? "") === lower);
- }
-
- function csvToFloorRows(csv: string, combo: ShopComboRow[]): FloorRow[] {
- const n = normalizeCodesCsv(csv);
- if (!n) return [];
- return n.split(",").map((code) => {
- const hit = findSupplierRow(combo, code);
- const canonical = hit?.code?.trim() || code;
- return { code: canonical, name: hit?.name?.trim() || "" };
- });
- }
-
- function floorRowsToCsv(rows: FloorRow[]): string {
- return rows.map((r) => r.code.trim()).filter(Boolean).join(",");
- }
-
- const DeliveryOrderFloorSettings: React.FC = () => {
- const { t } = useTranslation("deliveryOrderFloor");
- const saveInFlightRef = useRef(false);
- const addInFlightRef = useRef(false);
-
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [success, setSuccess] = useState<string | null>(null);
-
- const [codes2F, setCodes2F] = useState("");
- const [codes4F, setCodes4F] = useState("");
-
- const [editOpen, setEditOpen] = useState(false);
- const [editFloor, setEditFloor] = useState<EditFloor>("2F");
- const [dialogSaving, setDialogSaving] = useState(false);
- const [comboLoading, setComboLoading] = useState(false);
- const [supplierCombo, setSupplierCombo] = useState<ShopComboRow[] | null>(null);
- const [draftRows2F, setDraftRows2F] = useState<FloorRow[]>([]);
- const [draftRows4F, setDraftRows4F] = useState<FloorRow[]>([]);
-
- const [addOpen, setAddOpen] = useState(false);
- const [addCodeInput, setAddCodeInput] = useState("");
- const [addError, setAddError] = useState<string | null>(null);
-
- const load = useCallback(async () => {
- setLoading(true);
- setError(null);
- setSuccess(null);
- try {
- const floor = await fetchDoFloorSettingsClient();
- setCodes2F(floor.suppliers2F);
- setCodes4F(floor.suppliers4F);
- } catch (e: unknown) {
- setError(e instanceof Error ? e.message : String(e));
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- void load();
- }, [load]);
-
- const display2F = useMemo(() => formatBracketList(codes2F), [codes2F]);
- const display4F = useMemo(() => formatBracketList(codes4F), [codes4F]);
-
- const currentDraftRows = editFloor === "2F" ? draftRows2F : draftRows4F;
- const setCurrentDraftRows = editFloor === "2F" ? setDraftRows2F : setDraftRows4F;
-
- const openEdit = async (floor: EditFloor) => {
- setEditFloor(floor);
- setEditOpen(true);
- setError(null);
- setSuccess(null);
- setAddOpen(false);
- setAddCodeInput("");
- setAddError(null);
- setComboLoading(true);
- try {
- const combo = await fetchSupplierComboClient();
- setSupplierCombo(combo);
- setDraftRows2F(csvToFloorRows(codes2F, combo));
- setDraftRows4F(csvToFloorRows(codes4F, combo));
- } catch (e: unknown) {
- setError(e instanceof Error ? e.message : String(e));
- setDraftRows2F([]);
- setDraftRows4F([]);
- setSupplierCombo(null);
- } finally {
- setComboLoading(false);
- }
- };
-
- const closeEdit = () => {
- if (dialogSaving) return;
- setEditOpen(false);
- setAddOpen(false);
- setAddCodeInput("");
- setAddError(null);
- };
-
- const saveCurrentFloor = async () => {
- if (saveInFlightRef.current) return;
- saveInFlightRef.current = true;
- setDialogSaving(true);
- setError(null);
- setSuccess(null);
- try {
- const rows = editFloor === "2F" ? draftRows2F : draftRows4F;
- const normalized = normalizeCodesCsv(floorRowsToCsv(rows));
- const key =
- editFloor === "2F" ? SETTING_DO_FLOOR_SUPPLIERS_2F : SETTING_DO_FLOOR_SUPPLIERS_4F;
- await postSettingClient(key, normalized);
- if (editFloor === "2F") setCodes2F(normalized);
- else setCodes4F(normalized);
- setSuccess(t("Saved"));
- setEditOpen(false);
- setAddOpen(false);
- setAddCodeInput("");
- setAddError(null);
- } catch (e: unknown) {
- setError(e instanceof Error ? e.message : String(e));
- } finally {
- setDialogSaving(false);
- saveInFlightRef.current = false;
- }
- };
-
- const onFloorSelectChange = (e: SelectChangeEvent<EditFloor>) => {
- setEditFloor(e.target.value as EditFloor);
- setAddOpen(false);
- setAddCodeInput("");
- setAddError(null);
- };
-
- const removeRow = (code: string) => {
- const next = currentDraftRows.filter((r) => r.code !== code);
- setCurrentDraftRows(next);
- };
-
- const openAddMapping = () => {
- if (!supplierCombo?.length) {
- setError(t("Supplier list unavailable"));
- return;
- }
- setAddCodeInput("");
- setAddError(null);
- setAddOpen(true);
- };
-
- const closeAdd = () => {
- if (addInFlightRef.current) return;
- setAddOpen(false);
- setAddCodeInput("");
- setAddError(null);
- };
-
- const confirmAddMapping = () => {
- if (addInFlightRef.current) return;
- addInFlightRef.current = true;
- setAddError(null);
- try {
- const raw = addCodeInput.trim();
- if (!raw) {
- setAddError(t("Enter supplier code"));
- return;
- }
- const hit = findSupplierRow(supplierCombo ?? [], raw);
- if (!hit?.code) {
- setAddError(t("Supplier code not found"));
- return;
- }
- const canonical = hit.code.trim();
- const otherRows = editFloor === "2F" ? draftRows4F : draftRows2F;
- if (currentDraftRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) {
- setAddError(t("Duplicate in floor"));
- return;
- }
- if (otherRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) {
- setAddError(t("Duplicate in other floor"));
- return;
- }
- const name = hit.name?.trim() || "";
- setCurrentDraftRows([...currentDraftRows, { code: canonical, name }]);
- setAddOpen(false);
- setAddCodeInput("");
- } finally {
- addInFlightRef.current = false;
- }
- };
-
- if (loading) {
- return (
- <Stack alignItems="center" py={4}>
- <CircularProgress />
- </Stack>
- );
- }
-
- return (
- <Stack spacing={3}>
- <Typography variant="body2" color="text.secondary">
- {t("Intro")}
- </Typography>
-
- {error ? <Alert severity="error">{error}</Alert> : null}
- {success ? <Alert severity="success">{success}</Alert> : null}
-
- <Stack spacing={2}>
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- gap: 2,
- flexWrap: "wrap",
- py: 1.5,
- px: 0,
- borderBottom: 1,
- borderColor: "divider",
- }}
- >
- <Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
- {t("2F supplier")}
- </Typography>
- <Typography
- component="span"
- variant="body1"
- sx={{ fontFamily: "monospace", flex: 1, minWidth: 0, wordBreak: "break-all" }}
- >
- {display2F}
- </Typography>
- <IconButton
- aria-label={t("Edit 2F")}
- color="primary"
- edge="end"
- onClick={() => void openEdit("2F")}
- size="small"
- >
- <EditOutlined />
- </IconButton>
- </Box>
-
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- gap: 2,
- flexWrap: "wrap",
- py: 1.5,
- px: 0,
- borderBottom: 1,
- borderColor: "divider",
- }}
- >
- <Typography component="span" variant="subtitle1" sx={{ minWidth: 120, fontWeight: 600 }}>
- {t("4F supplier")}
- </Typography>
- <Typography
- component="span"
- variant="body1"
- sx={{ fontFamily: "monospace", flex: 1, minWidth: 0, wordBreak: "break-all" }}
- >
- {display4F}
- </Typography>
- <IconButton
- aria-label={t("Edit 4F")}
- color="primary"
- edge="end"
- onClick={() => void openEdit("4F")}
- size="small"
- >
- <EditOutlined />
- </IconButton>
- </Box>
- </Stack>
-
- <Dialog open={editOpen} onClose={closeEdit} fullWidth maxWidth="md">
- <DialogTitle>{t("Edit dialog title")}</DialogTitle>
- <DialogContent>
- <Stack spacing={2} sx={{ pt: 1 }}>
- <Stack
- direction={{ xs: "column", sm: "row" }}
- spacing={2}
- alignItems={{ xs: "stretch", sm: "center" }}
- flexWrap="wrap"
- >
- <FormControl size="small" sx={{ minWidth: 160 }}>
- <InputLabel id="do-floor-edit-floor-label">{t("Floor label")}</InputLabel>
- <Select
- labelId="do-floor-edit-floor-label"
- label={t("Floor label")}
- value={editFloor}
- onChange={onFloorSelectChange}
- disabled={dialogSaving || comboLoading}
- >
- <MenuItem value="2F">2F</MenuItem>
- <MenuItem value="4F">4F</MenuItem>
- </Select>
- </FormControl>
- <Button
- variant="outlined"
- color="primary"
- onClick={() => void saveCurrentFloor()}
- disabled={dialogSaving || comboLoading}
- sx={{ alignSelf: { xs: "stretch", sm: "center" } }}
- >
- {dialogSaving ? <CircularProgress size={22} /> : t("Save")}
- </Button>
- <Box sx={{ flex: 1 }} />
- <Button
- variant="contained"
- color="primary"
- startIcon={<Add />}
- onClick={openAddMapping}
- disabled={dialogSaving || comboLoading || !supplierCombo?.length}
- sx={{ alignSelf: { xs: "stretch", sm: "center" } }}
- >
- {t("Add mapping")}
- </Button>
- </Stack>
-
- {comboLoading ? (
- <Stack alignItems="center" py={4}>
- <CircularProgress size={32} />
- </Stack>
- ) : (
- <TableContainer sx={{ maxHeight: 360, border: 1, borderColor: "divider", borderRadius: 1 }}>
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell sx={{ fontWeight: 700 }}>{t("Col code")}</TableCell>
- <TableCell sx={{ fontWeight: 700 }}>{t("Col name")}</TableCell>
- <TableCell sx={{ fontWeight: 700, width: 100 }}>{t("Col type")}</TableCell>
- <TableCell align="right" sx={{ fontWeight: 700, width: 88 }}>
- {t("Col actions")}
- </TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {currentDraftRows.length === 0 ? (
- <TableRow>
- <TableCell colSpan={4}>
- <Typography variant="body2" color="text.secondary">
- {t("Empty floor list")}
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- currentDraftRows.map((row) => (
- <TableRow key={row.code} hover>
- <TableCell sx={{ fontFamily: "monospace" }}>{row.code}</TableCell>
- <TableCell>
- {row.name || (
- <Typography component="span" color="text.secondary">
- {t("Unknown supplier name")}
- </Typography>
- )}
- </TableCell>
- <TableCell>{editFloor}</TableCell>
- <TableCell align="right">
- <IconButton
- aria-label={t("Delete row")}
- color="error"
- size="small"
- onClick={() => removeRow(row.code)}
- disabled={dialogSaving}
- >
- <DeleteOutline fontSize="small" />
- </IconButton>
- </TableCell>
- </TableRow>
- ))
- )}
- </TableBody>
- </Table>
- </TableContainer>
- )}
- </Stack>
- </DialogContent>
- <DialogActions sx={{ px: 3, pb: 2 }}>
- <Button onClick={closeEdit} disabled={dialogSaving}>
- {t("Cancel")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog open={addOpen} onClose={closeAdd} fullWidth maxWidth="xs">
- <DialogTitle>{t("Add mapping title")}</DialogTitle>
- <DialogContent>
- <Stack spacing={2} sx={{ pt: 1 }}>
- <TextField
- autoFocus
- fullWidth
- size="small"
- label={t("Col code")}
- value={addCodeInput}
- onChange={(e) => {
- setAddCodeInput(e.target.value);
- setAddError(null);
- }}
- placeholder={t("Add code placeholder")}
- />
- {addError ? <Alert severity="error">{addError}</Alert> : null}
- </Stack>
- </DialogContent>
- <DialogActions>
- <Button onClick={closeAdd}>{t("Cancel")}</Button>
- <Button variant="contained" onClick={confirmAddMapping}>
- {t("Add confirm")}
- </Button>
- </DialogActions>
- </Dialog>
- </Stack>
- );
- };
-
- export default DeliveryOrderFloorSettings;
|