|
- "use client";
-
- import React from "react";
- import { useTranslation } from "react-i18next";
- import DeleteOutline from "@mui/icons-material/DeleteOutline";
- import {
- Alert,
- Box,
- Button,
- Checkbox,
- FormControlLabel,
- IconButton,
- Stack,
- ToggleButton,
- ToggleButtonGroup,
- Typography,
- } from "@mui/material";
- import type { WarehousePickRow } from "@/app/api/settings/deliveryOrderFloor/client";
- import {
- EXCLUDE_ALL_AREAS,
- earlierOverlappingExcludeGroups,
- emptyExcludeWarehouseRule,
- excludeRuleHasTarget,
- groupExcludeRuleIndexes,
- type ExcludePrintMode,
- type ExcludeWarehouseRule,
- } from "@/app/api/settings/deliveryOrderFloor/constants";
-
- const BUTTON_WIDTH = 80;
-
- function tokens(raw: string): string[] {
- return raw
- .split(",")
- .map((part) => part.trim())
- .filter(Boolean);
- }
-
- function uniqueSorted(values: string[]): string[] {
- const seen = new Set<string>();
- const out: string[] = [];
- for (const value of values) {
- const key = value.toUpperCase();
- if (!value || seen.has(key)) continue;
- seen.add(key);
- out.push(value);
- }
- return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
- }
-
- function slotCode(code: string): string {
- const parts = code.split("-");
- return parts[parts.length - 1] || "";
- }
-
- function slotKey(raw: string): string {
- return tokens(raw)
- .map((slot) => slot.toUpperCase())
- .sort()
- .join(",");
- }
-
- function shownSlots(stored: string, areaSlots: string[]): string[] {
- const picked = tokens(stored);
- if (picked.includes("-")) return [];
- if (picked.length === 0) return areaSlots;
- return areaSlots.filter((slot) => picked.some((item) => item.toUpperCase() === slot.toUpperCase()));
- }
-
- function slotsToStore(picked: string[], areaSlots: string[]): string {
- if (areaSlots.length > 0 && picked.length === areaSlots.length) return "";
- if (picked.length === 0) return "-";
- return picked.join(",");
- }
-
- function areasOnRule(rule: ExcludeWarehouseRule, areaNames: string[]): string[] {
- const picked = tokens(rule.areas);
- if (picked.includes(EXCLUDE_ALL_AREAS)) return areaNames;
- return picked;
- }
-
- function replaceGroup(rules: ExcludeWarehouseRule[], indexes: number[], nextGroup: ExcludeWarehouseRule[]): ExcludeWarehouseRule[] {
- const indexSet = new Set(indexes);
- const first = indexes[0] ?? 0;
- const out: ExcludeWarehouseRule[] = [];
- rules.forEach((rule, index) => {
- if (index === first) out.push(...nextGroup);
- else if (!indexSet.has(index)) out.push(rule);
- });
- return out;
- }
-
- function withoutArea(group: ExcludeWarehouseRule[], area: string, areaNames: string[]): ExcludeWarehouseRule[] {
- return group
- .map((rule) => ({
- ...rule,
- areas: areasOnRule(rule, areaNames)
- .filter((item) => item.toUpperCase() !== area.toUpperCase())
- .join(","),
- }))
- .filter((rule) => tokens(rule.areas).length > 0);
- }
-
- function withAreaSlots(
- group: ExcludeWarehouseRule[],
- area: string,
- slotCsv: string,
- areaNames: string[],
- template: ExcludeWarehouseRule,
- ): ExcludeWarehouseRule[] {
- const stripped = withoutArea(group, area, areaNames);
- const key = slotKey(slotCsv);
- const hitIndex = stripped.findIndex((rule) => slotKey(rule.slots) === key);
- if (hitIndex >= 0) {
- return stripped.map((rule, index) =>
- index === hitIndex ? { ...rule, areas: [...tokens(rule.areas), area].join(",") } : rule,
- );
- }
- const next = [...stripped, { ...template, areas: area, slots: tokens(slotCsv).join(",") }];
- return next.length > 0 ? next : [{ ...template, areas: "", slots: "" }];
- }
-
- function isChosen(value: string | string[] | null, option: string): boolean {
- if (Array.isArray(value)) return value.some((item) => item.toUpperCase() === option.toUpperCase());
- return typeof value === "string" && value.toUpperCase() === option.toUpperCase();
- }
-
- function ToggleRows({
- label,
- options,
- value,
- exclusive,
- disabled,
- onChange,
- renderLabel,
- }: {
- label: string;
- options: string[];
- value: string | string[] | null;
- exclusive: boolean;
- disabled?: boolean;
- onChange: (next: string | string[] | null) => void;
- renderLabel?: (option: string) => string;
- }) {
- const toggle = (option: string) => {
- if (disabled) return;
- if (exclusive) {
- onChange(isChosen(value, option) ? null : option);
- return;
- }
- const previous = Array.isArray(value) ? value : [];
- const next = isChosen(previous, option)
- ? previous.filter((item) => item.toUpperCase() !== option.toUpperCase())
- : [...previous, option];
- onChange(next);
- };
-
- return (
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75, flexShrink: 0 }}>
- {label}
- </Typography>
- {options.length === 0 ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- —
- </Typography>
- ) : (
- <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, flex: 1, minWidth: 0, opacity: disabled ? 0.45 : 1 }}>
- {options.map((option) => (
- <ToggleButton
- key={option}
- size="small"
- value={option}
- selected={isChosen(value, option)}
- disabled={disabled}
- onClick={() => toggle(option)}
- sx={{
- width: BUTTON_WIDTH,
- minWidth: BUTTON_WIDTH,
- maxWidth: BUTTON_WIDTH,
- px: 0,
- textTransform: "none",
- }}
- >
- {renderLabel ? renderLabel(option) : option}
- </ToggleButton>
- ))}
- </Box>
- )}
- </Box>
- );
- }
-
- export function ExcludeWarehouseRuleList({
- rules,
- warehouses,
- saving,
- onSave,
- }: {
- rules: ExcludeWarehouseRule[];
- warehouses: WarehousePickRow[];
- saving: boolean;
- onSave: (next: ExcludeWarehouseRule[]) => void;
- }) {
- const { t } = useTranslation("deliveryOrderFloor");
- const [viewByGroup, setViewByGroup] = React.useState<Record<string, string>>({});
-
- const saveGroup = (indexes: number[], nextGroup: ExcludeWarehouseRule[]) => {
- onSave(replaceGroup(rules, indexes, nextGroup));
- };
-
- const floors = uniqueSorted(warehouses.map((row) => row.storeId));
- const groups = groupExcludeRuleIndexes(rules).map((indexes) => ({ key: indexes.join(","), indexes }));
-
- return (
- <Stack spacing={1.5}>
- {rules.length === 0 ? (
- <Typography variant="body2" color="text.secondary">
- {t("Empty exclude rules")}
- </Typography>
- ) : (
- groups.map((group, groupIndex) => {
- const overlappedBy = earlierOverlappingExcludeGroups(rules, groupIndex);
- const groupRules = group.indexes.map((index) => rules[index]).filter((rule): rule is ExcludeWarehouseRule => rule != null);
- const rule = groupRules[0];
- if (!rule) return null;
- const warehousesForFloor = warehouses.filter(
- (row) => row.storeId.toUpperCase() === rule.floor.trim().toUpperCase(),
- );
- const warehouseOptions = uniqueSorted(warehousesForFloor.map((row) => row.warehouse));
- const areasForWarehouse = warehousesForFloor.filter(
- (row) => row.warehouse.toUpperCase() === rule.warehouse.trim().toUpperCase(),
- );
- const areaNames = uniqueSorted(areasForWarehouse.map((row) => row.area));
- const slotsByArea = new Map<string, string[]>();
- areasForWarehouse.forEach((row) => {
- const slot = slotCode(row.code);
- const list = slotsByArea.get(row.area) ?? [];
- if (slot && !list.some((item) => item.toUpperCase() === slot.toUpperCase())) list.push(slot);
- slotsByArea.set(row.area, list);
- });
- const selectedAreas = uniqueSorted(groupRules.flatMap((item) => areasOnRule(item, areaNames)));
- const viewArea = areaNames.find((area) => area.toUpperCase() === (viewByGroup[group.key] ?? "").toUpperCase()) ?? "";
- const viewedRule = groupRules.find((item) =>
- areasOnRule(item, areaNames).some((area) => area.toUpperCase() === viewArea.toUpperCase()),
- );
- const viewSlots = uniqueSorted(slotsByArea.get(viewArea) ?? []);
- const pressedSlots = viewArea ? shownSlots(viewedRule?.slots ?? "", viewSlots) : [];
- const partialAreas = new Set(
- selectedAreas
- .filter((area) => {
- const areaSlots = uniqueSorted(slotsByArea.get(area) ?? []);
- const owner = groupRules.find((item) =>
- areasOnRule(item, areaNames).some((name) => name.toUpperCase() === area.toUpperCase()),
- );
- return shownSlots(owner?.slots ?? "", areaSlots).length < areaSlots.length;
- })
- .map((area) => area.toUpperCase()),
- );
- const selectableRules = groupRules.filter((item) => excludeRuleHasTarget(item));
- const canEnable = selectableRules.length > 0;
- const applyGroup = (nextGroup: ExcludeWarehouseRule[]) => {
- const kept = nextGroup.length > 0 ? nextGroup : [{ ...rule, areas: "", slots: "" }];
- saveGroup(group.indexes, kept);
- };
- const setAreaIncluded = (area: string, included: boolean) => {
- if (!included) {
- const next = withoutArea(groupRules, area, areaNames);
- applyGroup(next);
- return;
- }
- applyGroup(withAreaSlots(groupRules, area, "", areaNames, rule));
- };
- return (
- <Box
- key={group.key}
- sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 1.5, minWidth: 0, overflow: "hidden" }}
- >
- <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5 }}>
- <Stack direction="row" spacing={1} alignItems="center">
- <Typography sx={{ fontWeight: 700 }}>{t("Exclude rule n", { n: groupIndex + 1 })}</Typography>
- <FormControlLabel
- sx={{ mr: 0 }}
- control={
- <Checkbox
- size="small"
- checked={
- canEnable &&
- overlappedBy.length === 0 &&
- selectableRules.every((item) => item.enabled !== false)
- }
- disabled={saving || !canEnable || overlappedBy.length > 0}
- onChange={(event) =>
- applyGroup(
- groupRules.map((item) => ({
- ...item,
- enabled: event.target.checked && excludeRuleHasTarget(item),
- })),
- )
- }
- />
- }
- label={t("Rule enabled")}
- />
- </Stack>
- <IconButton
- aria-label={t("Delete row")}
- size="small"
- disabled={saving}
- onClick={() => onSave(rules.filter((_, ruleIndex) => !group.indexes.includes(ruleIndex)))}
- >
- <DeleteOutline fontSize="small" />
- </IconButton>
- </Stack>
- {overlappedBy.length > 0 ? (
- <Alert severity="warning" sx={{ mb: 1.5 }}>
- {t("Putaway rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })}
- </Alert>
- ) : null}
- <Stack spacing={1.5}>
- <ToggleRows
- label={t("Floor")}
- options={floors}
- exclusive
- disabled={saving}
- value={rule.floor || null}
- onChange={(next) =>
- applyGroup([
- {
- ...rule,
- floor: typeof next === "string" ? next : "",
- warehouse: "",
- areas: "",
- slots: "",
- },
- ])
- }
- />
- <ToggleRows
- label={t("Warehouse")}
- options={warehouseOptions}
- exclusive
- disabled={saving || !rule.floor}
- value={rule.warehouse || null}
- onChange={(next) =>
- applyGroup([
- {
- ...rule,
- warehouse: typeof next === "string" ? next : "",
- areas: "",
- slots: "",
- },
- ])
- }
- />
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75, flexShrink: 0 }}>
- {t("Area")}
- </Typography>
- {areaNames.length === 0 ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- —
- </Typography>
- ) : (
- <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, flex: 1, minWidth: 0 }}>
- <Box
- sx={{
- width: BUTTON_WIDTH,
- minWidth: BUTTON_WIDTH,
- border: "1px solid",
- borderColor: "divider",
- borderRadius: 1,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- gap: 0.25,
- }}
- >
- <Checkbox
- size="small"
- disabled={saving}
- checked={areaNames.length > 0 && selectedAreas.length === areaNames.length}
- onChange={(_, checked) => {
- if (!checked) {
- applyGroup([{ ...rule, areas: "", slots: "" }]);
- return;
- }
- let next = groupRules;
- areaNames.forEach((area) => {
- if (!selectedAreas.some((item) => item.toUpperCase() === area.toUpperCase())) {
- next = withAreaSlots(next, area, "", areaNames, rule);
- }
- });
- applyGroup(next);
- }}
- sx={{ p: 0.25 }}
- />
- <Typography variant="body2">{t("All")}</Typography>
- </Box>
- {areaNames.map((area) => {
- const selected = selectedAreas.some((item) => item.toUpperCase() === area.toUpperCase());
- const viewing = viewArea.toUpperCase() === area.toUpperCase();
- const partial = partialAreas.has(area.toUpperCase());
- return (
- <Box
- key={area}
- sx={{
- width: BUTTON_WIDTH,
- minWidth: BUTTON_WIDTH,
- border: viewing ? "2px solid" : "1px solid",
- borderColor: viewing ? "primary.main" : partial ? "warning.main" : "divider",
- borderRadius: 1,
- bgcolor: partial ? "#FFF6E8" : selected ? "action.selected" : "transparent",
- display: "flex",
- alignItems: "center",
- }}
- >
- <Checkbox
- size="small"
- disabled={saving}
- checked={selected}
- onChange={(_, checked) => {
- setViewByGroup((prev) => ({ ...prev, [group.key]: area }));
- setAreaIncluded(area, checked);
- }}
- sx={{ p: 0.25 }}
- />
- <Box
- onClick={() => setViewByGroup((prev) => ({ ...prev, [group.key]: area }))}
- sx={{ flex: 1, textAlign: "center", cursor: "pointer", py: 0.5, pr: 0.5 }}
- >
- <Typography variant="body2">{area}</Typography>
- </Box>
- </Box>
- );
- })}
- </Box>
- )}
- </Box>
- <ToggleRows
- label={t("Putaway slot")}
- options={viewArea ? viewSlots : []}
- exclusive={false}
- disabled={saving || !viewArea}
- value={pressedSlots}
- onChange={(next) => {
- if (!viewArea) return;
- const picked = Array.isArray(next) ? next : [];
- applyGroup(withAreaSlots(groupRules, viewArea, slotsToStore(picked, viewSlots), areaNames, rule));
- }}
- />
- {!viewArea ? (
- <Typography variant="caption" color="text.secondary">
- {t("Click area to view slots")}
- </Typography>
- ) : (
- <Typography variant="caption" color="text.secondary">
- {t("Viewing area", { area: viewArea })}
- {!viewedRule ? ` ${t("Area not in rule")}` : ""}
- </Typography>
- )}
- <Box>
- <Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
- {t("Print list label")}
- </Typography>
- <ToggleButtonGroup
- exclusive
- value={rule.printMode === "hideQr" ? "hideQr" : "hideList"}
- disabled={saving}
- onChange={(_, next: ExcludePrintMode | null) => {
- if (!next) return;
- applyGroup(groupRules.map((item) => ({ ...item, printMode: next })));
- }}
- sx={{ flexWrap: "wrap" }}
- >
- <ToggleButton value="hideList" sx={{ textTransform: "none", px: 1.5, py: 1, alignItems: "flex-start" }}>
- <Box sx={{ textAlign: "left" }}>
- <Typography variant="body2" sx={{ fontWeight: 700 }}>
- {t("Print hide list")}
- </Typography>
- <Typography variant="caption" component="div" sx={{ color: "text.secondary", whiteSpace: "normal" }}>
- {t("Print hide list hint")}
- </Typography>
- </Box>
- </ToggleButton>
- <ToggleButton value="hideQr" sx={{ textTransform: "none", px: 1.5, py: 1, alignItems: "flex-start" }}>
- <Box sx={{ textAlign: "left" }}>
- <Typography variant="body2" sx={{ fontWeight: 700 }}>
- {t("Print hide qr")}
- </Typography>
- <Typography variant="caption" component="div" sx={{ color: "text.secondary", whiteSpace: "normal" }}>
- {t("Print hide qr hint")}
- </Typography>
- </Box>
- </ToggleButton>
- </ToggleButtonGroup>
- </Box>
- </Stack>
- </Box>
- );
- })
- )}
- <Button
- variant="outlined"
- disabled={saving}
- onClick={() => onSave([...rules, emptyExcludeWarehouseRule()])}
- sx={{ alignSelf: "flex-start" }}
- >
- {t("Add exclude rule")}
- </Button>
- </Stack>
- );
- }
|