| @@ -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<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<{ | |||
| 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"), | |||
| }; | |||
| @@ -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<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, | |||
| } 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<WorkbenchLotLabelPrintModalProps> = | |||
| null, | |||
| ); | |||
| const [excludePrintRule, setExcludePrintRule] = useState<{ | |||
| codes: Set<string>; | |||
| mode: ExcludePrintMode; | |||
| hideList: Set<string>; | |||
| excluded: Set<string>; | |||
| } | null>(null); | |||
| const [snackbar, setSnackbar] = useState<{ | |||
| @@ -236,13 +235,27 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| 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<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(() => { | |||
| if (!cancelled) setExcludePrintRule(null); | |||
| @@ -487,16 +500,15 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| 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<WorkbenchLotLabelPrintModalProps> = | |||
| 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 && | |||
| @@ -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." | |||
| } | |||
| @@ -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}} 不在這條規則的儲位裡,不會生效。" | |||
| } | |||