Bläddra i källkod

control page

controllQRcodescanWarehouse
CANCERYS\kw093 23 timmar sedan
förälder
incheckning
f70744e51c
7 ändrade filer med 1266 tillägg och 599 borttagningar
  1. +69
    -4
      src/app/api/settings/deliveryOrderFloor/client.ts
  2. +227
    -0
      src/app/api/settings/deliveryOrderFloor/constants.ts
  3. +394
    -579
      src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx
  4. +512
    -0
      src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx
  5. +22
    -10
      src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
  6. +21
    -3
      src/i18n/en/deliveryOrderFloor.json
  7. +21
    -3
      src/i18n/zh/deliveryOrderFloor.json

+ 69
- 4
src/app/api/settings/deliveryOrderFloor/client.ts Visa fil

@@ -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"),
};


+ 227
- 0
src/app/api/settings/deliveryOrderFloor/constants.ts Visa fil

@@ -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;
}

+ 394
- 579
src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx
Filskillnaden har hållits tillbaka eftersom den är för stor
Visa fil


+ 512
- 0
src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx Visa fil

@@ -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>
);
}

+ 22
- 10
src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx Visa fil

@@ -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 &&


+ 21
- 3
src/i18n/en/deliveryOrderFloor.json Visa fil

@@ -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."
}

+ 21
- 3
src/i18n/zh/deliveryOrderFloor.json Visa fil

@@ -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}} 不在這條規則的儲位裡,不會生效。"
}

Laddar…
Avbryt
Spara