diff --git a/src/app/api/settings/deliveryOrderFloor/client.ts b/src/app/api/settings/deliveryOrderFloor/client.ts index af23c2ca..59461708 100644 --- a/src/app/api/settings/deliveryOrderFloor/client.ts +++ b/src/app/api/settings/deliveryOrderFloor/client.ts @@ -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 { + 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 { + const res = await clientAuthFetch(`${base}/settings/pick-exclude-rules?scope=${scope}`); + return parseJson(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)), diff --git a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx index 34709cb8..3a1c2c8e 100644 --- a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx +++ b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx @@ -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([]); const [joExcludeRules, setJoExcludeRules] = useState([]); + const [savedDoExcludeRules, setSavedDoExcludeRules] = useState([]); + const [savedJoExcludeRules, setSavedJoExcludeRules] = useState([]); const [poPutAway, setPoPutAway] = useState([]); const [joPutAway, setJoPutAway] = useState([]); + const [savedPoPutAway, setSavedPoPutAway] = useState([]); + const [savedJoPutAway, setSavedJoPutAway] = useState([]); + const [savingSection, setSavingSection] = useState(null); + const [excludeMissingKind, setExcludeMissingKind] = useState(null); const [dialogSaving, setDialogSaving] = useState(false); const [comboLoading, setComboLoading] = useState(false); @@ -257,7 +276,6 @@ const DeliveryOrderFloorSettings: React.FC = () => { const [editLocationMode, setEditLocationMode] = useState("warehouse"); const [editWarehouseCode, setEditWarehouseCode] = useState(""); const [warehousePickTarget, setWarehousePickTarget] = useState("exclude"); - const excludeSavePendingRef = useRef<{ kind: ExcludeKind; next: ExcludeWarehouseRule[] } | null>(null); const [excludeQuery, setExcludeQuery] = useState(""); const [excludePick, setExcludePick] = useState(emptyExcludePick); const [excludeAddError, setExcludeAddError] = useState(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) => ( + + + + ); - 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))} ); }; @@ -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))} > @@ -1156,11 +1234,16 @@ const DeliveryOrderFloorSettings: React.FC = () => { variant="outlined" startIcon={} disabled={dialogSaving} - onClick={() => void persistPutAwayRules(kind, [...rules, emptyAutoPutAwayRule(kind)])} + onClick={() => stagePutAwayRules(kind, [...rules, emptyAutoPutAwayRule(kind)])} sx={{ mt: 1.5 }} > {t("Add putaway rule")} + {renderSectionSave( + putAwayDirty(kind) && putAwayEdit?.kind !== kind, + kind === "po" ? "putaway-po" : "putaway-jo", + () => void savePutAwayRules(kind), + )} ); }; @@ -1189,7 +1272,7 @@ const DeliveryOrderFloorSettings: React.FC = () => { void persistFloorSuppliers(floor, codes.filter((item) => item !== code))} + onDelete={dialogSaving ? undefined : () => updateFloorCodes(floor, codes.filter((item) => item !== code))} /> @@ -1318,6 +1401,7 @@ const DeliveryOrderFloorSettings: React.FC = () => { {renderSupplierRow("2F", codes2FList)} {renderSupplierRow("4F", codes4FList)} + {renderSectionSave(suppliersDirty, "suppliers", () => void saveFloorSuppliers())} {renderExcludeCard("do")} diff --git a/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx b/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx index 11cb7772..01c40fa7 100644 --- a/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx +++ b/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx @@ -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 ( - + {label} {options.length === 0 ? ( - + — ) : ( + {allLabel ? ( + + onToggleAll?.(checked)} + sx={{ p: 0.25 }} + /> + {allLabel} + + ) : null} {options.map((option) => ( 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>({}); const declinedCombineRef = React.useRef(new Set()); 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 ( index + 1).join(", ") })} ) : null} + {markMissing ? ( + + {t("Exclude rule incomplete", { + n: groupIndex + 1, + fields: missingFields.map(missingLabel).join("、"), + })} + + ) : null} 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({ } /> - + {t("Area")} {areaNames.length === 0 ? ( - + — ) : ( @@ -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 }} /> 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 }} > {area} @@ -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 ? ( diff --git a/src/i18n/en/deliveryOrderFloor.json b/src/i18n/en/deliveryOrderFloor.json index 07933908..7a7af790 100644 --- a/src/i18n/en/deliveryOrderFloor.json +++ b/src/i18n/en/deliveryOrderFloor.json @@ -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", diff --git a/src/i18n/zh/deliveryOrderFloor.json b/src/i18n/zh/deliveryOrderFloor.json index 7f10086b..28270187 100644 --- a/src/i18n/zh/deliveryOrderFloor.json +++ b/src/i18n/zh/deliveryOrderFloor.json @@ -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": "全部區域",