| @@ -11,24 +11,31 @@ import { | |||||
| EMPTY_CODE_LIST_MARKER, | EMPTY_CODE_LIST_MARKER, | ||||
| SETTING_DO_FLOOR_SUPPLIERS_2F, | SETTING_DO_FLOOR_SUPPLIERS_2F, | ||||
| SETTING_DO_FLOOR_SUPPLIERS_4F, | SETTING_DO_FLOOR_SUPPLIERS_4F, | ||||
| SETTING_DO_PICK_EXCLUDE_HIDE_QR, | |||||
| SETTING_DO_PICK_EXCLUDE_PRINT_MODE, | SETTING_DO_PICK_EXCLUDE_PRINT_MODE, | ||||
| SETTING_DO_PICK_EXCLUDE_RULES, | |||||
| SETTING_DO_PICK_EXCLUDE_WAREHOUSES, | SETTING_DO_PICK_EXCLUDE_WAREHOUSES, | ||||
| SETTING_JO_AUTO_PUTAWAY_BOM_TYPES, | SETTING_JO_AUTO_PUTAWAY_BOM_TYPES, | ||||
| SETTING_JO_AUTO_PUTAWAY_ITEM_KEYWORDS, | SETTING_JO_AUTO_PUTAWAY_ITEM_KEYWORDS, | ||||
| SETTING_JO_AUTO_PUTAWAY_RULES, | SETTING_JO_AUTO_PUTAWAY_RULES, | ||||
| SETTING_JO_AUTO_PUTAWAY_WAREHOUSE, | SETTING_JO_AUTO_PUTAWAY_WAREHOUSE, | ||||
| SETTING_JO_PICK_EXCLUDE_HIDE_QR, | |||||
| SETTING_JO_PICK_EXCLUDE_PRINT_MODE, | SETTING_JO_PICK_EXCLUDE_PRINT_MODE, | ||||
| SETTING_JO_PICK_EXCLUDE_RULES, | |||||
| SETTING_JO_PICK_EXCLUDE_WAREHOUSES, | SETTING_JO_PICK_EXCLUDE_WAREHOUSES, | ||||
| SETTING_PO_AUTO_PUTAWAY_BOM_TYPES, | SETTING_PO_AUTO_PUTAWAY_BOM_TYPES, | ||||
| SETTING_PO_AUTO_PUTAWAY_ITEM_KEYWORDS, | SETTING_PO_AUTO_PUTAWAY_ITEM_KEYWORDS, | ||||
| SETTING_PO_AUTO_PUTAWAY_RULES, | SETTING_PO_AUTO_PUTAWAY_RULES, | ||||
| SETTING_PO_AUTO_PUTAWAY_WAREHOUSE, | SETTING_PO_AUTO_PUTAWAY_WAREHOUSE, | ||||
| codesToExcludeRules, | |||||
| compactExcludeRules, | |||||
| defaultJoAutoPutAwayRules, | defaultJoAutoPutAwayRules, | ||||
| defaultPoAutoPutAwayRules, | defaultPoAutoPutAwayRules, | ||||
| type AutoPutAwayException, | type AutoPutAwayException, | ||||
| type AutoPutAwayRule, | type AutoPutAwayRule, | ||||
| type AutoPutAwayScope, | type AutoPutAwayScope, | ||||
| type ExcludePrintMode, | type ExcludePrintMode, | ||||
| type ExcludeWarehouseRule, | |||||
| type PutAwayLocationMode, | type PutAwayLocationMode, | ||||
| } from "./constants"; | } from "./constants"; | ||||
| @@ -258,26 +265,84 @@ function resolveAutoPutAwayRules( | |||||
| return scope === "po" ? defaultPoAutoPutAwayRules() : defaultJoAutoPutAwayRules(); | 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<string, unknown>; | |||||
| 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<{ | export async function fetchDoFloorSettingsClient(): Promise<{ | ||||
| suppliers2F: string; | suppliers2F: string; | ||||
| suppliers4F: string; | suppliers4F: string; | ||||
| doExcludeWarehouses: string; | doExcludeWarehouses: string; | ||||
| joExcludeWarehouses: string; | joExcludeWarehouses: string; | ||||
| doExcludeRules: ExcludeWarehouseRule[]; | |||||
| joExcludeRules: ExcludeWarehouseRule[]; | |||||
| doExcludePrintMode: ExcludePrintMode; | doExcludePrintMode: ExcludePrintMode; | ||||
| joExcludePrintMode: ExcludePrintMode; | joExcludePrintMode: ExcludePrintMode; | ||||
| doExcludeHideQrWarehouses: string | null; | |||||
| joExcludeHideQrWarehouses: string | null; | |||||
| poAutoPutAway: AutoPutAwayRule[]; | poAutoPutAway: AutoPutAwayRule[]; | ||||
| joAutoPutAway: AutoPutAwayRule[]; | joAutoPutAway: AutoPutAwayRule[]; | ||||
| }> { | }> { | ||||
| const all = await fetchAllSettingsClient(); | const all = await fetchAllSettingsClient(); | ||||
| const get = (name: string) => all.find((s) => s.name === name)?.value ?? ""; | const get = (name: string) => all.find((s) => s.name === name)?.value ?? ""; | ||||
| const raw = (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 { | return { | ||||
| suppliers2F: get(SETTING_DO_FLOOR_SUPPLIERS_2F), | suppliers2F: get(SETTING_DO_FLOOR_SUPPLIERS_2F), | ||||
| suppliers4F: get(SETTING_DO_FLOOR_SUPPLIERS_4F), | 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"), | poAutoPutAway: resolveAutoPutAwayRules(raw, "po"), | ||||
| joAutoPutAway: resolveAutoPutAwayRules(raw, "jo"), | joAutoPutAway: resolveAutoPutAwayRules(raw, "jo"), | ||||
| }; | }; | ||||
| @@ -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_JO_PICK_EXCLUDE_WAREHOUSES = "JO.pick.excludeWarehouses"; | ||||
| export const SETTING_DO_PICK_EXCLUDE_PRINT_MODE = "DO.pick.excludePrintMode"; | 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_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 可以通過。 */ | /** hideList:清單不出現,掃到實物 QR 也不通過。hideQr:清單仍顯示但不顯示 QR,掃到實物 QR 可以通過。 */ | ||||
| export type ExcludePrintMode = "hideList" | "hideQr"; | export type ExcludePrintMode = "hideList" | "hideQr"; | ||||
| export const EMPTY_CODE_LIST_MARKER = "-"; | export const EMPTY_CODE_LIST_MARKER = "-"; | ||||
| @@ -137,4 +141,227 @@ export function resolveAutoPutAwayTarget( | |||||
| return { locationMode: rule.locationMode, warehouseCode: rule.warehouseCode }; | return { locationMode: rule.locationMode, warehouseCode: rule.warehouseCode }; | ||||
| } | } | ||||
| return null; | 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<string, { floor: string; warehouse: string; area: string; slots: string[] }>(); | |||||
| 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<string, ExcludeWarehouseRule>(); | |||||
| 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; | |||||
| } | } | ||||
| @@ -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<string>(); | |||||
| const out: string[] = []; | |||||
| for (const value of values) { | |||||
| const key = value.toUpperCase(); | |||||
| if (!value || seen.has(key)) continue; | |||||
| seen.add(key); | |||||
| out.push(value); | |||||
| } | |||||
| return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })); | |||||
| } | |||||
| function slotCode(code: string): string { | |||||
| const parts = code.split("-"); | |||||
| return parts[parts.length - 1] || ""; | |||||
| } | |||||
| function slotKey(raw: string): string { | |||||
| return tokens(raw) | |||||
| .map((slot) => slot.toUpperCase()) | |||||
| .sort() | |||||
| .join(","); | |||||
| } | |||||
| function shownSlots(stored: string, areaSlots: string[]): string[] { | |||||
| const picked = tokens(stored); | |||||
| if (picked.includes("-")) return []; | |||||
| if (picked.length === 0) return areaSlots; | |||||
| return areaSlots.filter((slot) => picked.some((item) => item.toUpperCase() === slot.toUpperCase())); | |||||
| } | |||||
| function slotsToStore(picked: string[], areaSlots: string[]): string { | |||||
| if (areaSlots.length > 0 && picked.length === areaSlots.length) return ""; | |||||
| if (picked.length === 0) return "-"; | |||||
| return picked.join(","); | |||||
| } | |||||
| function areasOnRule(rule: ExcludeWarehouseRule, areaNames: string[]): string[] { | |||||
| const picked = tokens(rule.areas); | |||||
| if (picked.includes(EXCLUDE_ALL_AREAS)) return areaNames; | |||||
| return picked; | |||||
| } | |||||
| function replaceGroup(rules: ExcludeWarehouseRule[], indexes: number[], nextGroup: ExcludeWarehouseRule[]): ExcludeWarehouseRule[] { | |||||
| const indexSet = new Set(indexes); | |||||
| const first = indexes[0] ?? 0; | |||||
| const out: ExcludeWarehouseRule[] = []; | |||||
| rules.forEach((rule, index) => { | |||||
| if (index === first) out.push(...nextGroup); | |||||
| else if (!indexSet.has(index)) out.push(rule); | |||||
| }); | |||||
| return out; | |||||
| } | |||||
| function withoutArea(group: ExcludeWarehouseRule[], area: string, areaNames: string[]): ExcludeWarehouseRule[] { | |||||
| return group | |||||
| .map((rule) => ({ | |||||
| ...rule, | |||||
| areas: areasOnRule(rule, areaNames) | |||||
| .filter((item) => item.toUpperCase() !== area.toUpperCase()) | |||||
| .join(","), | |||||
| })) | |||||
| .filter((rule) => tokens(rule.areas).length > 0); | |||||
| } | |||||
| function withAreaSlots( | |||||
| group: ExcludeWarehouseRule[], | |||||
| area: string, | |||||
| slotCsv: string, | |||||
| areaNames: string[], | |||||
| template: ExcludeWarehouseRule, | |||||
| ): ExcludeWarehouseRule[] { | |||||
| const stripped = withoutArea(group, area, areaNames); | |||||
| const key = slotKey(slotCsv); | |||||
| const hitIndex = stripped.findIndex((rule) => slotKey(rule.slots) === key); | |||||
| if (hitIndex >= 0) { | |||||
| return stripped.map((rule, index) => | |||||
| index === hitIndex ? { ...rule, areas: [...tokens(rule.areas), area].join(",") } : rule, | |||||
| ); | |||||
| } | |||||
| const next = [...stripped, { ...template, areas: area, slots: tokens(slotCsv).join(",") }]; | |||||
| return next.length > 0 ? next : [{ ...template, areas: "", slots: "" }]; | |||||
| } | |||||
| function isChosen(value: string | string[] | null, option: string): boolean { | |||||
| if (Array.isArray(value)) return value.some((item) => item.toUpperCase() === option.toUpperCase()); | |||||
| return typeof value === "string" && value.toUpperCase() === option.toUpperCase(); | |||||
| } | |||||
| function ToggleRows({ | |||||
| label, | |||||
| options, | |||||
| value, | |||||
| exclusive, | |||||
| disabled, | |||||
| onChange, | |||||
| renderLabel, | |||||
| }: { | |||||
| label: string; | |||||
| options: string[]; | |||||
| value: string | string[] | null; | |||||
| exclusive: boolean; | |||||
| disabled?: boolean; | |||||
| onChange: (next: string | string[] | null) => void; | |||||
| renderLabel?: (option: string) => string; | |||||
| }) { | |||||
| const toggle = (option: string) => { | |||||
| if (disabled) return; | |||||
| if (exclusive) { | |||||
| onChange(isChosen(value, option) ? null : option); | |||||
| return; | |||||
| } | |||||
| const previous = Array.isArray(value) ? value : []; | |||||
| const next = isChosen(previous, option) | |||||
| ? previous.filter((item) => item.toUpperCase() !== option.toUpperCase()) | |||||
| : [...previous, option]; | |||||
| onChange(next); | |||||
| }; | |||||
| return ( | |||||
| <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75, flexShrink: 0 }}> | |||||
| {label} | |||||
| </Typography> | |||||
| {options.length === 0 ? ( | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}> | |||||
| — | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, flex: 1, minWidth: 0, opacity: disabled ? 0.45 : 1 }}> | |||||
| {options.map((option) => ( | |||||
| <ToggleButton | |||||
| key={option} | |||||
| size="small" | |||||
| value={option} | |||||
| selected={isChosen(value, option)} | |||||
| disabled={disabled} | |||||
| onClick={() => toggle(option)} | |||||
| sx={{ | |||||
| width: BUTTON_WIDTH, | |||||
| minWidth: BUTTON_WIDTH, | |||||
| maxWidth: BUTTON_WIDTH, | |||||
| px: 0, | |||||
| textTransform: "none", | |||||
| }} | |||||
| > | |||||
| {renderLabel ? renderLabel(option) : option} | |||||
| </ToggleButton> | |||||
| ))} | |||||
| </Box> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| export function ExcludeWarehouseRuleList({ | |||||
| rules, | |||||
| warehouses, | |||||
| saving, | |||||
| onSave, | |||||
| }: { | |||||
| rules: ExcludeWarehouseRule[]; | |||||
| warehouses: WarehousePickRow[]; | |||||
| saving: boolean; | |||||
| onSave: (next: ExcludeWarehouseRule[]) => void; | |||||
| }) { | |||||
| const { t } = useTranslation("deliveryOrderFloor"); | |||||
| const [viewByGroup, setViewByGroup] = React.useState<Record<string, string>>({}); | |||||
| const saveGroup = (indexes: number[], nextGroup: ExcludeWarehouseRule[]) => { | |||||
| onSave(replaceGroup(rules, indexes, nextGroup)); | |||||
| }; | |||||
| const floors = uniqueSorted(warehouses.map((row) => row.storeId)); | |||||
| const groups = groupExcludeRuleIndexes(rules).map((indexes) => ({ key: indexes.join(","), indexes })); | |||||
| return ( | |||||
| <Stack spacing={1.5}> | |||||
| {rules.length === 0 ? ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Empty exclude rules")} | |||||
| </Typography> | |||||
| ) : ( | |||||
| groups.map((group, groupIndex) => { | |||||
| const overlappedBy = earlierOverlappingExcludeGroups(rules, groupIndex); | |||||
| const groupRules = group.indexes.map((index) => rules[index]).filter((rule): rule is ExcludeWarehouseRule => rule != null); | |||||
| const rule = groupRules[0]; | |||||
| if (!rule) return null; | |||||
| const warehousesForFloor = warehouses.filter( | |||||
| (row) => row.storeId.toUpperCase() === rule.floor.trim().toUpperCase(), | |||||
| ); | |||||
| const warehouseOptions = uniqueSorted(warehousesForFloor.map((row) => row.warehouse)); | |||||
| const areasForWarehouse = warehousesForFloor.filter( | |||||
| (row) => row.warehouse.toUpperCase() === rule.warehouse.trim().toUpperCase(), | |||||
| ); | |||||
| const areaNames = uniqueSorted(areasForWarehouse.map((row) => row.area)); | |||||
| const slotsByArea = new Map<string, string[]>(); | |||||
| areasForWarehouse.forEach((row) => { | |||||
| const slot = slotCode(row.code); | |||||
| const list = slotsByArea.get(row.area) ?? []; | |||||
| if (slot && !list.some((item) => item.toUpperCase() === slot.toUpperCase())) list.push(slot); | |||||
| slotsByArea.set(row.area, list); | |||||
| }); | |||||
| const selectedAreas = uniqueSorted(groupRules.flatMap((item) => areasOnRule(item, areaNames))); | |||||
| const viewArea = areaNames.find((area) => area.toUpperCase() === (viewByGroup[group.key] ?? "").toUpperCase()) ?? ""; | |||||
| const viewedRule = groupRules.find((item) => | |||||
| areasOnRule(item, areaNames).some((area) => area.toUpperCase() === viewArea.toUpperCase()), | |||||
| ); | |||||
| const viewSlots = uniqueSorted(slotsByArea.get(viewArea) ?? []); | |||||
| const pressedSlots = viewArea ? shownSlots(viewedRule?.slots ?? "", viewSlots) : []; | |||||
| const partialAreas = new Set( | |||||
| selectedAreas | |||||
| .filter((area) => { | |||||
| const areaSlots = uniqueSorted(slotsByArea.get(area) ?? []); | |||||
| const owner = groupRules.find((item) => | |||||
| areasOnRule(item, areaNames).some((name) => name.toUpperCase() === area.toUpperCase()), | |||||
| ); | |||||
| return shownSlots(owner?.slots ?? "", areaSlots).length < areaSlots.length; | |||||
| }) | |||||
| .map((area) => area.toUpperCase()), | |||||
| ); | |||||
| const selectableRules = groupRules.filter((item) => excludeRuleHasTarget(item)); | |||||
| const canEnable = selectableRules.length > 0; | |||||
| const applyGroup = (nextGroup: ExcludeWarehouseRule[]) => { | |||||
| const kept = nextGroup.length > 0 ? nextGroup : [{ ...rule, areas: "", slots: "" }]; | |||||
| saveGroup(group.indexes, kept); | |||||
| }; | |||||
| const setAreaIncluded = (area: string, included: boolean) => { | |||||
| if (!included) { | |||||
| const next = withoutArea(groupRules, area, areaNames); | |||||
| applyGroup(next); | |||||
| return; | |||||
| } | |||||
| applyGroup(withAreaSlots(groupRules, area, "", areaNames, rule)); | |||||
| }; | |||||
| return ( | |||||
| <Box | |||||
| key={group.key} | |||||
| sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 1.5, minWidth: 0, overflow: "hidden" }} | |||||
| > | |||||
| <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5 }}> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography sx={{ fontWeight: 700 }}>{t("Exclude rule n", { n: groupIndex + 1 })}</Typography> | |||||
| <FormControlLabel | |||||
| sx={{ mr: 0 }} | |||||
| control={ | |||||
| <Checkbox | |||||
| size="small" | |||||
| checked={ | |||||
| canEnable && | |||||
| overlappedBy.length === 0 && | |||||
| selectableRules.every((item) => item.enabled !== false) | |||||
| } | |||||
| disabled={saving || !canEnable || overlappedBy.length > 0} | |||||
| onChange={(event) => | |||||
| applyGroup( | |||||
| groupRules.map((item) => ({ | |||||
| ...item, | |||||
| enabled: event.target.checked && excludeRuleHasTarget(item), | |||||
| })), | |||||
| ) | |||||
| } | |||||
| /> | |||||
| } | |||||
| label={t("Rule enabled")} | |||||
| /> | |||||
| </Stack> | |||||
| <IconButton | |||||
| aria-label={t("Delete row")} | |||||
| size="small" | |||||
| disabled={saving} | |||||
| onClick={() => onSave(rules.filter((_, ruleIndex) => !group.indexes.includes(ruleIndex)))} | |||||
| > | |||||
| <DeleteOutline fontSize="small" /> | |||||
| </IconButton> | |||||
| </Stack> | |||||
| {overlappedBy.length > 0 ? ( | |||||
| <Alert severity="warning" sx={{ mb: 1.5 }}> | |||||
| {t("Putaway rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })} | |||||
| </Alert> | |||||
| ) : null} | |||||
| <Stack spacing={1.5}> | |||||
| <ToggleRows | |||||
| label={t("Floor")} | |||||
| options={floors} | |||||
| exclusive | |||||
| disabled={saving} | |||||
| value={rule.floor || null} | |||||
| onChange={(next) => | |||||
| applyGroup([ | |||||
| { | |||||
| ...rule, | |||||
| floor: typeof next === "string" ? next : "", | |||||
| warehouse: "", | |||||
| areas: "", | |||||
| slots: "", | |||||
| }, | |||||
| ]) | |||||
| } | |||||
| /> | |||||
| <ToggleRows | |||||
| label={t("Warehouse")} | |||||
| options={warehouseOptions} | |||||
| exclusive | |||||
| disabled={saving || !rule.floor} | |||||
| value={rule.warehouse || null} | |||||
| onChange={(next) => | |||||
| applyGroup([ | |||||
| { | |||||
| ...rule, | |||||
| warehouse: typeof next === "string" ? next : "", | |||||
| areas: "", | |||||
| slots: "", | |||||
| }, | |||||
| ]) | |||||
| } | |||||
| /> | |||||
| <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75, flexShrink: 0 }}> | |||||
| {t("Area")} | |||||
| </Typography> | |||||
| {areaNames.length === 0 ? ( | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}> | |||||
| — | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, flex: 1, minWidth: 0 }}> | |||||
| <Box | |||||
| sx={{ | |||||
| width: BUTTON_WIDTH, | |||||
| minWidth: BUTTON_WIDTH, | |||||
| border: "1px solid", | |||||
| borderColor: "divider", | |||||
| borderRadius: 1, | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "center", | |||||
| gap: 0.25, | |||||
| }} | |||||
| > | |||||
| <Checkbox | |||||
| size="small" | |||||
| disabled={saving} | |||||
| checked={areaNames.length > 0 && selectedAreas.length === areaNames.length} | |||||
| onChange={(_, checked) => { | |||||
| if (!checked) { | |||||
| applyGroup([{ ...rule, areas: "", slots: "" }]); | |||||
| return; | |||||
| } | |||||
| let next = groupRules; | |||||
| areaNames.forEach((area) => { | |||||
| if (!selectedAreas.some((item) => item.toUpperCase() === area.toUpperCase())) { | |||||
| next = withAreaSlots(next, area, "", areaNames, rule); | |||||
| } | |||||
| }); | |||||
| applyGroup(next); | |||||
| }} | |||||
| sx={{ p: 0.25 }} | |||||
| /> | |||||
| <Typography variant="body2">{t("All")}</Typography> | |||||
| </Box> | |||||
| {areaNames.map((area) => { | |||||
| const selected = selectedAreas.some((item) => item.toUpperCase() === area.toUpperCase()); | |||||
| const viewing = viewArea.toUpperCase() === area.toUpperCase(); | |||||
| const partial = partialAreas.has(area.toUpperCase()); | |||||
| return ( | |||||
| <Box | |||||
| key={area} | |||||
| sx={{ | |||||
| width: BUTTON_WIDTH, | |||||
| minWidth: BUTTON_WIDTH, | |||||
| border: viewing ? "2px solid" : "1px solid", | |||||
| borderColor: viewing ? "primary.main" : partial ? "warning.main" : "divider", | |||||
| borderRadius: 1, | |||||
| bgcolor: partial ? "#FFF6E8" : selected ? "action.selected" : "transparent", | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| }} | |||||
| > | |||||
| <Checkbox | |||||
| size="small" | |||||
| disabled={saving} | |||||
| checked={selected} | |||||
| onChange={(_, checked) => { | |||||
| setViewByGroup((prev) => ({ ...prev, [group.key]: area })); | |||||
| setAreaIncluded(area, checked); | |||||
| }} | |||||
| sx={{ p: 0.25 }} | |||||
| /> | |||||
| <Box | |||||
| onClick={() => setViewByGroup((prev) => ({ ...prev, [group.key]: area }))} | |||||
| sx={{ flex: 1, textAlign: "center", cursor: "pointer", py: 0.5, pr: 0.5 }} | |||||
| > | |||||
| <Typography variant="body2">{area}</Typography> | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| })} | |||||
| </Box> | |||||
| )} | |||||
| </Box> | |||||
| <ToggleRows | |||||
| label={t("Putaway slot")} | |||||
| options={viewArea ? viewSlots : []} | |||||
| exclusive={false} | |||||
| disabled={saving || !viewArea} | |||||
| value={pressedSlots} | |||||
| onChange={(next) => { | |||||
| if (!viewArea) return; | |||||
| const picked = Array.isArray(next) ? next : []; | |||||
| applyGroup(withAreaSlots(groupRules, viewArea, slotsToStore(picked, viewSlots), areaNames, rule)); | |||||
| }} | |||||
| /> | |||||
| {!viewArea ? ( | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {t("Click area to view slots")} | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {t("Viewing area", { area: viewArea })} | |||||
| {!viewedRule ? ` ${t("Area not in rule")}` : ""} | |||||
| </Typography> | |||||
| )} | |||||
| <Box> | |||||
| <Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}> | |||||
| {t("Print list label")} | |||||
| </Typography> | |||||
| <ToggleButtonGroup | |||||
| exclusive | |||||
| value={rule.printMode === "hideQr" ? "hideQr" : "hideList"} | |||||
| disabled={saving} | |||||
| onChange={(_, next: ExcludePrintMode | null) => { | |||||
| if (!next) return; | |||||
| applyGroup(groupRules.map((item) => ({ ...item, printMode: next }))); | |||||
| }} | |||||
| sx={{ flexWrap: "wrap" }} | |||||
| > | |||||
| <ToggleButton value="hideList" sx={{ textTransform: "none", px: 1.5, py: 1, alignItems: "flex-start" }}> | |||||
| <Box sx={{ textAlign: "left" }}> | |||||
| <Typography variant="body2" sx={{ fontWeight: 700 }}> | |||||
| {t("Print hide list")} | |||||
| </Typography> | |||||
| <Typography variant="caption" component="div" sx={{ color: "text.secondary", whiteSpace: "normal" }}> | |||||
| {t("Print hide list hint")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </ToggleButton> | |||||
| <ToggleButton value="hideQr" sx={{ textTransform: "none", px: 1.5, py: 1, alignItems: "flex-start" }}> | |||||
| <Box sx={{ textAlign: "left" }}> | |||||
| <Typography variant="body2" sx={{ fontWeight: 700 }}> | |||||
| {t("Print hide qr")} | |||||
| </Typography> | |||||
| <Typography variant="caption" component="div" sx={{ color: "text.secondary", whiteSpace: "normal" }}> | |||||
| {t("Print hide qr hint")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </ToggleButton> | |||||
| </ToggleButtonGroup> | |||||
| </Box> | |||||
| </Stack> | |||||
| </Box> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| <Button | |||||
| variant="outlined" | |||||
| disabled={saving} | |||||
| onClick={() => onSave([...rules, emptyExcludeWarehouseRule()])} | |||||
| sx={{ alignSelf: "flex-start" }} | |||||
| > | |||||
| {t("Add exclude rule")} | |||||
| </Button> | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| @@ -37,7 +37,6 @@ import { | |||||
| printWorkbenchLotLabel, | printWorkbenchLotLabel, | ||||
| } from "@/app/api/doworkbench/actions"; | } from "@/app/api/doworkbench/actions"; | ||||
| import { fetchDoFloorSettingsClient } from "@/app/api/settings/deliveryOrderFloor/client"; | import { fetchDoFloorSettingsClient } from "@/app/api/settings/deliveryOrderFloor/client"; | ||||
| import type { ExcludePrintMode } from "@/app/api/settings/deliveryOrderFloor/constants"; | |||||
| import { QRCodeSVG } from "qrcode.react"; | import { QRCodeSVG } from "qrcode.react"; | ||||
| type ScanPayload = { | type ScanPayload = { | ||||
| @@ -193,8 +192,8 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| null, | null, | ||||
| ); | ); | ||||
| const [excludePrintRule, setExcludePrintRule] = useState<{ | const [excludePrintRule, setExcludePrintRule] = useState<{ | ||||
| codes: Set<string>; | |||||
| mode: ExcludePrintMode; | |||||
| hideList: Set<string>; | |||||
| excluded: Set<string>; | |||||
| } | null>(null); | } | null>(null); | ||||
| const [snackbar, setSnackbar] = useState<{ | const [snackbar, setSnackbar] = useState<{ | ||||
| @@ -236,13 +235,27 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| pickRuleScope === "do" ? settings.doExcludeWarehouses : settings.joExcludeWarehouses; | pickRuleScope === "do" ? settings.doExcludeWarehouses : settings.joExcludeWarehouses; | ||||
| const mode = | const mode = | ||||
| pickRuleScope === "do" ? settings.doExcludePrintMode : settings.joExcludePrintMode; | pickRuleScope === "do" ? settings.doExcludePrintMode : settings.joExcludePrintMode; | ||||
| const codes = new Set( | |||||
| const hideQrRaw = | |||||
| pickRuleScope === "do" ? settings.doExcludeHideQrWarehouses : settings.joExcludeHideQrWarehouses; | |||||
| const excluded = new Set( | |||||
| csv | csv | ||||
| .split(",") | .split(",") | ||||
| .map((code) => code.trim().toUpperCase()) | .map((code) => code.trim().toUpperCase()) | ||||
| .filter(Boolean), | .filter(Boolean), | ||||
| ); | ); | ||||
| setExcludePrintRule({ codes, mode }); | |||||
| const hideQr = | |||||
| hideQrRaw == null || hideQrRaw === "" | |||||
| ? mode === "hideQr" | |||||
| ? excluded | |||||
| : new Set<string>() | |||||
| : 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(() => { | .catch(() => { | ||||
| if (!cancelled) setExcludePrintRule(null); | if (!cancelled) setExcludePrintRule(null); | ||||
| @@ -487,16 +500,15 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| const filteredLots = useMemo(() => { | const filteredLots = useMemo(() => { | ||||
| const prefix = String(warehouseCodePrefixFilter ?? "").trim(); | const prefix = String(warehouseCodePrefixFilter ?? "").trim(); | ||||
| const excluded = excludePrintRule?.codes; | |||||
| const hideList = excludePrintRule?.mode === "hideList"; | |||||
| const hideList = excludePrintRule?.hideList; | |||||
| return availableLots.filter((lot) => { | return availableLots.filter((lot) => { | ||||
| if (prefix && !lot._scanned) { | if (prefix && !lot._scanned) { | ||||
| const code = String(lot.warehouseCode ?? ""); | const code = String(lot.warehouseCode ?? ""); | ||||
| if (!code.startsWith(prefix)) return false; | if (!code.startsWith(prefix)) return false; | ||||
| } | } | ||||
| if (hideList && excluded && !lot._scanned) { | |||||
| if (hideList && !lot._scanned) { | |||||
| const code = String(lot.warehouseCode ?? "").trim().toUpperCase(); | const code = String(lot.warehouseCode ?? "").trim().toUpperCase(); | ||||
| if (code && excluded.has(code)) return false; | |||||
| if (code && hideList.has(code)) return false; | |||||
| } | } | ||||
| return true; | return true; | ||||
| }); | }); | ||||
| @@ -728,7 +740,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| printingLotLineId === lot.inventoryLotLineId; | printingLotLineId === lot.inventoryLotLineId; | ||||
| const loc = String(lot.warehouseCode ?? "").trim(); | const loc = String(lot.warehouseCode ?? "").trim(); | ||||
| const suppressQr = Boolean( | const suppressQr = Boolean( | ||||
| excludePrintRule?.codes.has(loc.toUpperCase()), | |||||
| excludePrintRule?.excluded.has(loc.toUpperCase()), | |||||
| ); | ); | ||||
| const canShowLotQr = | const canShowLotQr = | ||||
| !!onWorkbenchScanPick && | !!onWorkbenchScanPick && | ||||
| @@ -23,7 +23,7 @@ | |||||
| "Exception keywords": "Exception item no.", | "Exception keywords": "Exception item no.", | ||||
| "Add exception": "Add exception", | "Add exception": "Add exception", | ||||
| "Exception needs keywords": "Enter exception item keywords", | "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.", | "Exception overlap": "Overlaps exception {{n}}. On the same rule, the earlier exception is used.", | ||||
| "Add putaway rule": "Add rule", | "Add putaway rule": "Add rule", | ||||
| "Edit putaway": "Edit put-away", | "Edit putaway": "Edit put-away", | ||||
| @@ -114,7 +114,8 @@ | |||||
| "Col name": "Supplier name", | "Col name": "Supplier name", | ||||
| "Col type": "Floor", | "Col type": "Floor", | ||||
| "Col actions": "Actions", | "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)", | "Unknown supplier name": "(Not in master data or empty name)", | ||||
| "Delete row": "Delete row", | "Delete row": "Delete row", | ||||
| "Supplier list unavailable": "Could not load supplier list. Try again later.", | "Supplier list unavailable": "Could not load supplier list. Try again later.", | ||||
| @@ -127,5 +128,22 @@ | |||||
| "Save": "Save", | "Save": "Save", | ||||
| "Saved": "Saved", | "Saved": "Saved", | ||||
| "Cancel": "Cancel", | "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." | |||||
| } | } | ||||
| @@ -23,7 +23,7 @@ | |||||
| "Exception keywords": "例外貨品編號", | "Exception keywords": "例外貨品編號", | ||||
| "Add exception": "新增例外", | "Add exception": "新增例外", | ||||
| "Exception needs keywords": "請填例外貨號", | "Exception needs keywords": "請填例外貨號", | ||||
| "Exception outside rule": "這個例外的貨號不在這條規則裡,不會生效。", | |||||
| "Exception outside rule": "{{codes}} 不在這條規則的貨品編號裡,不會生效。", | |||||
| "Exception overlap": "與例外 {{n}} 貨號重疊。同一條規則先用上面的例外。", | "Exception overlap": "與例外 {{n}} 貨號重疊。同一條規則先用上面的例外。", | ||||
| "Add putaway rule": "新增規則", | "Add putaway rule": "新增規則", | ||||
| "Edit putaway": "編輯自動上架", | "Edit putaway": "編輯自動上架", | ||||
| @@ -114,7 +114,8 @@ | |||||
| "Col name": "供應商名稱", | "Col name": "供應商名稱", | ||||
| "Col type": "樓層", | "Col type": "樓層", | ||||
| "Col actions": "操作", | "Col actions": "操作", | ||||
| "Empty floor list": "此樓層尚無供應商,請按「新增映射」加入。", | |||||
| "Empty floor list": "此樓層尚無供應商。", | |||||
| "Show supplier name": "顯示名稱", | |||||
| "Unknown supplier name": "(主檔無此代碼或名稱為空)", | "Unknown supplier name": "(主檔無此代碼或名稱為空)", | ||||
| "Delete row": "刪除此列", | "Delete row": "刪除此列", | ||||
| "Supplier list unavailable": "無法載入供應商清單,請稍後再試。", | "Supplier list unavailable": "無法載入供應商清單,請稍後再試。", | ||||
| @@ -127,5 +128,22 @@ | |||||
| "Save": "儲存", | "Save": "儲存", | ||||
| "Saved": "已儲存", | "Saved": "已儲存", | ||||
| "Cancel": "取消", | "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}} 不在這條規則的儲位裡,不會生效。" | |||||
| } | } | ||||