Pārlūkot izejas kodu

update

controllQRcodescanWarehouse
CANCERYS\kw093 pirms 1 mēnesi
vecāks
revīzija
b45816fd80
5 mainītis faili ar 349 papildinājumiem un 118 dzēšanām
  1. +35
    -2
      src/app/api/settings/deliveryOrderFloor/client.ts
  2. +190
    -106
      src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx
  3. +120
    -10
      src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx
  4. +2
    -0
      src/i18n/en/deliveryOrderFloor.json
  5. +2
    -0
      src/i18n/zh/deliveryOrderFloor.json

+ 35
- 2
src/app/api/settings/deliveryOrderFloor/client.ts Parādīt failu

@@ -282,6 +282,39 @@ function normalizeExcludeRule(row: unknown, fallbackMode: ExcludePrintMode): Exc
};
}

async function resolveStoredExcludeRules(
stored: string | undefined,
csv: string,
fallbackMode: ExcludePrintMode,
scope: "DO" | "JO",
): Promise<ExcludeWarehouseRule[]> {
const trimmed = stored?.trim() ?? "";
if (trimmed === "db" || trimmed.startsWith("[") || trimmed.startsWith("{")) {
return fetchPickExcludeRulesClient(scope);
}
const base = resolveExcludeRules(stored, csv, fallbackMode);
const archived = await fetchPickExcludeRulesClient(scope).catch(() => [] as ExcludeWarehouseRule[]);
const ids = new Set(base.map((rule) => rule.cardId));
return [...base, ...archived.filter((rule) => rule.enabled === false && rule.cardId && !ids.has(rule.cardId))];
}

export async function fetchPickExcludeRulesClient(scope: "DO" | "JO"): Promise<ExcludeWarehouseRule[]> {
const res = await clientAuthFetch(`${base}/settings/pick-exclude-rules?scope=${scope}`);
return parseJson<ExcludeWarehouseRule[]>(res);
}

export async function savePickExcludeRulesClient(
scope: "DO" | "JO",
rules: ExcludeWarehouseRule[],
): Promise<{ source: string; rules: ExcludeWarehouseRule[]; droppedUnusedOrDeleted: number }> {
const res = await clientAuthFetch(`${base}/settings/pick-exclude-rules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, rules }),
});
return parseJson(res);
}

function resolveExcludeRules(stored: string | undefined, csv: string, fallbackMode: ExcludePrintMode): ExcludeWarehouseRule[] {
const trimmed = stored?.trim() ?? "";
if (trimmed && trimmed !== EMPTY_CODE_LIST_MARKER) {
@@ -343,8 +376,8 @@ export async function fetchDoFloorSettingsClient(): Promise<{
suppliers4F: get(SETTING_DO_FLOOR_SUPPLIERS_4F),
doExcludeWarehouses,
joExcludeWarehouses,
doExcludeRules: resolveExcludeRules(raw(SETTING_DO_PICK_EXCLUDE_RULES), doExcludeWarehouses, doExcludePrintMode),
joExcludeRules: resolveExcludeRules(raw(SETTING_JO_PICK_EXCLUDE_RULES), joExcludeWarehouses, joExcludePrintMode),
doExcludeRules: await resolveStoredExcludeRules(raw(SETTING_DO_PICK_EXCLUDE_RULES), doExcludeWarehouses, doExcludePrintMode, "DO"),
joExcludeRules: await resolveStoredExcludeRules(raw(SETTING_JO_PICK_EXCLUDE_RULES), joExcludeWarehouses, joExcludePrintMode, "JO"),
doExcludePrintMode,
joExcludePrintMode,
doExcludeHideQrWarehouses: resolveHideQrCsv(raw(SETTING_DO_PICK_EXCLUDE_HIDE_QR)),


+ 190
- 106
src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx Parādīt failu

@@ -12,7 +12,11 @@ import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import ShoppingCartOutlined from "@mui/icons-material/ShoppingCartOutlined";
import WarningAmberOutlined from "@mui/icons-material/WarningAmberOutlined";
import Search from "@mui/icons-material/Search";
import { ExcludeWarehouseRuleList } from "@/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList";
import {
ExcludeWarehouseRuleList,
incompleteExcludeGroups,
type ExcludeMissingField,
} from "@/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList";
import {
Alert,
Box,
@@ -46,6 +50,7 @@ import {
fetchSupplierComboClient,
fetchWarehouseCodeRowsClient,
postSettingClient,
savePickExcludeRulesClient,
type ShopComboRow,
type WarehousePickRow,
} from "@/app/api/settings/deliveryOrderFloor/client";
@@ -59,6 +64,7 @@ import {
SETTING_DO_PICK_EXCLUDE_WAREHOUSES,
codesToExcludeRules,
compactExcludeRules,
ensureExcludeCardIds,
disableOverlappingExcludeRules,
partitionExcludeCodes,
DEFAULT_AUTO_PUTAWAY_WAREHOUSE,
@@ -86,6 +92,7 @@ function normalizeCodesCsv(raw: string): string {
type EditFloor = "2F" | "4F";
type ExcludeKind = "do" | "jo";
type PageTab = "do" | "jo" | "po";
type SettingsSaveSection = "suppliers" | "exclude-do" | "exclude-jo" | "putaway-po" | "putaway-jo";
type WarehousePickTarget = "exclude" | "putaway";
type PutAwayRowField = "keywords" | "bom" | "location" | "exceptionKeywords";
type PutAwayEditCell = { kind: AutoPutAwayScope; index: number; field: PutAwayRowField; exceptionIndex?: number };
@@ -200,6 +207,10 @@ function floorRowsToCsv(rows: FloorRow[]): string {
return rows.map((r) => r.code.trim()).filter(Boolean).join(",");
}

function sameJson(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}

const LOCATION_ALL = "ALL";
const BUTTONS_PER_ROW = 15;
const BUTTON_WIDTH = 80;
@@ -230,12 +241,20 @@ const DeliveryOrderFloorSettings: React.FC = () => {

const [codes2F, setCodes2F] = useState("");
const [codes4F, setCodes4F] = useState("");
const [savedCodes2F, setSavedCodes2F] = useState("");
const [savedCodes4F, setSavedCodes4F] = useState("");
const [doExclude, setDoExclude] = useState("");
const [joExclude, setJoExclude] = useState("");
const [doExcludeRules, setDoExcludeRules] = useState<ExcludeWarehouseRule[]>([]);
const [joExcludeRules, setJoExcludeRules] = useState<ExcludeWarehouseRule[]>([]);
const [savedDoExcludeRules, setSavedDoExcludeRules] = useState<ExcludeWarehouseRule[]>([]);
const [savedJoExcludeRules, setSavedJoExcludeRules] = useState<ExcludeWarehouseRule[]>([]);
const [poPutAway, setPoPutAway] = useState<AutoPutAwayRule[]>([]);
const [joPutAway, setJoPutAway] = useState<AutoPutAwayRule[]>([]);
const [savedPoPutAway, setSavedPoPutAway] = useState<AutoPutAwayRule[]>([]);
const [savedJoPutAway, setSavedJoPutAway] = useState<AutoPutAwayRule[]>([]);
const [savingSection, setSavingSection] = useState<SettingsSaveSection | null>(null);
const [excludeMissingKind, setExcludeMissingKind] = useState<ExcludeKind | null>(null);

const [dialogSaving, setDialogSaving] = useState(false);
const [comboLoading, setComboLoading] = useState(false);
@@ -257,7 +276,6 @@ const DeliveryOrderFloorSettings: React.FC = () => {
const [editLocationMode, setEditLocationMode] = useState<PutAwayLocationMode>("warehouse");
const [editWarehouseCode, setEditWarehouseCode] = useState("");
const [warehousePickTarget, setWarehousePickTarget] = useState<WarehousePickTarget>("exclude");
const excludeSavePendingRef = useRef<{ kind: ExcludeKind; next: ExcludeWarehouseRule[] } | null>(null);
const [excludeQuery, setExcludeQuery] = useState("");
const [excludePick, setExcludePick] = useState<ExcludePick>(emptyExcludePick);
const [excludeAddError, setExcludeAddError] = useState<string | null>(null);
@@ -270,12 +288,18 @@ const DeliveryOrderFloorSettings: React.FC = () => {
const floor = await fetchDoFloorSettingsClient();
setCodes2F(floor.suppliers2F);
setCodes4F(floor.suppliers4F);
setSavedCodes2F(floor.suppliers2F);
setSavedCodes4F(floor.suppliers4F);
setDoExclude(floor.doExcludeWarehouses);
setJoExclude(floor.joExcludeWarehouses);
setDoExcludeRules(floor.doExcludeRules);
setJoExcludeRules(floor.joExcludeRules);
setSavedDoExcludeRules(floor.doExcludeRules);
setSavedJoExcludeRules(floor.joExcludeRules);
setPoPutAway(floor.poAutoPutAway);
setJoPutAway(floor.joAutoPutAway);
setSavedPoPutAway(floor.poAutoPutAway);
setSavedJoPutAway(floor.joAutoPutAway);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
@@ -324,24 +348,64 @@ const DeliveryOrderFloorSettings: React.FC = () => {
const codes2FList = useMemo(() => supplierCodes(codes2F), [codes2F]);
const codes4FList = useMemo(() => supplierCodes(codes4F), [codes4F]);
const supplierName = (code: string) => findSupplierRow(supplierCombo ?? [], code)?.name?.trim() || "";
const suppliersDirty = useMemo(() => {
const current2 = normalizeCodesCsv(codes2FList.join(","));
const current4 = normalizeCodesCsv(codes4FList.join(","));
const saved2 = normalizeCodesCsv(supplierCodes(savedCodes2F).join(","));
const saved4 = normalizeCodesCsv(supplierCodes(savedCodes4F).join(","));
return current2 !== saved2 || current4 !== saved4;
}, [codes2FList, codes4FList, savedCodes2F, savedCodes4F]);
const excludeDirty = (kind: ExcludeKind) =>
!sameJson(
kind === "do" ? doExcludeRules : joExcludeRules,
kind === "do" ? savedDoExcludeRules : savedJoExcludeRules,
);
const putAwayDirty = (kind: AutoPutAwayScope) =>
!sameJson(kind === "po" ? poPutAway : joPutAway, kind === "po" ? savedPoPutAway : savedJoPutAway);

const renderSectionSave = (dirty: boolean, section: SettingsSaveSection, onClick: () => void) => (
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
<Button variant="contained" disabled={!dirty || dialogSaving} onClick={onClick}>
{dialogSaving && savingSection === section ? <CircularProgress size={22} color="inherit" /> : t("Save changes")}
</Button>
</Box>
);

const persistFloorSuppliers = async (floor: EditFloor, codes: string[]) => {
const updateFloorCodes = (floor: EditFloor, codes: string[]) => {
const normalized = normalizeCodesCsv(codes.filter((code) => code !== EMPTY_CODE_LIST_MARKER).join(","));
if (floor === "2F") setCodes2F(normalized);
else setCodes4F(normalized);
setSuccess(null);
};

const saveFloorSuppliers = async () => {
if (saveInFlightRef.current) return;
saveInFlightRef.current = true;
setDialogSaving(true);
setSavingSection("suppliers");
setError(null);
setSuccess(null);
try {
const normalized = normalizeCodesCsv(codes.filter((code) => code !== EMPTY_CODE_LIST_MARKER).join(","));
const key = floor === "2F" ? SETTING_DO_FLOOR_SUPPLIERS_2F : SETTING_DO_FLOOR_SUPPLIERS_4F;
await postSettingClient(key, normalized || EMPTY_CODE_LIST_MARKER);
if (floor === "2F") setCodes2F(normalized);
else setCodes4F(normalized);
const next2 = normalizeCodesCsv(codes2FList.join(","));
const next4 = normalizeCodesCsv(codes4FList.join(","));
const prev2 = normalizeCodesCsv(supplierCodes(savedCodes2F).join(","));
const prev4 = normalizeCodesCsv(supplierCodes(savedCodes4F).join(","));
if (next2 !== prev2) {
await postSettingClient(SETTING_DO_FLOOR_SUPPLIERS_2F, next2 || EMPTY_CODE_LIST_MARKER);
}
if (next4 !== prev4) {
await postSettingClient(SETTING_DO_FLOOR_SUPPLIERS_4F, next4 || EMPTY_CODE_LIST_MARKER);
}
setCodes2F(next2);
setCodes4F(next4);
setSavedCodes2F(next2);
setSavedCodes4F(next4);
setSuccess(t("Saved"));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDialogSaving(false);
setSavingSection(null);
saveInFlightRef.current = false;
}
};
@@ -375,7 +439,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
}
setSupplierRowError((prev) => ({ ...prev, [floor]: undefined }));
setSupplierInput((prev) => ({ ...prev, [floor]: "" }));
void persistFloorSuppliers(floor, [...current, canonical]);
updateFloorCodes(floor, [...current, canonical]);
};

const openExclude = async (kind: ExcludeKind) => {
@@ -520,50 +584,79 @@ const DeliveryOrderFloorSettings: React.FC = () => {
}, [warehouses, excludePick.storeId, excludePick.warehouse, excludePick.area]);
const slotRows = useMemo(() => chunk(putAwaySlotChoices, BUTTONS_PER_ROW), [putAwaySlotChoices]);

const persistExcludeRules = async (kind: ExcludeKind, incoming: ExcludeWarehouseRule[], silent = false) => {
const excludeFieldName = (field: ExcludeMissingField) => {
if (field === "floor") return t("Floor");
if (field === "warehouse") return t("Warehouse");
if (field === "area") return t("Area");
return t("Putaway slot");
};

const excludeIncompleteMessage = (rules: ExcludeWarehouseRule[]) => {
const incomplete = incompleteExcludeGroups(rules);
if (incomplete.length === 0) return null;
return incomplete
.map((issue) =>
t("Exclude rule incomplete", {
n: issue.n,
fields: issue.fields.map(excludeFieldName).join("、"),
}),
)
.join(" ");
};

const stageExcludeRules = (kind: ExcludeKind, incoming: ExcludeWarehouseRule[]) => {
const next = disableOverlappingExcludeRules(incoming);
if (kind === "do") setDoExcludeRules(next);
else setJoExcludeRules(next);
if (saveInFlightRef.current) {
excludeSavePendingRef.current = { kind, next };
setSuccess(null);
if (excludeMissingKind === kind) {
const message = excludeIncompleteMessage(next);
setError(message);
if (!message) setExcludeMissingKind(null);
}
};

const saveExcludeRules = async (kind: ExcludeKind) => {
if (saveInFlightRef.current) return;
const current = kind === "do" ? doExcludeRules : joExcludeRules;
const next = disableOverlappingExcludeRules(current);
const incompleteMessage = excludeIncompleteMessage(next);
if (incompleteMessage) {
setExcludeMissingKind(kind);
setError(incompleteMessage);
setSuccess(null);
return;
}
setExcludeMissingKind(null);
saveInFlightRef.current = true;
setDialogSaving(true);
setSavingSection(kind === "do" ? "exclude-do" : "exclude-jo");
setError(null);
setSuccess(null);
try {
const rows = warehouseCombo ?? (await fetchWarehouseCodeRowsClient());
if (!warehouseCombo) setWarehouseCombo(rows);
const partitioned = partitionExcludeCodes(next, rows);
const csv = partitioned.all.length > 0 ? partitioned.all.join(",") : EMPTY_CODE_LIST_MARKER;
const hideQrCsv = partitioned.hideQr.length > 0 ? partitioned.hideQr.join(",") : EMPTY_CODE_LIST_MARKER;
const rulesJson = JSON.stringify(compactExcludeRules(next));
if (csv.length > 1000 || hideQrCsv.length > 1000 || rulesJson.length > 1000) {
setError(t("List too long"));
return;
const saved = await savePickExcludeRulesClient(
kind === "do" ? "DO" : "JO",
ensureExcludeCardIds(compactExcludeRules(next)),
);
const kept = saved.rules;
if (kind === "do") {
setDoExcludeRules(kept);
setSavedDoExcludeRules(kept);
} else {
setJoExcludeRules(kept);
setSavedJoExcludeRules(kept);
}
const rulesKey = kind === "do" ? SETTING_DO_PICK_EXCLUDE_RULES : SETTING_JO_PICK_EXCLUDE_RULES;
const csvKey = kind === "do" ? SETTING_DO_PICK_EXCLUDE_WAREHOUSES : SETTING_JO_PICK_EXCLUDE_WAREHOUSES;
const hideQrKey = kind === "do" ? SETTING_DO_PICK_EXCLUDE_HIDE_QR : SETTING_JO_PICK_EXCLUDE_HIDE_QR;
const printModeKey = kind === "do" ? SETTING_DO_PICK_EXCLUDE_PRINT_MODE : SETTING_JO_PICK_EXCLUDE_PRINT_MODE;
const legacyMode =
partitioned.all.length > 0 && partitioned.hideQr.length === partitioned.all.length ? "hideQr" : "hideList";
await postSettingClient(rulesKey, rulesJson);
await postSettingClient(csvKey, csv);
await postSettingClient(hideQrKey, hideQrCsv);
await postSettingClient(printModeKey, legacyMode);
if (kind === "do") setDoExclude(csv === EMPTY_CODE_LIST_MARKER ? "" : csv);
else setJoExclude(csv === EMPTY_CODE_LIST_MARKER ? "" : csv);
if (!silent) setSuccess(t("Saved"));
setSuccess(
saved.droppedUnusedOrDeleted > 0
? t("Unused rules trimmed", { count: saved.droppedUnusedOrDeleted })
: t("Saved"),
);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDialogSaving(false);
setSavingSection(null);
saveInFlightRef.current = false;
const pending = excludeSavePendingRef.current;
excludeSavePendingRef.current = null;
if (pending) void persistExcludeRules(pending.kind, pending.next);
}
};

@@ -640,87 +733,69 @@ const DeliveryOrderFloorSettings: React.FC = () => {
}
};

const persistPutAwayRules = async (kind: AutoPutAwayScope, rules: AutoPutAwayRule[], silent = false) => {
const preparePutAwayRules = (kind: AutoPutAwayScope, rules: AutoPutAwayRule[]): AutoPutAwayRule[] =>
disableOverlappingRules(
kind,
rules.map((rule) => ({
itemKeywords: normalizeCodesCsv(rule.itemKeywords),
bomTypes: kind === "jo" ? normalizeCodesCsv(rule.bomTypes) : "",
locationMode: rule.locationMode,
warehouseCode: rule.warehouseCode.trim() || DEFAULT_AUTO_PUTAWAY_WAREHOUSE,
enabled: rule.enabled !== false,
exceptions: (rule.exceptions ?? []).map((row) => ({
itemKeywords: normalizeCodesCsv(row.itemKeywords),
})),
})),
);

const stagePutAwayRules = (kind: AutoPutAwayScope, rules: AutoPutAwayRule[]) => {
const next = preparePutAwayRules(kind, rules);
if (kind === "po") setPoPutAway(next);
else setJoPutAway(next);
setSuccess(null);
return next;
};

const savePutAwayRules = async (kind: AutoPutAwayScope) => {
if (saveInFlightRef.current) return;
const next = preparePutAwayRules(kind, kind === "po" ? poPutAway : joPutAway);
if (next.some((rule) => rule.locationMode === "warehouse" && !rule.warehouseCode)) {
setError(t("Select one warehouse"));
return;
}
const payload = JSON.stringify(next);
if (payload.length > 1000) {
setError(t("List too long"));
return;
}
saveInFlightRef.current = true;
setDialogSaving(true);
setSavingSection(kind === "po" ? "putaway-po" : "putaway-jo");
setError(null);
setSuccess(null);
try {
const next = disableOverlappingRules(
kind,
rules.map((rule) => ({
itemKeywords: normalizeCodesCsv(rule.itemKeywords),
bomTypes: kind === "jo" ? normalizeCodesCsv(rule.bomTypes) : "",
locationMode: rule.locationMode,
warehouseCode: rule.warehouseCode.trim() || DEFAULT_AUTO_PUTAWAY_WAREHOUSE,
enabled: rule.enabled !== false,
exceptions: (rule.exceptions ?? []).map((row) => ({
itemKeywords: normalizeCodesCsv(row.itemKeywords),
})),
})),
);
if (next.some((rule) => rule.locationMode === "warehouse" && !rule.warehouseCode)) {
setError(t("Select one warehouse"));
return;
}
const payload = JSON.stringify(next);
if (payload.length > 1000) {
setError(t("List too long"));
return;
}
const key = kind === "po" ? SETTING_PO_AUTO_PUTAWAY_RULES : SETTING_JO_AUTO_PUTAWAY_RULES;
await postSettingClient(key, next.length === 0 ? EMPTY_CODE_LIST_MARKER : payload);
if (kind === "po") setPoPutAway(next);
else setJoPutAway(next);
if (!silent) {
setSuccess(t("Saved"));
setPutAwayEdit(null);
if (kind === "po") {
setPoPutAway(next);
setSavedPoPutAway(next);
} else {
setJoPutAway(next);
setSavedJoPutAway(next);
}
setPutAwayEdit(null);
setSuccess(t("Saved"));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDialogSaving(false);
setSavingSection(null);
saveInFlightRef.current = false;
}
};

const rulesOf = (kind: AutoPutAwayScope) => (kind === "po" ? poPutAway : joPutAway);

useEffect(() => {
if (loading) return;
const jobs: Array<[AutoPutAwayScope, AutoPutAwayRule[]]> = [];
(["po", "jo"] as const).forEach((kind) => {
const current = kind === "po" ? poPutAway : joPutAway;
const fixed = disableOverlappingRules(kind, current);
const changed = fixed.some((rule, index) => rule.enabled !== (current[index]?.enabled !== false));
if (changed) jobs.push([kind, fixed]);
});
if (jobs.length === 0) return;
void (async () => {
for (const [kind, rules] of jobs) {
await persistPutAwayRules(kind, rules, true);
}
})();
}, [poPutAway, joPutAway, loading]);

useEffect(() => {
if (loading) return;
const jobs: Array<[ExcludeKind, ExcludeWarehouseRule[]]> = [];
(["do", "jo"] as const).forEach((kind) => {
const current = kind === "do" ? doExcludeRules : joExcludeRules;
const fixed = disableOverlappingExcludeRules(current);
const changed = fixed.some((rule, index) => rule.enabled !== (current[index]?.enabled !== false));
if (changed) jobs.push([kind, fixed]);
});
if (jobs.length === 0) return;
void (async () => {
for (const [kind, rules] of jobs) {
await persistExcludeRules(kind, rules, true);
}
})();
}, [doExcludeRules, joExcludeRules, loading]);

const startRowEdit = async (
kind: AutoPutAwayScope,
index: number,
@@ -759,7 +834,8 @@ const DeliveryOrderFloorSettings: React.FC = () => {
if (field === "exceptionKeywords") {
rule.exceptions = csvTokens(csv).length === 0 ? [] : [{ itemKeywords: csv }];
}
await persistPutAwayRules(kind, next);
stagePutAwayRules(kind, next);
setPutAwayEdit(null);
};

const putAwayTokenKey = (kind: AutoPutAwayScope, index: number, field: "keywords" | "exceptionKeywords") =>
@@ -780,7 +856,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
if (!rule) return;
if (field === "keywords") rule.itemKeywords = csv;
else rule.exceptions = csvTokens(csv).length === 0 ? [] : [{ itemKeywords: csv }];
void persistPutAwayRules(kind, next);
stagePutAwayRules(kind, next);
};

const addPutAwayToken = (
@@ -848,8 +924,10 @@ const DeliveryOrderFloorSettings: React.FC = () => {
rules={kind === "do" ? doExcludeRules : joExcludeRules}
warehouses={warehouseCombo ?? []}
saving={dialogSaving}
onSave={(next) => void persistExcludeRules(kind, next)}
showMissing={excludeMissingKind === kind}
onSave={(next) => stageExcludeRules(kind, next)}
/>
{renderSectionSave(excludeDirty(kind), kind === "do" ? "exclude-do" : "exclude-jo", () => void saveExcludeRules(kind))}
</Box>
);
};
@@ -1010,7 +1088,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
const next = rules.map((row, rowIndex) =>
rowIndex === index ? { ...row, enabled: event.target.checked } : row,
);
void persistPutAwayRules(kind, next);
stagePutAwayRules(kind, next);
}}
/>
}
@@ -1022,7 +1100,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
size="small"
color="error"
disabled={dialogSaving}
onClick={() => void persistPutAwayRules(kind, rules.filter((_, i) => i !== index))}
onClick={() => stagePutAwayRules(kind, rules.filter((_, i) => i !== index))}
>
<DeleteOutline fontSize="small" />
</IconButton>
@@ -1156,11 +1234,16 @@ const DeliveryOrderFloorSettings: React.FC = () => {
variant="outlined"
startIcon={<Add />}
disabled={dialogSaving}
onClick={() => void persistPutAwayRules(kind, [...rules, emptyAutoPutAwayRule(kind)])}
onClick={() => stagePutAwayRules(kind, [...rules, emptyAutoPutAwayRule(kind)])}
sx={{ mt: 1.5 }}
>
{t("Add putaway rule")}
</Button>
{renderSectionSave(
putAwayDirty(kind) && putAwayEdit?.kind !== kind,
kind === "po" ? "putaway-po" : "putaway-jo",
() => void savePutAwayRules(kind),
)}
</Box>
);
};
@@ -1189,7 +1272,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
<span>
<ValueChip
label={label}
onDelete={dialogSaving ? undefined : () => void persistFloorSuppliers(floor, codes.filter((item) => item !== code))}
onDelete={dialogSaving ? undefined : () => updateFloorCodes(floor, codes.filter((item) => item !== code))}
/>
</span>
</Tooltip>
@@ -1318,6 +1401,7 @@ const DeliveryOrderFloorSettings: React.FC = () => {
{renderSupplierRow("2F", codes2FList)}
{renderSupplierRow("4F", codes4FList)}
</Stack>
{renderSectionSave(suppliersDirty, "suppliers", () => void saveFloorSuppliers())}
</Box>
{renderExcludeCard("do")}
</Stack>


+ 120
- 10
src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx Parādīt failu

@@ -189,6 +189,33 @@ function withAreaSlots(
return next.length > 0 ? next : [{ ...template, areas: "", slots: "" }];
}

export type ExcludeMissingField = "floor" | "warehouse" | "area" | "slot";

export function missingExcludeFields(groupRules: ExcludeWarehouseRule[]): ExcludeMissingField[] {
const rule = groupRules[0];
const missing: ExcludeMissingField[] = [];
if (!rule?.floor.trim()) missing.push("floor");
if (!rule?.warehouse.trim()) missing.push("warehouse");
const withArea = groupRules.filter((item) => tokens(item.areas).length > 0);
if (withArea.length === 0) missing.push("area");
if (withArea.length === 0 || withArea.some((item) => tokens(item.slots).includes("-"))) missing.push("slot");
return missing;
}

export function incompleteExcludeGroups(
rules: ExcludeWarehouseRule[],
): Array<{ n: number; fields: ExcludeMissingField[] }> {
const ready = ensureExcludeCardIds(rules);
return groupExcludeRuleIndexes(ready).flatMap((indexes, groupIndex) => {
const groupRules = indexes
.map((index) => ready[index])
.filter((rule): rule is ExcludeWarehouseRule => rule != null);
const fields = missingExcludeFields(groupRules);
if (fields.length === 0) return [];
return [{ n: groupIndex + 1, fields }];
});
}

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();
@@ -200,6 +227,11 @@ function ToggleRows({
value,
exclusive,
disabled,
missing,
allLabel,
allChecked,
allIndeterminate,
onToggleAll,
onChange,
renderLabel,
}: {
@@ -208,6 +240,11 @@ function ToggleRows({
value: string | string[] | null;
exclusive: boolean;
disabled?: boolean;
missing?: boolean;
allLabel?: string;
allChecked?: boolean;
allIndeterminate?: boolean;
onToggleAll?: (checked: boolean) => void;
onChange: (next: string | string[] | null) => void;
renderLabel?: (option: string) => string;
}) {
@@ -226,15 +263,44 @@ function ToggleRows({

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 }}>
<Typography
variant="body2"
color={missing ? "error" : "text.primary"}
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 variant="body2" color={missing ? "error" : "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 }}>
{allLabel ? (
<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={disabled}
checked={Boolean(allChecked)}
indeterminate={Boolean(allIndeterminate)}
onChange={(_, checked) => onToggleAll?.(checked)}
sx={{ p: 0.25 }}
/>
<Typography variant="body2">{allLabel}</Typography>
</Box>
) : null}
{options.map((option) => (
<ToggleButton
key={option}
@@ -264,14 +330,22 @@ export function ExcludeWarehouseRuleList({
rules,
warehouses,
saving,
showMissing,
onSave,
}: {
rules: ExcludeWarehouseRule[];
warehouses: WarehousePickRow[];
saving: boolean;
showMissing?: boolean;
onSave: (next: ExcludeWarehouseRule[]) => void;
}) {
const { t } = useTranslation("deliveryOrderFloor");
const missingLabel = (field: ExcludeMissingField) => {
if (field === "floor") return t("Floor");
if (field === "warehouse") return t("Warehouse");
if (field === "area") return t("Area");
return t("Putaway slot");
};
const [viewByGroup, setViewByGroup] = React.useState<Record<string, string>>({});
const declinedCombineRef = React.useRef(new Set<string>());
const [combineAsk, setCombineAsk] = React.useState<{
@@ -357,12 +431,13 @@ export function ExcludeWarehouseRuleList({
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 viewKey = rule.cardId || group.key;
const viewArea = areaNames.find((area) => area.toUpperCase() === (viewByGroup[viewKey] ?? "").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 pressedSlots = viewedRule ? shownSlots(viewedRule.slots ?? "", viewSlots) : [];
const partialAreas = new Set(
selectedAreas
.filter((area) => {
@@ -374,6 +449,8 @@ export function ExcludeWarehouseRuleList({
})
.map((area) => area.toUpperCase()),
);
const missingFields = missingExcludeFields(groupRules);
const markMissing = Boolean(showMissing) && missingFields.length > 0;
const selectableRules = groupRules.filter((item) => excludeRuleHasTarget(item));
const canEnable = selectableRules.length > 0;
const applyGroup = (nextGroup: ExcludeWarehouseRule[], askCombine = false) => {
@@ -388,6 +465,15 @@ export function ExcludeWarehouseRuleList({
}
applyGroup(withAreaSlots(groupRules, area, "", areaNames, rule));
};
const setViewedSlots = (stored: string) => {
if (!viewArea) return;
setViewByGroup((prev) => ({ ...prev, [viewKey]: viewArea }));
if (stored === "-") {
applyGroup(withoutArea(groupRules, viewArea, areaNames));
return;
}
applyGroup(withAreaSlots(groupRules, viewArea, stored, areaNames, rule));
};
return (
<Box
key={group.key}
@@ -435,12 +521,21 @@ export function ExcludeWarehouseRuleList({
{t("Exclude rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })}
</Alert>
) : null}
{markMissing ? (
<Alert severity="error" sx={{ mb: 1.5 }}>
{t("Exclude rule incomplete", {
n: groupIndex + 1,
fields: missingFields.map(missingLabel).join("、"),
})}
</Alert>
) : null}
<Stack spacing={1.5}>
<ToggleRows
label={t("Floor")}
options={floors}
exclusive
disabled={saving}
missing={markMissing && missingFields.includes("floor")}
value={rule.floor || null}
onChange={(next) =>
applyGroup(
@@ -461,6 +556,7 @@ export function ExcludeWarehouseRuleList({
options={warehouseOptions}
exclusive
disabled={saving || !rule.floor}
missing={markMissing && missingFields.includes("warehouse")}
value={rule.warehouse || null}
onChange={(next) =>
applyGroup(
@@ -476,11 +572,19 @@ export function ExcludeWarehouseRuleList({
}
/>
<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 }}>
<Typography
variant="body2"
color={markMissing && missingFields.includes("area") ? "error" : "text.primary"}
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
variant="body2"
color={markMissing && missingFields.includes("area") ? "error" : "text.secondary"}
sx={{ mt: 0.75 }}
>
—
</Typography>
) : (
@@ -542,13 +646,13 @@ export function ExcludeWarehouseRuleList({
disabled={saving}
checked={selected}
onChange={(_, checked) => {
setViewByGroup((prev) => ({ ...prev, [group.key]: area }));
setViewByGroup((prev) => ({ ...prev, [viewKey]: area }));
setAreaIncluded(area, checked);
}}
sx={{ p: 0.25 }}
/>
<Box
onClick={() => setViewByGroup((prev) => ({ ...prev, [group.key]: area }))}
onClick={() => setViewByGroup((prev) => ({ ...prev, [viewKey]: area }))}
sx={{ flex: 1, textAlign: "center", cursor: "pointer", py: 0.5, pr: 0.5 }}
>
<Typography variant="body2">{area}</Typography>
@@ -564,11 +668,17 @@ export function ExcludeWarehouseRuleList({
options={viewArea ? viewSlots : []}
exclusive={false}
disabled={saving || !viewArea}
missing={markMissing && missingFields.includes("slot")}
allLabel={viewArea && viewSlots.length > 0 ? t("All") : undefined}
allChecked={viewSlots.length > 0 && pressedSlots.length === viewSlots.length}
allIndeterminate={pressedSlots.length > 0 && pressedSlots.length < viewSlots.length}
onToggleAll={(checked) => {
setViewedSlots(checked ? "" : "-");
}}
value={pressedSlots}
onChange={(next) => {
if (!viewArea) return;
const picked = Array.isArray(next) ? next : [];
applyGroup(withAreaSlots(groupRules, viewArea, slotsToStore(picked, viewSlots), areaNames, rule));
setViewedSlots(slotsToStore(picked, viewSlots));
}}
/>
{!viewArea ? (


+ 2
- 0
src/i18n/en/deliveryOrderFloor.json Parādīt failu

@@ -95,6 +95,7 @@
"Warehouse code not found": "This warehouse code does not exist in the system.",
"Duplicate warehouse": "This warehouse is already in the list.",
"List too long": "The list is too long. Remove some warehouse codes and save again.",
"Unused rules trimmed": "Saved. Only the latest 100 disabled or deleted rules are kept. {{count}} older ones were removed.",
"Floor": "Floor",
"Warehouse": "Warehouse",
"Area": "Area",
@@ -141,6 +142,7 @@
"Area slots partial": "{{areas}} still have unselected slots: {{slots}}. Those are not excluded.",
"Area slots complete": "Every slot in {{areas}} is selected.",
"Exclude rule n": "Rule {{n}}",
"Exclude rule incomplete": "Rule {{n}} is missing: {{fields}}",
"Add exclude rule": "Add rule",
"Empty exclude rules": "No exclude rules yet.",
"All areas": "All areas",


+ 2
- 0
src/i18n/zh/deliveryOrderFloor.json Parādīt failu

@@ -95,6 +95,7 @@
"Warehouse code not found": "此倉庫代碼不存在於系統主檔。",
"Duplicate warehouse": "此倉庫已在清單中。",
"List too long": "清單過長,請減少倉庫代碼後再儲存。",
"Unused rules trimmed": "已儲存。停用或已刪除的規則只保留最近 100 筆,較舊的 {{count}} 筆已移除。",
"Floor": "樓層",
"Warehouse": "倉庫",
"Area": "區域",
@@ -141,6 +142,7 @@
"Area slots partial": "{{areas}} 還有未選儲位:{{slots}}。這些不會排除。",
"Area slots complete": "{{areas}} 的儲位都已選。",
"Exclude rule n": "規則 {{n}}",
"Exclude rule incomplete": "規則 {{n}} 還沒填完:{{fields}}",
"Add exclude rule": "新增規則",
"Empty exclude rules": "目前沒有排除規則。",
"All areas": "全部區域",


Notiek ielāde…
Atcelt
Saglabāt