diff --git a/src/app/api/settings/deliveryOrderFloor/client.ts b/src/app/api/settings/deliveryOrderFloor/client.ts index ca8d87fc..6f7081e9 100644 --- a/src/app/api/settings/deliveryOrderFloor/client.ts +++ b/src/app/api/settings/deliveryOrderFloor/client.ts @@ -11,24 +11,31 @@ import { EMPTY_CODE_LIST_MARKER, SETTING_DO_FLOOR_SUPPLIERS_2F, SETTING_DO_FLOOR_SUPPLIERS_4F, + SETTING_DO_PICK_EXCLUDE_HIDE_QR, SETTING_DO_PICK_EXCLUDE_PRINT_MODE, + SETTING_DO_PICK_EXCLUDE_RULES, SETTING_DO_PICK_EXCLUDE_WAREHOUSES, SETTING_JO_AUTO_PUTAWAY_BOM_TYPES, SETTING_JO_AUTO_PUTAWAY_ITEM_KEYWORDS, SETTING_JO_AUTO_PUTAWAY_RULES, SETTING_JO_AUTO_PUTAWAY_WAREHOUSE, + SETTING_JO_PICK_EXCLUDE_HIDE_QR, SETTING_JO_PICK_EXCLUDE_PRINT_MODE, + SETTING_JO_PICK_EXCLUDE_RULES, SETTING_JO_PICK_EXCLUDE_WAREHOUSES, SETTING_PO_AUTO_PUTAWAY_BOM_TYPES, SETTING_PO_AUTO_PUTAWAY_ITEM_KEYWORDS, SETTING_PO_AUTO_PUTAWAY_RULES, SETTING_PO_AUTO_PUTAWAY_WAREHOUSE, + codesToExcludeRules, + compactExcludeRules, defaultJoAutoPutAwayRules, defaultPoAutoPutAwayRules, type AutoPutAwayException, type AutoPutAwayRule, type AutoPutAwayScope, type ExcludePrintMode, + type ExcludeWarehouseRule, type PutAwayLocationMode, } from "./constants"; @@ -258,26 +265,84 @@ function resolveAutoPutAwayRules( return scope === "po" ? defaultPoAutoPutAwayRules() : defaultJoAutoPutAwayRules(); } +function normalizeExcludeRule(row: unknown, fallbackMode: ExcludePrintMode): ExcludeWarehouseRule | null { + if (!row || typeof row !== "object") return null; + const value = row as Record; + const text = (key: string) => (typeof value[key] === "string" ? value[key] : ""); + const mode = text("printMode").trim(); + return { + floor: text("floor").trim(), + warehouse: text("warehouse").trim(), + areas: text("areas"), + slots: text("slots"), + printMode: mode === "hideQr" ? "hideQr" : mode === "hideList" ? "hideList" : fallbackMode, + enabled: value.enabled !== false, + }; +} + +function resolveExcludeRules(stored: string | undefined, csv: string, fallbackMode: ExcludePrintMode): ExcludeWarehouseRule[] { + const trimmed = stored?.trim() ?? ""; + if (trimmed && trimmed !== EMPTY_CODE_LIST_MARKER) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return compactExcludeRules( + parsed.map((row) => normalizeExcludeRule(row, fallbackMode)).filter((row): row is ExcludeWarehouseRule => row != null), + ); + } + } catch { + // Fall through to the warehouse-code list. + } + } + return compactExcludeRules(codesToExcludeRules(csv).map((rule) => ({ ...rule, printMode: fallbackMode }))); +} + +/** Missing hide-QR row means the old single print mode still applies. `-` means no hide-QR warehouses. */ +export function resolveHideQrCsv(raw: string | undefined): string | null { + if (raw == null) return null; + const trimmed = raw.trim(); + if (!trimmed || trimmed === EMPTY_CODE_LIST_MARKER) return ""; + return trimmed; +} + export async function fetchDoFloorSettingsClient(): Promise<{ suppliers2F: string; suppliers4F: string; doExcludeWarehouses: string; joExcludeWarehouses: string; + doExcludeRules: ExcludeWarehouseRule[]; + joExcludeRules: ExcludeWarehouseRule[]; doExcludePrintMode: ExcludePrintMode; joExcludePrintMode: ExcludePrintMode; + doExcludeHideQrWarehouses: string | null; + joExcludeHideQrWarehouses: string | null; poAutoPutAway: AutoPutAwayRule[]; joAutoPutAway: AutoPutAwayRule[]; }> { const all = await fetchAllSettingsClient(); const get = (name: string) => all.find((s) => s.name === name)?.value ?? ""; const raw = (name: string) => all.find((s) => s.name === name)?.value; + const doExcludeWarehouses = resolveExcludeCsv( + raw(SETTING_DO_PICK_EXCLUDE_WAREHOUSES), + DEFAULT_DO_PICK_EXCLUDE_WAREHOUSES, + ); + const joExcludeWarehouses = resolveExcludeCsv( + raw(SETTING_JO_PICK_EXCLUDE_WAREHOUSES), + DEFAULT_JO_PICK_EXCLUDE_WAREHOUSES, + ); + const doExcludePrintMode = resolvePrintMode(raw(SETTING_DO_PICK_EXCLUDE_PRINT_MODE)); + const joExcludePrintMode = resolvePrintMode(raw(SETTING_JO_PICK_EXCLUDE_PRINT_MODE)); return { suppliers2F: get(SETTING_DO_FLOOR_SUPPLIERS_2F), suppliers4F: get(SETTING_DO_FLOOR_SUPPLIERS_4F), - doExcludeWarehouses: resolveExcludeCsv(raw(SETTING_DO_PICK_EXCLUDE_WAREHOUSES), DEFAULT_DO_PICK_EXCLUDE_WAREHOUSES), - joExcludeWarehouses: resolveExcludeCsv(raw(SETTING_JO_PICK_EXCLUDE_WAREHOUSES), DEFAULT_JO_PICK_EXCLUDE_WAREHOUSES), - doExcludePrintMode: resolvePrintMode(raw(SETTING_DO_PICK_EXCLUDE_PRINT_MODE)), - joExcludePrintMode: resolvePrintMode(raw(SETTING_JO_PICK_EXCLUDE_PRINT_MODE)), + doExcludeWarehouses, + joExcludeWarehouses, + doExcludeRules: resolveExcludeRules(raw(SETTING_DO_PICK_EXCLUDE_RULES), doExcludeWarehouses, doExcludePrintMode), + joExcludeRules: resolveExcludeRules(raw(SETTING_JO_PICK_EXCLUDE_RULES), joExcludeWarehouses, joExcludePrintMode), + doExcludePrintMode, + joExcludePrintMode, + doExcludeHideQrWarehouses: resolveHideQrCsv(raw(SETTING_DO_PICK_EXCLUDE_HIDE_QR)), + joExcludeHideQrWarehouses: resolveHideQrCsv(raw(SETTING_JO_PICK_EXCLUDE_HIDE_QR)), poAutoPutAway: resolveAutoPutAwayRules(raw, "po"), joAutoPutAway: resolveAutoPutAwayRules(raw, "jo"), }; diff --git a/src/app/api/settings/deliveryOrderFloor/constants.ts b/src/app/api/settings/deliveryOrderFloor/constants.ts index 667f7021..8ca0ebc2 100644 --- a/src/app/api/settings/deliveryOrderFloor/constants.ts +++ b/src/app/api/settings/deliveryOrderFloor/constants.ts @@ -7,6 +7,10 @@ export const SETTING_DO_PICK_EXCLUDE_WAREHOUSES = "DO.pick.excludeWarehouses"; export const SETTING_JO_PICK_EXCLUDE_WAREHOUSES = "JO.pick.excludeWarehouses"; export const SETTING_DO_PICK_EXCLUDE_PRINT_MODE = "DO.pick.excludePrintMode"; export const SETTING_JO_PICK_EXCLUDE_PRINT_MODE = "JO.pick.excludePrintMode"; +export const SETTING_DO_PICK_EXCLUDE_HIDE_QR = "DO.pick.excludeHideQrWarehouses"; +export const SETTING_JO_PICK_EXCLUDE_HIDE_QR = "JO.pick.excludeHideQrWarehouses"; +export const SETTING_DO_PICK_EXCLUDE_RULES = "DO.pick.excludeRules"; +export const SETTING_JO_PICK_EXCLUDE_RULES = "JO.pick.excludeRules"; /** hideList:清單不出現,掃到實物 QR 也不通過。hideQr:清單仍顯示但不顯示 QR,掃到實物 QR 可以通過。 */ export type ExcludePrintMode = "hideList" | "hideQr"; export const EMPTY_CODE_LIST_MARKER = "-"; @@ -137,4 +141,227 @@ export function resolveAutoPutAwayTarget( return { locationMode: rule.locationMode, warehouseCode: rule.warehouseCode }; } return null; +} + +/** Floor + warehouse scope. Empty slots mean every slot in the chosen area. `*` area means every area. */ +export type ExcludeWarehouseRule = { + floor: string; + warehouse: string; + areas: string; + slots: string; + printMode: ExcludePrintMode; + enabled: boolean; +}; + +function listTokens(raw: string): string[] { + return raw + .split(",") + .map((part) => part.trim().toUpperCase()) + .filter(Boolean); +} + +export function splitWarehouseCode(code: string): { floor: string; warehouse: string; area: string; slot: string } | null { + const parts = code.split("-").map((part) => part.trim()).filter(Boolean); + if (parts.length < 4) return null; + return { floor: parts[0], warehouse: parts[1], area: parts[2], slot: parts.slice(3).join("-") }; +} + +/** One rule uses one area. `*` means every area in that warehouse. */ +export const EXCLUDE_ALL_AREAS = "*"; + +export function codesToExcludeRules(csv: string): ExcludeWarehouseRule[] { + const groups = new Map(); + const order: string[] = []; + for (const code of csv.split(",").map((part) => part.trim()).filter(Boolean)) { + const parsed = splitWarehouseCode(code); + if (!parsed) continue; + const key = `${parsed.floor.toUpperCase()}|${parsed.warehouse.toUpperCase()}|${parsed.area.toUpperCase()}`; + if (!groups.has(key)) { + groups.set(key, { floor: parsed.floor, warehouse: parsed.warehouse, area: parsed.area, slots: [] }); + order.push(key); + } + const group = groups.get(key); + if (group && !group.slots.some((slot) => slot.toUpperCase() === parsed.slot.toUpperCase())) { + group.slots.push(parsed.slot); + } + } + return order.map((key) => { + const group = groups.get(key); + return { + floor: group?.floor ?? "", + warehouse: group?.warehouse ?? "", + areas: group?.area ?? "", + slots: group?.slots.join(",") ?? "", + printMode: "hideList", + enabled: true, + }; + }); +} + +/** Merge rules that share the same warehouse, slots, and list handling, so one card can show many areas. */ +export function compactExcludeRules(rules: ExcludeWarehouseRule[]): ExcludeWarehouseRule[] { + const grouped = new Map(); + const order: string[] = []; + for (const rule of rules) { + const key = [ + rule.floor.trim().toUpperCase(), + rule.warehouse.trim().toUpperCase(), + listTokens(rule.slots).sort().join(","), + rule.printMode === "hideQr" ? "hideQr" : "hideList", + rule.enabled === false ? "off" : "on", + ].join("|"); + const existing = grouped.get(key); + const area = rule.areas.trim(); + if (!existing) { + grouped.set(key, { ...rule, areas: area }); + order.push(key); + continue; + } + const areas = existing.areas.split(",").map((part) => part.trim()).filter(Boolean); + if (area && !areas.some((item) => item.toUpperCase() === area.toUpperCase())) areas.push(area); + existing.areas = areas.join(","); + } + return order.map((key) => grouped.get(key) ?? emptyExcludeWarehouseRule()); +} + +export function expandExcludeRules( + rules: ExcludeWarehouseRule[], + warehouses: Array<{ code: string; storeId: string; warehouse: string }>, +): string[] { + const codes: string[] = []; + for (const rule of rules) { + if (rule.enabled === false) continue; + const areas = listTokens(rule.areas); + const allAreas = areas.includes(EXCLUDE_ALL_AREAS); + const slots = listTokens(rule.slots); + for (const row of warehouses) { + const parsed = splitWarehouseCode(row.code); + if (!parsed) continue; + if (parsed.floor.toUpperCase() !== rule.floor.trim().toUpperCase()) continue; + if (parsed.warehouse.toUpperCase() !== rule.warehouse.trim().toUpperCase()) continue; + if (!allAreas && !areas.includes(parsed.area.toUpperCase())) continue; + if (slots.includes("-")) continue; + if (slots.length > 0 && !slots.includes(parsed.slot.toUpperCase())) continue; + codes.push(row.code.trim()); + } + } + return [...new Set(codes)]; +} + +export function partitionExcludeCodes( + rules: ExcludeWarehouseRule[], + warehouses: Array<{ code: string; storeId: string; warehouse: string }>, +): { all: string[]; hideQr: string[] } { + const ready = rules.filter((rule) => rule.floor.trim() && rule.warehouse.trim() && rule.areas.trim()); + const hideList = new Set( + expandExcludeRules( + ready.filter((rule) => rule.printMode !== "hideQr"), + warehouses, + ).map((code) => code.toUpperCase()), + ); + const hideQr = expandExcludeRules( + ready.filter((rule) => rule.printMode === "hideQr"), + warehouses, + ).filter((code) => !hideList.has(code.toUpperCase())); + const all = expandExcludeRules(ready, warehouses); + return { all, hideQr }; +} + +export function emptyExcludeWarehouseRule(): ExcludeWarehouseRule { + return { floor: "", warehouse: "", areas: "", slots: "", printMode: "hideList", enabled: true }; +} + +function excludePlaceKey(rule: ExcludeWarehouseRule): string { + return [ + rule.floor.trim().toUpperCase(), + rule.warehouse.trim().toUpperCase(), + rule.printMode === "hideQr" ? "hideQr" : "hideList", + ].join("|"); +} + +/** A rule can be enabled only when it has an area and at least one slot. Empty slots mean every slot. `-` means none. */ +export function excludeRuleHasTarget(rule: ExcludeWarehouseRule): boolean { + if (listTokens(rule.areas).length === 0) return false; + return !listTokens(rule.slots).includes("-"); +} + +function excludeAreasIntersect(a: ExcludeWarehouseRule, b: ExcludeWarehouseRule): boolean { + const left = listTokens(a.areas); + const right = listTokens(b.areas); + if (left.length === 0 || right.length === 0) return false; + if (left.includes(EXCLUDE_ALL_AREAS) || right.includes(EXCLUDE_ALL_AREAS)) return true; + return right.some((area) => left.includes(area)); +} + +function excludeSlotsOverlap(a: string, b: string): boolean { + const left = listTokens(a); + const right = listTokens(b); + if (left.includes("-") || right.includes("-")) return false; + if (left.length === 0 || right.length === 0) return true; + return right.some((slot) => left.includes(slot)); +} + +function excludeRulesOverlap(a: ExcludeWarehouseRule, b: ExcludeWarehouseRule): boolean { + if (!a.floor.trim() || !b.floor.trim() || !a.warehouse.trim() || !b.warehouse.trim()) return false; + if (a.floor.trim().toUpperCase() !== b.floor.trim().toUpperCase()) return false; + if (a.warehouse.trim().toUpperCase() !== b.warehouse.trim().toUpperCase()) return false; + if (!excludeAreasIntersect(a, b)) return false; + return excludeSlotsOverlap(a.slots, b.slots); +} + +/** Same floor, warehouse, and list handling stay one card when their areas do not overlap. */ +export function groupExcludeRuleIndexes(rules: ExcludeWarehouseRule[]): number[][] { + const groups: number[][] = []; + rules.forEach((rule, index) => { + const incomplete = !rule.floor.trim() || !rule.warehouse.trim(); + const found = incomplete + ? undefined + : groups.find((indexes) => { + const first = rules[indexes[0]]; + if (!first || excludePlaceKey(first) !== excludePlaceKey(rule)) return false; + return indexes.every((earlier) => { + const other = rules[earlier]; + return other != null && !excludeAreasIntersect(other, rule); + }); + }); + if (found) found.push(index); + else groups.push([index]); + }); + return groups; +} + +export function earlierOverlappingExcludeGroups(rules: ExcludeWarehouseRule[], groupIndex: number): number[] { + const groups = groupExcludeRuleIndexes(rules); + const group = groups[groupIndex]; + if (!group) return []; + const hits: number[] = []; + for (let earlier = 0; earlier < groupIndex; earlier += 1) { + const other = groups[earlier]; + if (!other || other.every((index) => rules[index]?.enabled === false)) continue; + const overlapped = other.some((left) => + group.some((right) => { + const a = rules[left]; + const b = rules[right]; + return a != null && b != null && excludeRulesOverlap(a, b); + }), + ); + if (overlapped) hits.push(earlier); + } + return hits; +} + +export function disableOverlappingExcludeRules(rules: ExcludeWarehouseRule[]): ExcludeWarehouseRule[] { + const next = rules.map((rule) => ({ + ...rule, + enabled: rule.enabled !== false && excludeRuleHasTarget(rule), + })); + const groups = groupExcludeRuleIndexes(next); + groups.forEach((_, groupIndex) => { + if (earlierOverlappingExcludeGroups(next, groupIndex).length === 0) return; + groups[groupIndex]?.forEach((index) => { + const rule = next[index]; + if (rule) rule.enabled = false; + }); + }); + return next; } \ No newline at end of file diff --git a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx index d816f502..34709cb8 100644 --- a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx +++ b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -12,7 +12,7 @@ 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 ExpandMore from "@mui/icons-material/ExpandMore"; +import { ExcludeWarehouseRuleList } from "@/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList"; import { Alert, Box, @@ -24,13 +24,8 @@ import { DialogActions, DialogContent, DialogTitle, - FormControl, FormControlLabel, IconButton, - InputLabel, - MenuItem, - Select, - type SelectChangeEvent, Stack, Table, TableBody, @@ -43,6 +38,7 @@ import { TextField, ToggleButton, ToggleButtonGroup, + Tooltip, Typography, } from "@mui/material"; import { @@ -57,17 +53,25 @@ import { EMPTY_CODE_LIST_MARKER, SETTING_DO_FLOOR_SUPPLIERS_2F, SETTING_DO_FLOOR_SUPPLIERS_4F, + SETTING_DO_PICK_EXCLUDE_HIDE_QR, SETTING_DO_PICK_EXCLUDE_PRINT_MODE, + SETTING_DO_PICK_EXCLUDE_RULES, SETTING_DO_PICK_EXCLUDE_WAREHOUSES, + codesToExcludeRules, + compactExcludeRules, + disableOverlappingExcludeRules, + partitionExcludeCodes, DEFAULT_AUTO_PUTAWAY_WAREHOUSE, SETTING_JO_AUTO_PUTAWAY_RULES, + SETTING_JO_PICK_EXCLUDE_HIDE_QR, SETTING_JO_PICK_EXCLUDE_PRINT_MODE, + SETTING_JO_PICK_EXCLUDE_RULES, SETTING_JO_PICK_EXCLUDE_WAREHOUSES, SETTING_PO_AUTO_PUTAWAY_RULES, emptyAutoPutAwayRule, type AutoPutAwayRule, type AutoPutAwayScope, - type ExcludePrintMode, + type ExcludeWarehouseRule, type PutAwayLocationMode, } from "@/app/api/settings/deliveryOrderFloor/constants"; @@ -86,12 +90,13 @@ type WarehousePickTarget = "exclude" | "putaway"; type PutAwayRowField = "keywords" | "bom" | "location" | "exceptionKeywords"; type PutAwayEditCell = { kind: AutoPutAwayScope; index: number; field: PutAwayRowField; exceptionIndex?: number }; -function exceptionOutsideParent(parentKeywords: string, exceptionKeywords: string): boolean { +function exceptionOutsideParent(parentKeywords: string, exceptionKeywords: string): string[] { const parent = csvTokens(parentKeywords).map((token) => token.toUpperCase()); - if (parent.length === 0) return false; - const keywords = csvTokens(exceptionKeywords).map((token) => token.toUpperCase()); - if (keywords.length === 0) return false; - return keywords.some((keyword) => !parent.some((item) => keyword === item || keyword.includes(item) || item.includes(keyword))); + if (parent.length === 0) return []; + return csvTokens(exceptionKeywords).filter((token) => { + const keyword = token.toUpperCase(); + return !parent.some((item) => keyword === item || keyword.includes(item) || item.includes(keyword)); + }); } function csvTokens(raw: string): string[] { @@ -214,32 +219,10 @@ const chunk = (items: T[], size: number): T[][] => { const withAllOption = (options: string[]) => (options.length > 1 ? [LOCATION_ALL, ...options] : options); -type WarehouseChipGroup = { key: string; title: string; tails: string[] }; - -function groupWarehouseCodes(csv: string): WarehouseChipGroup[] { - const codes = normalizeCodesCsv(csv).split(",").filter(Boolean); - const order: string[] = []; - const map = new Map(); - for (const code of codes) { - const parts = code.split("-"); - const floor = parts[0] ?? ""; - const zone = parts[1] ?? ""; - const tail = parts.length > 2 ? parts.slice(2).join("-") : code; - const key = floor && zone ? `${floor} · ${zone}` : code; - if (!map.has(key)) { - map.set(key, []); - order.push(key); - } - map.get(key)?.push(tail); - } - return order.map((key) => ({ key, title: key, tails: map.get(key) ?? [] })); -} - const DeliveryOrderFloorSettings: React.FC = () => { const { t } = useTranslation("deliveryOrderFloor"); const saveInFlightRef = useRef(false); const addInFlightRef = useRef(false); - const printModeInFlightRef = useRef(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -249,22 +232,17 @@ const DeliveryOrderFloorSettings: React.FC = () => { const [codes4F, setCodes4F] = useState(""); const [doExclude, setDoExclude] = useState(""); const [joExclude, setJoExclude] = useState(""); - const [doPrintMode, setDoPrintMode] = useState("hideList"); - const [joPrintMode, setJoPrintMode] = useState("hideList"); + const [doExcludeRules, setDoExcludeRules] = useState([]); + const [joExcludeRules, setJoExcludeRules] = useState([]); const [poPutAway, setPoPutAway] = useState([]); const [joPutAway, setJoPutAway] = useState([]); - const [editOpen, setEditOpen] = useState(false); - const [editFloor, setEditFloor] = useState("2F"); const [dialogSaving, setDialogSaving] = useState(false); const [comboLoading, setComboLoading] = useState(false); const [supplierCombo, setSupplierCombo] = useState(null); - const [draftRows2F, setDraftRows2F] = useState([]); - const [draftRows4F, setDraftRows4F] = useState([]); - - const [addOpen, setAddOpen] = useState(false); - const [addCodeInput, setAddCodeInput] = useState(""); - const [addError, setAddError] = useState(null); + const [showSupplierName, setShowSupplierName] = useState(false); + const [supplierInput, setSupplierInput] = useState>({ "2F": "", "4F": "" }); + const [supplierRowError, setSupplierRowError] = useState>>({}); const [excludeOpen, setExcludeOpen] = useState(false); const [excludeKind, setExcludeKind] = useState("do"); @@ -275,11 +253,11 @@ const DeliveryOrderFloorSettings: React.FC = () => { const [pageTab, setPageTab] = useState("do"); const [putAwayEdit, setPutAwayEdit] = useState(null); const [editCsv, setEditCsv] = useState(""); - const [editToken, setEditToken] = useState(""); + const [putAwayToken, setPutAwayToken] = useState>({}); const [editLocationMode, setEditLocationMode] = useState("warehouse"); const [editWarehouseCode, setEditWarehouseCode] = useState(""); const [warehousePickTarget, setWarehousePickTarget] = useState("exclude"); - const [expandedGroupKeys, setExpandedGroupKeys] = useState>({}); + const excludeSavePendingRef = useRef<{ kind: ExcludeKind; next: ExcludeWarehouseRule[] } | null>(null); const [excludeQuery, setExcludeQuery] = useState(""); const [excludePick, setExcludePick] = useState(emptyExcludePick); const [excludeAddError, setExcludeAddError] = useState(null); @@ -294,8 +272,8 @@ const DeliveryOrderFloorSettings: React.FC = () => { setCodes4F(floor.suppliers4F); setDoExclude(floor.doExcludeWarehouses); setJoExclude(floor.joExcludeWarehouses); - setDoPrintMode(floor.doExcludePrintMode); - setJoPrintMode(floor.joExcludePrintMode); + setDoExcludeRules(floor.doExcludeRules); + setJoExcludeRules(floor.joExcludeRules); setPoPutAway(floor.poAutoPutAway); setJoPutAway(floor.joAutoPutAway); } catch (e: unknown) { @@ -309,73 +287,57 @@ const DeliveryOrderFloorSettings: React.FC = () => { void load(); }, [load]); - const codes2FList = useMemo(() => { - const n = normalizeCodesCsv(codes2F); - return n ? n.split(",") : []; - }, [codes2F]); - const codes4FList = useMemo(() => { - const n = normalizeCodesCsv(codes4F); - return n ? n.split(",") : []; - }, [codes4F]); - const doExcludeGroups = useMemo(() => groupWarehouseCodes(doExclude), [doExclude]); - const joExcludeGroups = useMemo(() => groupWarehouseCodes(joExclude), [joExclude]); - const doExcludeCount = doExcludeGroups.reduce((n, g) => n + g.tails.length, 0); - const joExcludeCount = joExcludeGroups.reduce((n, g) => n + g.tails.length, 0); - - 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); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const combo = await fetchWarehouseCodeRowsClient(); + if (!cancelled) setWarehouseCombo(combo); + } catch { + if (!cancelled) setWarehouseCombo([]); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; 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); - } - }; + void (async () => { + try { + const combo = await fetchSupplierComboClient(); + if (!cancelled) setSupplierCombo(combo); + } catch { + if (!cancelled) setSupplierCombo([]); + } finally { + if (!cancelled) setComboLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); - const closeEdit = () => { - if (dialogSaving) return; - setEditOpen(false); - setAddOpen(false); - setAddCodeInput(""); - setAddError(null); - }; + const supplierCodes = (raw: string) => csvTokens(raw).filter((code) => code !== EMPTY_CODE_LIST_MARKER); + const codes2FList = useMemo(() => supplierCodes(codes2F), [codes2F]); + const codes4FList = useMemo(() => supplierCodes(codes4F), [codes4F]); + const supplierName = (code: string) => findSupplierRow(supplierCombo ?? [], code)?.name?.trim() || ""; - const saveCurrentFloor = async () => { + const persistFloorSuppliers = async (floor: EditFloor, codes: string[]) => { 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); + 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); setSuccess(t("Saved")); - setEditOpen(false); - setAddOpen(false); - setAddCodeInput(""); - setAddError(null); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -384,67 +346,36 @@ const DeliveryOrderFloorSettings: React.FC = () => { } }; - const onFloorSelectChange = (e: SelectChangeEvent) => { - 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 = () => { + const addSupplierCode = (floor: EditFloor) => { + if (saveInFlightRef.current) return; + const raw = (supplierInput[floor] ?? "").trim(); + if (!raw) { + setSupplierRowError((prev) => ({ ...prev, [floor]: t("Enter supplier code") })); + return; + } if (!supplierCombo?.length) { - setError(t("Supplier list unavailable")); + setSupplierRowError((prev) => ({ ...prev, [floor]: 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; + const hit = findSupplierRow(supplierCombo, raw); + if (!hit?.code) { + setSupplierRowError((prev) => ({ ...prev, [floor]: t("Supplier code not found") })); + return; + } + const canonical = hit.code.trim(); + const current = floor === "2F" ? codes2FList : codes4FList; + const other = floor === "2F" ? codes4FList : codes2FList; + if (current.some((code) => code.toLowerCase() === canonical.toLowerCase())) { + setSupplierRowError((prev) => ({ ...prev, [floor]: t("Duplicate in floor") })); + return; } + if (other.some((code) => code.toLowerCase() === canonical.toLowerCase())) { + setSupplierRowError((prev) => ({ ...prev, [floor]: t("Duplicate in other floor") })); + return; + } + setSupplierRowError((prev) => ({ ...prev, [floor]: undefined })); + setSupplierInput((prev) => ({ ...prev, [floor]: "" })); + void persistFloorSuppliers(floor, [...current, canonical]); }; const openExclude = async (kind: ExcludeKind) => { @@ -492,9 +423,42 @@ const DeliveryOrderFloorSettings: React.FC = () => { } const key = excludeKind === "do" ? SETTING_DO_PICK_EXCLUDE_WAREHOUSES : SETTING_JO_PICK_EXCLUDE_WAREHOUSES; + const rulesKey = excludeKind === "do" ? SETTING_DO_PICK_EXCLUDE_RULES : SETTING_JO_PICK_EXCLUDE_RULES; + const hideQrKey = excludeKind === "do" ? SETTING_DO_PICK_EXCLUDE_HIDE_QR : SETTING_JO_PICK_EXCLUDE_HIDE_QR; + const printModeKey = + excludeKind === "do" ? SETTING_DO_PICK_EXCLUDE_PRINT_MODE : SETTING_JO_PICK_EXCLUDE_PRINT_MODE; + const previous = excludeKind === "do" ? doExcludeRules : joExcludeRules; + const rules = codesToExcludeRules(normalized).map((rule) => { + const hit = previous.find( + (item) => + item.floor.toUpperCase() === rule.floor.toUpperCase() && + item.warehouse.toUpperCase() === rule.warehouse.toUpperCase() && + item.areas.toUpperCase() === rule.areas.toUpperCase(), + ); + return { ...rule, printMode: hit?.printMode ?? "hideList" }; + }); + const rows = warehouseCombo ?? (await fetchWarehouseCodeRowsClient()); + if (!warehouseCombo) setWarehouseCombo(rows); + const partitioned = partitionExcludeCodes(rules, rows); + const hideQrCsv = partitioned.hideQr.length > 0 ? partitioned.hideQr.join(",") : EMPTY_CODE_LIST_MARKER; + const rulesJson = JSON.stringify(compactExcludeRules(rules)); + if (rulesJson.length > 1000 || hideQrCsv.length > 1000) { + setError(t("List too long")); + return; + } + const legacyMode = + partitioned.all.length > 0 && partitioned.hideQr.length === partitioned.all.length ? "hideQr" : "hideList"; + await postSettingClient(rulesKey, rulesJson.length > 0 ? rulesJson : "[]"); await postSettingClient(key, normalized || EMPTY_CODE_LIST_MARKER); - if (excludeKind === "do") setDoExclude(normalized); - else setJoExclude(normalized); + await postSettingClient(hideQrKey, hideQrCsv); + await postSettingClient(printModeKey, legacyMode); + if (excludeKind === "do") { + setDoExclude(normalized); + setDoExcludeRules(rules); + } else { + setJoExclude(normalized); + setJoExcludeRules(rules); + } setSuccess(t("Saved")); setExcludeOpen(false); setExcludeAddOpen(false); @@ -556,6 +520,53 @@ 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 next = disableOverlappingExcludeRules(incoming); + if (kind === "do") setDoExcludeRules(next); + else setJoExcludeRules(next); + if (saveInFlightRef.current) { + excludeSavePendingRef.current = { kind, next }; + return; + } + saveInFlightRef.current = true; + setDialogSaving(true); + 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 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")); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDialogSaving(false); + saveInFlightRef.current = false; + const pending = excludeSavePendingRef.current; + excludeSavePendingRef.current = null; + if (pending) void persistExcludeRules(pending.kind, pending.next); + } + }; + const confirmAddWarehouse = () => { if (addInFlightRef.current) return; addInFlightRef.current = true; @@ -665,7 +676,6 @@ const DeliveryOrderFloorSettings: React.FC = () => { if (!silent) { setSuccess(t("Saved")); setPutAwayEdit(null); - setEditToken(""); } } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -694,6 +704,23 @@ const DeliveryOrderFloorSettings: React.FC = () => { })(); }, [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, @@ -705,8 +732,7 @@ const DeliveryOrderFloorSettings: React.FC = () => { if (field === "bom") setEditCsv(rule.bomTypes); else if (field === "exceptionKeywords") { setEditCsv((rule.exceptions ?? []).map((row) => row.itemKeywords).filter(Boolean).join(",")); - } else setEditCsv(rule.itemKeywords); - setEditToken(""); + } else setEditCsv(rule.itemKeywords); setEditLocationMode(rule.locationMode); setEditWarehouseCode(rule.warehouseCode); setError(null); @@ -736,35 +762,41 @@ const DeliveryOrderFloorSettings: React.FC = () => { await persistPutAwayRules(kind, next); }; - const addEditToken = () => { - const raw = editToken.trim().toUpperCase(); - if (!raw) return; - const parts = csvTokens(editCsv); - if (parts.some((part) => part.toUpperCase() === raw)) return; - const next = [...parts, raw].join(","); - setEditCsv(next); - setEditToken(""); - void saveRowEdit(next); + const putAwayTokenKey = (kind: AutoPutAwayScope, index: number, field: "keywords" | "exceptionKeywords") => + `${kind}-${index}-${field}`; + + const changePutAwayKeywords = ( + kind: AutoPutAwayScope, + index: number, + field: "keywords" | "exceptionKeywords", + csv: string, + ) => { + if (saveInFlightRef.current) return; + const next = rulesOf(kind).map((rule) => ({ + ...rule, + exceptions: (rule.exceptions ?? []).map((row) => ({ ...row })), + })); + const rule = next[index]; + if (!rule) return; + if (field === "keywords") rule.itemKeywords = csv; + else rule.exceptions = csvTokens(csv).length === 0 ? [] : [{ itemKeywords: csv }]; + void persistPutAwayRules(kind, next); }; - const savePrintMode = async (kind: ExcludeKind, mode: ExcludePrintMode) => { - if (printModeInFlightRef.current) return; - printModeInFlightRef.current = true; - const previous = kind === "do" ? doPrintMode : joPrintMode; - if (kind === "do") setDoPrintMode(mode); - else setJoPrintMode(mode); - setError(null); - try { - const key = kind === "do" ? SETTING_DO_PICK_EXCLUDE_PRINT_MODE : SETTING_JO_PICK_EXCLUDE_PRINT_MODE; - await postSettingClient(key, mode); - setSuccess(t("Saved")); - } catch (e: unknown) { - if (kind === "do") setDoPrintMode(previous); - else setJoPrintMode(previous); - setError(e instanceof Error ? e.message : String(e)); - } finally { - printModeInFlightRef.current = false; - } + const addPutAwayToken = ( + kind: AutoPutAwayScope, + index: number, + field: "keywords" | "exceptionKeywords", + currentCsv: string, + ) => { + if (saveInFlightRef.current) return; + const key = putAwayTokenKey(kind, index, field); + const raw = (putAwayToken[key] ?? "").trim().toUpperCase(); + if (!raw) return; + const parts = csvTokens(currentCsv); + setPutAwayToken((prev) => ({ ...prev, [key]: "" })); + if (parts.some((part) => part.toUpperCase() === raw)) return; + changePutAwayKeywords(kind, index, field, [...parts, raw].join(",")); }; const cardSx = { @@ -775,27 +807,7 @@ const DeliveryOrderFloorSettings: React.FC = () => { p: 2.5, }; - const renderSlotChip = (key: string, tail: string) => ( - - - {tail} - - } - sx={{ - borderRadius: 2, - fontFamily: "monospace", - height: 40, - fontSize: 16, - "& .MuiChip-label": { px: 1.5 }, - }} - /> - ); - - const renderExcludeCard = (groups: WarehouseChipGroup[], kind: ExcludeKind) => { + const renderExcludeCard = (kind: ExcludeKind) => { return ( @@ -826,100 +838,18 @@ const DeliveryOrderFloorSettings: React.FC = () => { - - - - {t("Print list label")} - - { - if (next) void savePrintMode(kind, next); - }} - sx={{ flexWrap: "wrap" }} - > - - - - {t("Print hide list")} - - - {t("Print hide list hint")} - - - - - - - {t("Print hide qr")} - - - {t("Print hide qr hint")} - - - - - - {groups.length === 0 ? ( - - {t("Empty exclude chips")} - - ) : ( - - {groups.map((group) => { - const expanded = Boolean(expandedGroupKeys[group.key]); - const preview = !expanded && group.tails.length > 8 ? group.tails.slice(0, 8) : group.tails; - const hidden = group.tails.length - preview.length; - return ( - - - {group.title} - - - - {preview.map((tail) => renderSlotChip(`${group.key}-${tail}`, tail))} - - {group.tails.length > 8 ? ( - - - - ) : null} - - ); - })} - - )} + {/* + + {t("Exclude rules hint")} + + */} + void persistExcludeRules(kind, next)} + /> ); }; @@ -938,14 +868,11 @@ const DeliveryOrderFloorSettings: React.FC = () => { exceptionIndex?: number, ) => { if (isEditingRow(kind, index, field, exceptionIndex)) { - const keywordRow = field === "keywords" || field === "exceptionKeywords"; return ( - {keywordRow ? null : ( - void saveRowEdit()}> - {dialogSaving ? : } - - )} + void saveRowEdit()}> + {dialogSaving ? : } + @@ -964,46 +891,68 @@ const DeliveryOrderFloorSettings: React.FC = () => { ); }; - const renderTokenEditor = (emptyText: string) => { - const tokens = csvTokens(editCsv); + const renderKeywordRow = ( + kind: AutoPutAwayScope, + index: number, + field: "keywords" | "exceptionKeywords", + label: string, + csv: string, + emptyText: string, + ) => { + const tokens = csvTokens(csv); + const key = putAwayTokenKey(kind, index, field); return ( - - - {tokens.length === 0 ? ( - - {emptyText} - - ) : ( - tokens.map((token) => ( - { - const next = csvTokens(editCsv).filter((part) => part !== token).join(","); - setEditCsv(next); - void saveRowEdit(next); - }} - /> - )) - )} - - - setEditToken(e.target.value)} - placeholder={t("Putaway token placeholder")} - onKeyDown={(e) => { - if (e.key !== "Enter") return; - e.preventDefault(); - addEditToken(); - }} - sx={{ "& .MuiInputBase-input::placeholder": { color: "#9e9e9e", opacity: 1 } }} - /> - + + + {label} + + + + {tokens.length === 0 ? ( + + ) : ( + tokens.map((token) => ( + changePutAwayKeywords(kind, index, field, tokens.filter((part) => part !== token).join(",")) + } + /> + )) + )} + + + setPutAwayToken((prev) => ({ ...prev, [key]: event.target.value }))} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addPutAwayToken(kind, index, field, csv); + }} + sx={{ "& .MuiInputBase-input::placeholder": { color: "#9e9e9e", opacity: 1 } }} + /> + + ); @@ -1084,23 +1033,7 @@ const DeliveryOrderFloorSettings: React.FC = () => { ) : null} - - - {t("Putaway keywords")} - - {isEditingRow(kind, index, "keywords") ? ( - renderTokenEditor(t("All items")) - ) : ( - - {csvTokens(rule.itemKeywords).length === 0 ? ( - - ) : ( - csvTokens(rule.itemKeywords).map((token) => ) - )} - - )} - {renderRowActions(kind, index, "keywords", rule)} - + {renderKeywordRow(kind, index, "keywords", t("Putaway keywords"), rule.itemKeywords, t("All items"))} {kind === "jo" ? ( @@ -1196,28 +1129,20 @@ const DeliveryOrderFloorSettings: React.FC = () => { {(() => { const exceptionKeywords = (rule.exceptions ?? []).map((row) => row.itemKeywords).filter(Boolean).join(","); + const outsideCodes = exceptionOutsideParent(rule.itemKeywords, exceptionKeywords); return ( <> - {exceptionOutsideParent(rule.itemKeywords, exceptionKeywords) ? ( - {t("Exception outside rule")} + {outsideCodes.length > 0 ? ( + {t("Exception outside rule", { codes: outsideCodes.join(", ") })} ) : null} - - - {t("Exception keywords")} - - {isEditingRow(kind, index, "exceptionKeywords") ? ( - renderTokenEditor(t("Empty putaway condition")) - ) : ( - - {csvTokens(exceptionKeywords).length === 0 ? ( - - ) : ( - csvTokens(exceptionKeywords).map((token) => ) - )} - - )} - {renderRowActions(kind, index, "exceptionKeywords", rule)} - + {renderKeywordRow( + kind, + index, + "exceptionKeywords", + t("Exception keywords"), + exceptionKeywords, + t("Empty putaway condition"), + )} ); })()} @@ -1241,31 +1166,70 @@ const DeliveryOrderFloorSettings: React.FC = () => { }; const renderSupplierRow = (floor: EditFloor, codes: string[]) => ( - - - {t("Floor label")} - - {floor} - - {t("Supplier list")} - - - {codes.length === 0 ? ( - - {t("Empty floor list")} - - ) : ( - codes.map((code) => ) - )} - - void openEdit(floor)} size="small"> - - + + + + {t("Floor label")} + + {floor} + + {t("Supplier list")} + + + {codes.length === 0 ? ( + + {t("Empty floor list")} + + ) : ( + codes.map((code) => { + const name = supplierName(code); + const label = showSupplierName && name ? `${code} ${name}` : code; + return ( + + + void persistFloorSuppliers(floor, codes.filter((item) => item !== code))} + /> + + + ); + }) + )} + + + + { + setSupplierInput((prev) => ({ ...prev, [floor]: event.target.value })); + setSupplierRowError((prev) => ({ ...prev, [floor]: undefined })); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addSupplierCode(floor); + }} + sx={{ "& .MuiInputBase-input::placeholder": { color: "#9e9e9e", opacity: 1 } }} + /> + + + {supplierRowError[floor] ? ( + + {supplierRowError[floor]} + + ) : null} ); @@ -1300,28 +1264,8 @@ const DeliveryOrderFloorSettings: React.FC = () => { onChange={(_, value: PageTab) => setPageTab(value)} sx={{ borderBottom: 1, borderColor: "divider", "& .MuiTab-root": { textTransform: "none", minHeight: 48 } }} > - } - iconPosition="start" - label={ - - {t("Tab delivery order")} - - - } - /> - } - iconPosition="start" - label={ - - {t("Tab job order")} - - - } - /> + } iconPosition="start" label={t("Tab delivery order")} /> + } iconPosition="start" label={t("Tab job order")} /> } @@ -1358,163 +1302,34 @@ const DeliveryOrderFloorSettings: React.FC = () => { - + setShowSupplierName(event.target.checked)} + /> + } + label={t("Show supplier name")} + /> {renderSupplierRow("2F", codes2FList)} {renderSupplierRow("4F", codes4FList)} - {renderExcludeCard(doExcludeGroups, "do")} + {renderExcludeCard("do")} ) : pageTab === "jo" ? ( - {renderExcludeCard(joExcludeGroups, "jo")} + {renderExcludeCard("jo")} {renderPutAwayCard("jo")} ) : ( renderPutAwayCard("po") )} - - {t("Edit dialog title")} - - - - - {t("Floor label")} - - - - - - - - {comboLoading ? ( - - - - ) : ( - - - - - {t("Col code")} - {t("Col name")} - {t("Col type")} - - {t("Col actions")} - - - - - {currentDraftRows.length === 0 ? ( - - - - {t("Empty floor list")} - - - - ) : ( - currentDraftRows.map((row) => ( - - {row.code} - - {row.name || ( - - {t("Unknown supplier name")} - - )} - - {editFloor} - - removeRow(row.code)} - disabled={dialogSaving} - > - - - - - )) - )} - -
-
- )} -
-
- - - -
- - - {t("Add mapping title")} - - - { - setAddCodeInput(e.target.value); - setAddError(null); - }} - placeholder={t("Add code placeholder")} - /> - {addError ? {addError} : null} - - - - - - - - {excludeKind === "do" ? t("Edit DO exclude title") : t("Edit JO exclude title")} @@ -1790,7 +1605,7 @@ const DeliveryOrderFloorSettings: React.FC = () => { diff --git a/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx b/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx new file mode 100644 index 00000000..1e03e428 --- /dev/null +++ b/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx @@ -0,0 +1,512 @@ +"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(); + 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 ( + + + {label} + + {options.length === 0 ? ( + + — + + ) : ( + + {options.map((option) => ( + toggle(option)} + sx={{ + width: BUTTON_WIDTH, + minWidth: BUTTON_WIDTH, + maxWidth: BUTTON_WIDTH, + px: 0, + textTransform: "none", + }} + > + {renderLabel ? renderLabel(option) : option} + + ))} + + )} + + ); +} + +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>({}); + + 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 ( + + {rules.length === 0 ? ( + + {t("Empty exclude rules")} + + ) : ( + 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(); + 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 ( + + + + {t("Exclude rule n", { n: groupIndex + 1 })} + 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")} + /> + + onSave(rules.filter((_, ruleIndex) => !group.indexes.includes(ruleIndex)))} + > + + + + {overlappedBy.length > 0 ? ( + + {t("Putaway rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })} + + ) : null} + + + applyGroup([ + { + ...rule, + floor: typeof next === "string" ? next : "", + warehouse: "", + areas: "", + slots: "", + }, + ]) + } + /> + + applyGroup([ + { + ...rule, + warehouse: typeof next === "string" ? next : "", + areas: "", + slots: "", + }, + ]) + } + /> + + + {t("Area")} + + {areaNames.length === 0 ? ( + + — + + ) : ( + + + 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 }} + /> + {t("All")} + + {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 ( + + { + setViewByGroup((prev) => ({ ...prev, [group.key]: area })); + setAreaIncluded(area, checked); + }} + sx={{ p: 0.25 }} + /> + setViewByGroup((prev) => ({ ...prev, [group.key]: area }))} + sx={{ flex: 1, textAlign: "center", cursor: "pointer", py: 0.5, pr: 0.5 }} + > + {area} + + + ); + })} + + )} + + { + if (!viewArea) return; + const picked = Array.isArray(next) ? next : []; + applyGroup(withAreaSlots(groupRules, viewArea, slotsToStore(picked, viewSlots), areaNames, rule)); + }} + /> + {!viewArea ? ( + + {t("Click area to view slots")} + + ) : ( + + {t("Viewing area", { area: viewArea })} + {!viewedRule ? ` ${t("Area not in rule")}` : ""} + + )} + + + {t("Print list label")} + + { + if (!next) return; + applyGroup(groupRules.map((item) => ({ ...item, printMode: next }))); + }} + sx={{ flexWrap: "wrap" }} + > + + + + {t("Print hide list")} + + + {t("Print hide list hint")} + + + + + + + {t("Print hide qr")} + + + {t("Print hide qr hint")} + + + + + + + + ); + }) + )} + + + ); +} diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index 23c03ce5..875aa7d0 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -37,7 +37,6 @@ import { printWorkbenchLotLabel, } from "@/app/api/doworkbench/actions"; import { fetchDoFloorSettingsClient } from "@/app/api/settings/deliveryOrderFloor/client"; -import type { ExcludePrintMode } from "@/app/api/settings/deliveryOrderFloor/constants"; import { QRCodeSVG } from "qrcode.react"; type ScanPayload = { @@ -193,8 +192,8 @@ const WorkbenchLotLabelPrintModal: React.FC = null, ); const [excludePrintRule, setExcludePrintRule] = useState<{ - codes: Set; - mode: ExcludePrintMode; + hideList: Set; + excluded: Set; } | null>(null); const [snackbar, setSnackbar] = useState<{ @@ -236,13 +235,27 @@ const WorkbenchLotLabelPrintModal: React.FC = pickRuleScope === "do" ? settings.doExcludeWarehouses : settings.joExcludeWarehouses; const mode = pickRuleScope === "do" ? settings.doExcludePrintMode : settings.joExcludePrintMode; - const codes = new Set( + const hideQrRaw = + pickRuleScope === "do" ? settings.doExcludeHideQrWarehouses : settings.joExcludeHideQrWarehouses; + const excluded = new Set( csv .split(",") .map((code) => code.trim().toUpperCase()) .filter(Boolean), ); - setExcludePrintRule({ codes, mode }); + const hideQr = + hideQrRaw == null || hideQrRaw === "" + ? mode === "hideQr" + ? excluded + : new Set() + : new Set( + hideQrRaw + .split(",") + .map((code) => code.trim().toUpperCase()) + .filter((code) => code && excluded.has(code)), + ); + const hideList = new Set([...excluded].filter((code) => !hideQr.has(code))); + setExcludePrintRule({ hideList, excluded }); }) .catch(() => { if (!cancelled) setExcludePrintRule(null); @@ -487,16 +500,15 @@ const WorkbenchLotLabelPrintModal: React.FC = const filteredLots = useMemo(() => { const prefix = String(warehouseCodePrefixFilter ?? "").trim(); - const excluded = excludePrintRule?.codes; - const hideList = excludePrintRule?.mode === "hideList"; + const hideList = excludePrintRule?.hideList; return availableLots.filter((lot) => { if (prefix && !lot._scanned) { const code = String(lot.warehouseCode ?? ""); if (!code.startsWith(prefix)) return false; } - if (hideList && excluded && !lot._scanned) { + if (hideList && !lot._scanned) { const code = String(lot.warehouseCode ?? "").trim().toUpperCase(); - if (code && excluded.has(code)) return false; + if (code && hideList.has(code)) return false; } return true; }); @@ -728,7 +740,7 @@ const WorkbenchLotLabelPrintModal: React.FC = printingLotLineId === lot.inventoryLotLineId; const loc = String(lot.warehouseCode ?? "").trim(); const suppressQr = Boolean( - excludePrintRule?.codes.has(loc.toUpperCase()), + excludePrintRule?.excluded.has(loc.toUpperCase()), ); const canShowLotQr = !!onWorkbenchScanPick && diff --git a/src/i18n/en/deliveryOrderFloor.json b/src/i18n/en/deliveryOrderFloor.json index 7d211ce3..26c76c98 100644 --- a/src/i18n/en/deliveryOrderFloor.json +++ b/src/i18n/en/deliveryOrderFloor.json @@ -23,7 +23,7 @@ "Exception keywords": "Exception item no.", "Add exception": "Add exception", "Exception needs keywords": "Enter exception item keywords", - "Exception outside rule": "These keywords are outside this rule, so the exception will not run.", + "Exception outside rule": "{{codes}} are outside this rule's item numbers, so they will not run.", "Exception overlap": "Overlaps exception {{n}}. On the same rule, the earlier exception is used.", "Add putaway rule": "Add rule", "Edit putaway": "Edit put-away", @@ -114,7 +114,8 @@ "Col name": "Supplier name", "Col type": "Floor", "Col actions": "Actions", - "Empty floor list": "No suppliers for this floor yet. Use “Add mapping”.", + "Empty floor list": "No suppliers on this floor.", + "Show supplier name": "Show names", "Unknown supplier name": "(Not in master data or empty name)", "Delete row": "Delete row", "Supplier list unavailable": "Could not load supplier list. Try again later.", @@ -127,5 +128,22 @@ "Save": "Save", "Saved": "Saved", "Cancel": "Cancel", - "DO floor (supplier)": "DO floor (supplier)" + "DO floor (supplier)": "DO floor (supplier)", + "Exclude rules hint": "Click an area to view its slots. Check an area to include it. No slot checked means every slot in that area.", + "Click area to view slots": "Click an area to view its slots.", + "Viewing area": "Viewing {{area}}", + "Area not in rule": "This area is not in the rule.", + "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}}", + "Add exclude rule": "Add rule", + "Empty exclude rules": "No exclude rules yet.", + "All areas": "All areas", + "All slots": "All slots", + "Exception area": "Exception area", + "Exception slot": "Exception slot", + "Exclude token placeholder": "Enter a code", + "Choose floor warehouse": "Choose floor and warehouse", + "Exception area outside": "{{codes}} are not in this rule's areas and will not apply.", + "Exception slot outside": "{{codes}} are not in this rule's slots and will not apply." } diff --git a/src/i18n/zh/deliveryOrderFloor.json b/src/i18n/zh/deliveryOrderFloor.json index 131b9748..4ac2ae3f 100644 --- a/src/i18n/zh/deliveryOrderFloor.json +++ b/src/i18n/zh/deliveryOrderFloor.json @@ -23,7 +23,7 @@ "Exception keywords": "例外貨品編號", "Add exception": "新增例外", "Exception needs keywords": "請填例外貨號", - "Exception outside rule": "這個例外的貨號不在這條規則裡,不會生效。", + "Exception outside rule": "{{codes}} 不在這條規則的貨品編號裡,不會生效。", "Exception overlap": "與例外 {{n}} 貨號重疊。同一條規則先用上面的例外。", "Add putaway rule": "新增規則", "Edit putaway": "編輯自動上架", @@ -114,7 +114,8 @@ "Col name": "供應商名稱", "Col type": "樓層", "Col actions": "操作", - "Empty floor list": "此樓層尚無供應商,請按「新增映射」加入。", + "Empty floor list": "此樓層尚無供應商。", + "Show supplier name": "顯示名稱", "Unknown supplier name": "(主檔無此代碼或名稱為空)", "Delete row": "刪除此列", "Supplier list unavailable": "無法載入供應商清單,請稍後再試。", @@ -127,5 +128,22 @@ "Save": "儲存", "Saved": "已儲存", "Cancel": "取消", - "DO floor (supplier)": "送貨單樓層(供應商)" + "DO floor (supplier)": "送貨單樓層(供應商)", + "Exclude rules hint": "點區域只查看該區儲位。勾選區域才會納入規則。未勾儲位代表該區全部儲位。", + "Click area to view slots": "點一個區域,查看它的儲位。", + "Viewing area": "正在查看 {{area}}", + "Area not in rule": "此區域未納入規則。", + "Area slots partial": "{{areas}} 還有未選儲位:{{slots}}。這些不會排除。", + "Area slots complete": "{{areas}} 的儲位都已選。", + "Exclude rule n": "規則 {{n}}", + "Add exclude rule": "新增規則", + "Empty exclude rules": "目前沒有排除規則。", + "All areas": "全部區域", + "All slots": "全部儲位", + "Exception area": "例外區域", + "Exception slot": "例外儲位", + "Exclude token placeholder": "輸入代碼", + "Choose floor warehouse": "選擇樓層與倉庫", + "Exception area outside": "{{codes}} 不在這條規則的區域裡,不會生效。", + "Exception slot outside": "{{codes}} 不在這條規則的儲位裡,不會生效。" }