@@ -8,6 +8,10 @@ import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
Stack,
@@ -18,8 +22,11 @@ import {
import type { WarehousePickRow } from "@/app/api/settings/deliveryOrderFloor/client";
import {
EXCLUDE_ALL_AREAS,
combineCandidate,
earlierOverlappingExcludeGroups,
emptyExcludeWarehouseRule,
ensureExcludeCardIds,
excludeCombinePairKey,
excludeRuleHasTarget,
groupExcludeRuleIndexes,
type ExcludePrintMode,
@@ -100,6 +107,69 @@ function withoutArea(group: ExcludeWarehouseRule[], area: string, areaNames: str
.filter((rule) => tokens(rule.areas).length > 0);
}
function unionSlotCsv(leftCsv: string, rightCsv: string): string {
const left = tokens(leftCsv);
const right = tokens(rightCsv);
if (left.includes("-")) return right.includes("-") ? "-" : rightCsv;
if (right.includes("-")) return leftCsv;
if (left.length === 0 || right.length === 0) return "";
const seen = new Map<string, string>();
for (const slot of [...left, ...right]) seen.set(slot.toUpperCase(), slot);
return [...seen.values()].join(",");
}
function mergeExcludeCards(
rules: ExcludeWarehouseRule[],
sourceId: string,
targetId: string,
warehouses: WarehousePickRow[],
): ExcludeWarehouseRule[] {
const source = rules.filter((rule) => rule.cardId === sourceId);
const target = rules.filter((rule) => rule.cardId === targetId);
const base = target[0] ?? source[0];
if (!base) return rules;
const template: ExcludeWarehouseRule = { ...base, cardId: targetId, areas: "", slots: "", enabled: true };
const matched = warehouses.filter(
(row) =>
row.storeId.toUpperCase() === template.floor.trim().toUpperCase() &&
row.warehouse.toUpperCase() === template.warehouse.trim().toUpperCase(),
);
const areaNames = uniqueSorted(matched.map((row) => row.area));
const slotsByArea = new Map<string, string[]>();
matched.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 slotMap = new Map<string, string>();
const absorb = (card: ExcludeWarehouseRule[]) => {
for (const rule of card) {
const names = tokens(rule.areas).some((area) => area.toUpperCase() === EXCLUDE_ALL_AREAS) ? areaNames : tokens(rule.areas);
for (const area of names) {
const key = area.toUpperCase();
const previous = slotMap.get(key);
slotMap.set(key, previous == null ? rule.slots : unionSlotCsv(previous, rule.slots));
}
}
};
absorb(target);
absorb(source);
let built: ExcludeWarehouseRule[] = [];
for (const [key, slots] of slotMap) {
const area = areaNames.find((name) => name.toUpperCase() === key) ?? key;
const known = uniqueSorted(slotsByArea.get(area) ?? []);
const picked = tokens(slots);
const stored = known.length === 0 ? slots : slotsToStore(picked.includes("-") ? [] : picked.length === 0 ? known : known.filter((slot) => picked.some((item) => item.toUpperCase() === slot.toUpperCase())), known);
built = withAreaSlots(built, area, stored, areaNames, template);
}
built = built.map((rule) => ({ ...rule, cardId: targetId, enabled: true, printMode: template.printMode }));
const firstTarget = rules.findIndex((rule) => rule.cardId === targetId);
const rest = rules.filter((rule) => rule.cardId !== sourceId && rule.cardId !== targetId);
const insertAt = rules.slice(0, Math.max(firstTarget, 0)).filter((rule) => rule.cardId !== sourceId && rule.cardId !== targetId).length;
return [...rest.slice(0, insertAt), ...built, ...rest.slice(insertAt)];
}
function withAreaSlots(
group: ExcludeWarehouseRule[],
area: string,
@@ -203,24 +273,72 @@ export function ExcludeWarehouseRuleList({
}) {
const { t } = useTranslation("deliveryOrderFloor");
const [viewByGroup, setViewByGroup] = React.useState<Record<string, string>>({});
const declinedCombineRef = React.useRef(new Set<string>());
const [combineAsk, setCombineAsk] = React.useState<{
next: ExcludeWarehouseRule[];
editedIndexes: number[];
targetCardId: string;
targetNumber: number;
fromNumber: number;
floor: string;
warehouse: string;
} | null>(null);
const readyRules = React.useMemo(() => ensureExcludeCardIds(rules), [rules]);
const saveGroup = (indexes: number[], nextGroup: ExcludeWarehouseRule[], askCombine = false) => {
const next = replaceGroup(readyRules, indexes, nextGroup);
if (askCombine) {
const editedIndex = indexes[0] ?? 0;
const candidate = combineCandidate(next, editedIndex, declinedCombineRef.current);
const edited = next[editedIndex];
if (candidate && edited) {
setCombineAsk({
next,
editedIndexes: Array.from({ length: nextGroup.length }, (_, offset) => editedIndex + offset),
targetCardId: candidate.cardId,
targetNumber: candidate.groupNumber,
fromNumber: candidate.fromNumber,
floor: edited.floor,
warehouse: edited.warehouse,
});
return;
}
}
onSave(next);
};
const acceptCombine = () => {
if (!combineAsk) return;
const edited = combineAsk.next[combineAsk.editedIndexes[0] ?? -1];
const merged = edited
? mergeExcludeCards(combineAsk.next, edited.cardId, combineAsk.targetCardId, warehouses)
: combineAsk.next;
setCombineAsk(null);
onSave(merged);
};
const saveGroup = (indexes: number[], nextGroup: ExcludeWarehouseRule[]) => {
onSave(replaceGroup(rules, indexes, nextGroup));
const declineCombine = () => {
if (!combineAsk) return;
const edited = combineAsk.next[combineAsk.editedIndexes[0] ?? -1];
if (edited) declinedCombineRef.current.add(excludeCombinePairKey(edited.cardId, combineAsk.targetCardId, edited));
const next = combineAsk.next;
setCombineAsk(null);
onSave(next);
};
const floors = uniqueSorted(warehouses.map((row) => row.storeId));
const groups = groupExcludeRuleIndexes(rules).map((indexes) => ({ key: indexes.join(","), indexes }));
const groups = groupExcludeRuleIndexes(readyR ules).map((indexes) => ({ key: indexes.join(","), indexes }));
return (
<Stack spacing={1.5}>
{rules.length === 0 ? (
{readyR ules.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 overlappedBy = earlierOverlappingExcludeGroups(readyR ules, groupIndex);
const groupRules = group.indexes.map((index) => readyR ules[index]).filter((rule): rule is ExcludeWarehouseRule => rule != null);
const rule = groupRules[0];
if (!rule) return null;
const warehousesForFloor = warehouses.filter(
@@ -258,9 +376,9 @@ export function ExcludeWarehouseRuleList({
);
const selectableRules = groupRules.filter((item) => excludeRuleHasTarget(item));
const canEnable = selectableRules.length > 0;
const applyGroup = (nextGroup: ExcludeWarehouseRule[]) => {
const applyGroup = (nextGroup: ExcludeWarehouseRule[], askCombine = false ) => {
const kept = nextGroup.length > 0 ? nextGroup : [{ ...rule, areas: "", slots: "" }];
saveGroup(group.indexes, kept);
saveGroup(group.indexes, kept, askCombine );
};
const setAreaIncluded = (area: string, included: boolean) => {
if (!included) {
@@ -295,6 +413,7 @@ export function ExcludeWarehouseRuleList({
...item,
enabled: event.target.checked && excludeRuleHasTarget(item),
})),
event.target.checked,
)
}
/>
@@ -306,14 +425,14 @@ export function ExcludeWarehouseRuleList({
aria-label={t("Delete row")}
size="small"
disabled={saving}
onClick={() => onSave(rules.filter((_, ruleIndex) => !group.indexes.includes(ruleIndex)))}
onClick={() => onSave(readyR ules.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(", ") })}
{t("Exclude rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })}
</Alert>
) : null}
<Stack spacing={1.5}>
@@ -324,15 +443,17 @@ export function ExcludeWarehouseRuleList({
disabled={saving}
value={rule.floor || null}
onChange={(next) =>
applyGroup([
{
...rule,
floor: typeof next === "string" ? next : "",
warehouse: "",
areas: "",
slots: "",
},
])
applyGroup(
[
{
...rule,
floor: typeof next === "string" ? next : "",
warehouse: "",
areas: "",
slots: "",
},
],
)
}
/>
<ToggleRows
@@ -342,14 +463,16 @@ export function ExcludeWarehouseRuleList({
disabled={saving || !rule.floor}
value={rule.warehouse || null}
onChange={(next) =>
applyGroup([
{
...rule,
warehouse: typeof next === "string" ? next : "",
areas: "",
slots: "",
},
])
applyGroup(
[
{
...rule,
warehouse: typeof next === "string" ? next : "",
areas: "",
slots: "",
},
],
)
}
/>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}>
@@ -502,11 +625,32 @@ export function ExcludeWarehouseRuleList({
<Button
variant="outlined"
disabled={saving}
onClick={() => onSave([...rules, emptyExcludeWarehouseRule()])}
onClick={() => onSave([...readyR ules, emptyExcludeWarehouseRule()])}
sx={{ alignSelf: "flex-start" }}
>
{t("Add exclude rule")}
</Button>
<Dialog open={combineAsk != null} onClose={declineCombine} fullWidth maxWidth="xs">
<DialogTitle>{t("Combine exclude rules title")}</DialogTitle>
<DialogContent>
<Typography variant="body2">
{combineAsk
? t("Combine exclude rules", {
from: combineAsk.fromNumber,
to: combineAsk.targetNumber,
floor: combineAsk.floor,
warehouse: combineAsk.warehouse,
})
: ""}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={declineCombine}>{t("Combine decline")}</Button>
<Button variant="contained" onClick={acceptCombine}>
{t("Combine accept")}
</Button>
</DialogActions>
</Dialog>
</Stack>
);
}