import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../ui/table"; import { Input } from "../ui/input"; import { Button } from "../ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { Badge } from "../ui/badge"; import { Plus, Edit, MoreHorizontal, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { skipCountForPage } from "../../lib/paginationQuery"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { SearchableSelect } from "../ui/searchable-select"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "../ui/pagination"; import { getLabels, getLabel, createLabel, updateLabel, deleteLabel } from "../../services/labelService"; import type { LabelDto, LabelCreateInput, LabelUpdateInput } from "../../types/label"; import { getLocations } from "../../services/locationService"; import { getGroups } from "../../services/groupService"; import { getLabelCategories } from "../../services/labelCategoryService"; import { getLabelTypes } from "../../services/labelTypeService"; import { getLabelTemplate, getLabelTemplates, updateLabelTemplate } from "../../services/labelTemplateService"; import { getProduct, getProducts } from "../../services/productService"; import { getProductCategories } from "../../services/productCategoryService"; import type { GroupListItem } from "../../types/group"; import type { LocationDto } from "../../types/location"; import type { LabelCategoryDto } from "../../types/labelCategory"; import type { LabelTypeDto } from "../../types/labelType"; import type { LabelElement, LabelTemplate, LabelTemplateDto, LabelTemplateProductDefaultDto, } from "../../types/labelTemplate"; import type { ProductDto } from "../../types/product"; import type { ProductCategoryDto } from "../../types/productCategory"; import type { ProductLocationLinkDto } from "../../types/productLocation"; import { getProductLocations } from "../../services/productLocationService"; import { LabelTemplateDataEntryView } from "./LabelTemplateDataEntryView"; import { LabelPreviewOnly } from "./LabelTemplateEditor/LabelCanvas"; import { appliedLocationToEditor, canonicalElementType, dataEntryColumnLabel, isDataEntryTableColumnElement, isDateTimeDataEntryField, labelElementsToApiPayload, sortTemplateElementsForDisplay, } from "../../types/labelTemplate"; import { applyOffsetToDate, formatDateByPreset, LABEL_FORM_OFFSET_UNITS, normalizeLabelFormOffsetInput, serializePrintInputOffset, } from "../../lib/labelFormDatePreview"; import { listNutritionElements, listNutritionManualFieldSpecs, mergeNutritionManualIntoConfig, nutritionDefaultValuesJsonForSave, nutritionManualValuesFromTemplateConfig, type NutritionManualFieldSpec, } from "../../lib/nutritionManualEntry"; import { applyProductCodeValueToLabelElements, buildTemplateBarcodeQrDefaultsFromCodeValue, isTemplateSectionBarcodeOrQrElement, } from "../../lib/productCodeValueTemplate"; function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); return s ? s : "None"; } /** 列表行:产品列(接口可能返回 `products` 汇总字符串或 `productName` / productIds) */ function labelRowProductsText(item: LabelDto): string { const aggregated = (item.products ?? "").trim(); if (aggregated) return aggregated; const pn = (item.productName ?? "").trim(); if (pn) return pn; const n = item.productIds?.length ?? 0; if (n > 0) return `${n} product(s)`; return "None"; } /** 与列表/产品页一致:按区域(Group)解析该组下门店 */ function locationsForRegionGroup( locations: LocationDto[], groups: GroupListItem[], regionGroupId: string, ): LocationDto[] { const id = regionGroupId.trim(); if (!id) return []; const g = groups.find((x) => (x.id ?? "").trim() === id); if (!g) return []; const gn = (g.groupName ?? "").trim(); const pn = (g.partnerName ?? "").trim(); return locations.filter( (l) => (l.groupName ?? "").trim() === gn && (l.partner ?? "").trim() === pn, ); } function buildProductLocationMap(rows: ProductLocationLinkDto[]): Map { const map = new Map(); for (const row of rows) { const pid = (row.productId ?? "").trim(); const lid = (row.locationId ?? "").trim(); if (!pid || !lid) continue; if (!map.has(pid)) map.set(pid, []); const arr = map.get(pid)!; if (!arr.includes(lid)) arr.push(lid); } return map; } function mergeProductLocationOverlay(map: Map, products: ProductDto[]): Map { const next = new Map(map); for (const p of products) { if (Array.isArray(p.locationIds)) { next.set(p.id, [...new Set(p.locationIds.map((x) => String(x).trim()).filter(Boolean))]); } } return next; } function productCategoryMatchesRegion(c: ProductCategoryDto, allowedLocIds: Set): boolean { const at = String(c.availabilityType ?? "ALL").trim().toUpperCase(); if (at !== "SPECIFIED") return true; const lids = (c.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean); return lids.some((lid) => allowedLocIds.has(lid)); } function labelCategoryMatchesRegion(c: LabelCategoryDto, allowedLocIds: Set): boolean { const at = String(c.availabilityType ?? "ALL").trim().toUpperCase(); if (at !== "SPECIFIED") return true; const lids = (c.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean); return lids.some((lid) => allowedLocIds.has(lid)); } function templateAppliesToRegion(tpl: LabelTemplateDto, allowedLocIds: Set): boolean { const mode = appliedLocationToEditor(tpl); if (mode === "ALL") return true; const ids = (tpl.appliedLocationIds ?? []).map((x) => String(x).trim()).filter(Boolean); return ids.some((lid) => allowedLocIds.has(lid)); } /** 详情 / 列表行 → 编辑表单(列表接口可能缺 ID 字段,需再以 GET 详情补全) */ function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput { const ids = d.productIds; const arr = Array.isArray(ids) ? ids.map((x) => String(x).trim()).filter(Boolean) : []; return { labelName: d.labelName ?? "", templateCode: d.templateCode ?? "", locationId: d.locationId ?? "", labelCategoryId: d.labelCategoryId ?? "", labelTypeId: d.labelTypeId ?? "", /** 编辑表单仅支持单商品:多商品时取第一个 */ productIds: arr.length ? [arr[0]] : [], labelInfoJson: d.labelInfoJson ?? null, state: d.state ?? true, }; } function getDataEntryElements(template: LabelTemplateDto | null): LabelElement[] { if (!template) return []; return sortTemplateElementsForDisplay((template.elements ?? []) as LabelElement[]).filter( isDataEntryTableColumnElement, ); } /** 新建标签弹窗「Template Input Data」:Label 可编辑列 + Template 条码/二维码只读列 */ function getCreateLabelTemplateInputElements(template: LabelTemplateDto | null): LabelElement[] { if (!template) return []; const sorted = sortTemplateElementsForDisplay((template.elements ?? []) as LabelElement[]); const seen = new Set(); const out: LabelElement[] = []; for (const el of sorted) { if (!isDataEntryTableColumnElement(el) && !isTemplateSectionBarcodeOrQrElement(el)) continue; if (seen.has(el.id)) continue; seen.add(el.id); out.push(el); } return out; } function buildCreateLabelPreviewTemplate( apiTpl: LabelTemplateDto | null, textValues: Record, dateOffsets: Record, nutritionByElementId: Record>, productCodeValue?: string | null, ): LabelTemplate | null { if (!apiTpl) return null; const tmpl = dtoToEditorTemplate(apiTpl); const now = new Date(); for (const el of tmpl.elements) { if (!isDataEntryTableColumnElement(el)) continue; const cfg = { ...(el.config as Record) }; delete cfg.__previewFormatted; const id = el.id; const type = canonicalElementType(el.type); if (isDateTimeDataEntryField(el)) { const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; const unit = pair.unit || "Days"; const norm = normalizeLabelFormOffsetInput(pair.value); if (norm.kind === "invalid") { cfg.__previewFormatted = ""; } else { const amount = norm.kind === "zero" ? 0 : norm.amount; const d = applyOffsetToDate(now, amount, unit); if (type === "DATE") { const it = String(cfg.inputType ?? cfg.InputType ?? "").toLowerCase(); const format = (typeof cfg.format === "string" && cfg.format.trim() ? cfg.format : typeof cfg.Format === "string" && cfg.Format.trim() ? cfg.Format : it === "datetime" ? "YYYY-MM-DD HH:mm" : "DD/MM/YYYY") ?? "DD/MM/YYYY"; cfg.__previewFormatted = formatDateByPreset(format, d); } else if (type === "TIME") { cfg.__previewFormatted = formatDateByPreset("HH:mm", d); } else { cfg.__previewFormatted = `${amount} ${unit}`; } } } else { const v = textValues[id] ?? ""; if (type === "BARCODE" || type === "QRCODE") cfg.data = v; else if (type === "IMAGE") cfg.src = v; else if (type === "TEXT_STATIC" || type === "TEXT_PRODUCT" || type === "TEXT_PRICE") cfg.text = v; else cfg.text = v; } el.config = cfg; } for (const el of tmpl.elements) { if (canonicalElementType(el.type) !== "NUTRITION") continue; const manual = nutritionByElementId[el.id] ?? {}; const merged = mergeNutritionManualIntoConfig({ ...(el.config as Record) }, manual); el.config = merged as LabelElement["config"]; } tmpl.elements = applyProductCodeValueToLabelElements(tmpl.elements, productCodeValue); return tmpl; } function collectTemplateDefaultValuesForSave( latest: LabelTemplateDto, textValues: Record, dateOffsets: Record, nutritionByElementId: Record>, productCodeValue?: string | null, ): Record { const out: Record = {}; for (const el of getDataEntryElements(latest)) { const id = el.id; if (isDateTimeDataEntryField(el)) { const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; const unit = pair.unit || "Days"; const norm = normalizeLabelFormOffsetInput(pair.value); if (norm.kind === "invalid") { out[id] = ""; } else if (norm.kind === "zero") { out[id] = serializePrintInputOffset(unit, "0"); } else { out[id] = serializePrintInputOffset(unit, norm.storeValue); } } else { out[id] = String(textValues[id] ?? ""); } } for (const nel of listNutritionElements((latest.elements ?? []) as LabelElement[])) { const manual = nutritionByElementId[nel.id]; if (!manual) continue; const j = nutritionDefaultValuesJsonForSave(manual); if (j) out[nel.id] = j; } Object.assign( out, buildTemplateBarcodeQrDefaultsFromCodeValue( (latest.elements ?? []) as LabelElement[], productCodeValue, ), ); return out; } function templateListCode(t: LabelTemplateDto): string { return (t.templateCode ?? t.id ?? "").trim(); } function templateListLabel(t: LabelTemplateDto): string { const name = (t.templateName ?? t.name ?? "").trim() || "None"; const code = templateListCode(t) || "None"; return `${name} (${code})`; } function dtoToEditorTemplate(apiTemplate: LabelTemplateDto): LabelTemplate { return { id: apiTemplate.id, name: (apiTemplate.name ?? apiTemplate.templateName ?? "").trim() || "Unnamed template", labelType: (apiTemplate.labelType as any) ?? "PRICE", unit: (apiTemplate.unit as any) ?? "cm", width: apiTemplate.width ?? 6, height: apiTemplate.height ?? 4, appliedLocation: appliedLocationToEditor(apiTemplate), appliedLocationIds: [...(apiTemplate.appliedLocationIds ?? [])], showRuler: apiTemplate.showRuler ?? true, showGrid: apiTemplate.showGrid ?? true, elements: (apiTemplate.elements ?? []).map((raw, idx) => { const el = raw as LabelElement; const en = (el.elementName ?? "").trim(); return { ...el, elementName: en || `element${idx + 1}`, }; }), }; } function buildTemplateDefaultsMap( template: LabelTemplateDto, ): Map { const map = new Map(); for (const row of template.templateProductDefaults ?? []) { const key = `${row.productId}::${row.labelTypeId}`; map.set(key, row); } return map; } function useLabelFormReferenceData(open: boolean) { const [loading, setLoading] = useState(false); const [templates, setTemplates] = useState([]); const [locations, setLocations] = useState([]); const [groups, setGroups] = useState([]); const [categories, setCategories] = useState([]); const [types, setTypes] = useState([]); const [products, setProducts] = useState([]); /** 商品分类(Menu / fl_product_category),用于筛选商品 */ const [productCategories, setProductCategories] = useState([]); const [productLocationMap, setProductLocationMap] = useState>(() => new Map()); useEffect(() => { if (!open) return; let cancelled = false; (async () => { setLoading(true); try { const [tplRes, locRes, grpRes, catRes, typeRes, prodRes, menuCatRes, plRes] = await Promise.all([ getLabelTemplates({ skipCount: 1, maxResultCount: 500 }), getLocations({ skipCount: 1, maxResultCount: 500 }), getGroups({ skipCount: 1, maxResultCount: 500 }), getLabelCategories({ skipCount: 1, maxResultCount: 500 }), getLabelTypes({ skipCount: 1, maxResultCount: 500 }), getProducts({ skipCount: 1, maxResultCount: 500 }), getProductCategories({ skipCount: 1, maxResultCount: 500, sorting: "OrderNum desc" }), getProductLocations({ skipCount: 1, maxResultCount: 2000 }), ]); if (cancelled) return; setTemplates(tplRes.items ?? []); setLocations(locRes.items ?? []); setGroups(grpRes.items ?? []); setCategories(catRes.items ?? []); setTypes(typeRes.items ?? []); setProducts(prodRes.items ?? []); setProductCategories(menuCatRes.items ?? []); const rawMap = buildProductLocationMap(plRes.items ?? []); setProductLocationMap(mergeProductLocationOverlay(rawMap, prodRes.items ?? [])); } catch (e: any) { if (!cancelled) { toast.error("Failed to load options", { description: e?.message ? String(e.message) : "Check network or sign-in.", }); setTemplates([]); setLocations([]); setGroups([]); setCategories([]); setTypes([]); setProducts([]); setProductCategories([]); setProductLocationMap(new Map()); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [open]); return { loading, templates, locations, groups, categories, types, products, productCategories, productLocationMap, }; } /** 先选商品分类,再在分类下搜索并单选商品(提交仍用 productIds: [id]) */ function ProductSingleSelectByCategoryField({ productCatalogCategoryId, onProductCatalogCategoryIdChange, productId, onProductIdChange, products, productCategories, disabled, }: { productCatalogCategoryId: string; onProductCatalogCategoryIdChange: (id: string) => void; productId: string; onProductIdChange: (id: string) => void; products: ProductDto[]; productCategories: ProductCategoryDto[]; /** 为 true 时表示未选区域等作用域,下拉禁用并显示统一提示 */ disabled?: boolean; }) { const catalogCategoryOptions = useMemo( () => productCategories .map((c) => ({ value: (c.id ?? "").trim(), label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), })) .filter((o) => o.value), [productCategories], ); const filteredProducts = useMemo(() => { const cid = productCatalogCategoryId.trim(); if (!cid) return []; return products.filter((p) => (p.categoryId ?? "").trim() === cid); }, [products, productCatalogCategoryId]); const productSelectOptions = useMemo(() => { const rows = filteredProducts.map((p) => { const name = (p.productName ?? p.productCode ?? "").trim() || p.id; return { value: p.id, label: `${name}` }; }); const pid = productId.trim(); if (pid && !rows.some((r) => r.value === pid)) { const p = products.find((x) => x.id === pid); const name = (p?.productName ?? p?.productCode ?? "").trim() || pid; return [{ value: pid, label: `${name} (current)` }, ...rows]; } return rows; }, [filteredProducts, productId, products]); const productOptionsForSelect = useMemo( () => productSelectOptions.map((r) => ({ value: r.value, label: r.label, })), [productSelectOptions], ); return (
{ onProductCatalogCategoryIdChange(v); onProductIdChange(""); }} options={catalogCategoryOptions} placeholder={ disabled ? "Select company and region first" : "Select product category first" } searchPlaceholder="Search product category…" emptyText={disabled ? "Select company and region." : "No product categories."} disabled={disabled} />
{productId.trim() ? (

Id: {productId}

) : null}
); } type LabelsListProps = { /** 大于 0 时打开「新增标签」弹窗,并由父级通过 onOpenCreateIntentConsumed 归零 */ openCreateSeq?: number; onOpenCreateIntentConsumed?: () => void; }; export function LabelsList({ openCreateSeq = 0, onOpenCreateIntentConsumed }: LabelsListProps = {}) { const [dataEntryTemplateCode, setDataEntryTemplateCode] = useState(null); const [dataEntryContextHint, setDataEntryContextHint] = useState(undefined); const [isBulkAddDialogOpen, setIsBulkAddDialogOpen] = useState(false); const [bulkLoadingTemplates, setBulkLoadingTemplates] = useState(false); const [bulkLoadingPreview, setBulkLoadingPreview] = useState(false); const [bulkTemplates, setBulkTemplates] = useState([]); const [bulkTemplateCode, setBulkTemplateCode] = useState(""); const [bulkPreviewTemplate, setBulkPreviewTemplate] = useState(null); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [editingLabel, setEditingLabel] = useState(null); const [deletingLabel, setDeletingLabel] = useState(null); const [labels, setLabels] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [refreshSeq, setRefreshSeq] = useState(0); const [actionsOpenForId, setActionsOpenForId] = useState(null); const [keyword, setKeyword] = useState(""); const [regionFilter, setRegionFilter] = useState("all"); const [locationFilter, setLocationFilter] = useState("all"); const [labelCategoryFilter, setLabelCategoryFilter] = useState("all"); const [labelTypeFilter, setLabelTypeFilter] = useState("all"); const [templateFilter, setTemplateFilter] = useState("all"); const [stateFilter, setStateFilter] = useState("all"); const [filterLocations, setFilterLocations] = useState([]); const [filterGroups, setFilterGroups] = useState([]); const [filterLabelCategories, setFilterLabelCategories] = useState([]); const [filterLabelTypes, setFilterLabelTypes] = useState([]); useEffect(() => { let cancelled = false; (async () => { try { const [locRes, grpRes, catRes, typeRes] = await Promise.all([ getLocations({ skipCount: 1, maxResultCount: 500 }), getGroups({ skipCount: 1, maxResultCount: 500 }), getLabelCategories({ skipCount: 1, maxResultCount: 500 }), getLabelTypes({ skipCount: 1, maxResultCount: 500 }), ]); if (cancelled) return; setFilterLocations(locRes.items ?? []); setFilterGroups(grpRes.items ?? []); setFilterLabelCategories(catRes.items ?? []); setFilterLabelTypes(typeRes.items ?? []); } catch (e: unknown) { if (!cancelled) { setFilterLocations([]); setFilterGroups([]); setFilterLabelCategories([]); setFilterLabelTypes([]); toast.error("Failed to load filters.", { description: e instanceof Error ? e.message : "Please try again.", }); } } })(); return () => { cancelled = true; }; }, []); const regionSelectOptions = useMemo(() => { const m = new Map(); for (const g of filterGroups) { const id = (g.id ?? "").trim(); if (id && !m.has(id)) m.set(id, g); } return Array.from(m.values()).sort((a, b) => (a.groupName ?? "").localeCompare(b.groupName ?? "", undefined, { sensitivity: "base" }), ); }, [filterGroups]); const locationsForToolbarFilter = useMemo(() => { if (regionFilter === "all") return filterLocations; const g = filterGroups.find((x) => x.id === regionFilter); if (!g) return filterLocations; const gn = (g.groupName ?? "").trim(); const pn = (g.partnerName ?? "").trim(); return filterLocations.filter( (l) => (l.groupName ?? "").trim() === gn && (l.partner ?? "").trim() === pn, ); }, [filterLocations, filterGroups, regionFilter]); const categoryToolbarOptions = useMemo(() => { const m = new Map(); for (const c of filterLabelCategories) { const id = (c.id ?? "").trim(); if (id && !m.has(id)) m.set(id, c); } return Array.from(m.values()).sort((a, b) => { const la = (a.categoryName ?? a.categoryCode ?? a.id ?? "").toString(); const lb = (b.categoryName ?? b.categoryCode ?? b.id ?? "").toString(); return la.localeCompare(lb, undefined, { sensitivity: "base" }); }); }, [filterLabelCategories]); const typeToolbarOptions = useMemo(() => { const m = new Map(); for (const t of filterLabelTypes) { const id = (t.id ?? "").trim(); if (id && !m.has(id)) m.set(id, t); } return Array.from(m.values()).sort((a, b) => { const la = (a.typeName ?? a.typeCode ?? a.id ?? "").toString(); const lb = (b.typeName ?? b.typeCode ?? b.id ?? "").toString(); return la.localeCompare(lb, undefined, { sensitivity: "base" }); }); }, [filterLabelTypes]); useEffect(() => { if (labelCategoryFilter === "all") return; const allowed = new Set(categoryToolbarOptions.map((c) => c.id)); if (!allowed.has(labelCategoryFilter)) setLabelCategoryFilter("all"); }, [categoryToolbarOptions, labelCategoryFilter]); useEffect(() => { if (labelTypeFilter === "all") return; const allowed = new Set(typeToolbarOptions.map((t) => t.id)); if (!allowed.has(labelTypeFilter)) setLabelTypeFilter("all"); }, [typeToolbarOptions, labelTypeFilter]); useEffect(() => { if (locationFilter === "all") return; const allowed = new Set(locationsForToolbarFilter.map((l) => l.id)); if (!allowed.has(locationFilter)) setLocationFilter("all"); }, [locationsForToolbarFilter, locationFilter]); const [pageIndex, setPageIndex] = useState(1); const [pageSize, setPageSize] = useState(10); useEffect(() => { if (openCreateSeq <= 0) return; setIsCreateDialogOpen(true); onOpenCreateIntentConsumed?.(); }, [openCreateSeq, onOpenCreateIntentConsumed]); const abortRef = useRef(null); const keywordTimerRef = useRef(null); const [debouncedKeyword, setDebouncedKeyword] = useState(""); useEffect(() => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); return () => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); }; }, [keyword]); const totalPages = Math.max(1, Math.ceil(total / pageSize)); useEffect(() => { setPageIndex(1); }, [debouncedKeyword, regionFilter, locationFilter, labelCategoryFilter, labelTypeFilter, templateFilter, stateFilter, pageSize]); useEffect(() => { const run = async () => { abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setLoading(true); try { const baseInput = { keyword: debouncedKeyword || undefined, labelCategoryId: labelCategoryFilter !== "all" ? labelCategoryFilter : undefined, labelTypeId: labelTypeFilter !== "all" ? labelTypeFilter : undefined, templateCode: templateFilter !== "all" ? templateFilter : undefined, state: stateFilter === "all" ? undefined : stateFilter === "true", }; /** 标签接口无 Region 字段:选区域且仍为「全部地点」时与其它模块一致——拉一批再按门店裁剪后分页 */ if (regionFilter !== "all" && locationFilter === "all") { const allowed = new Set(locationsForToolbarFilter.map((l) => l.id)); const res = await getLabels( { skipCount: 1, maxResultCount: 500, ...baseInput, }, ac.signal, ); let list = res.items ?? []; list = list.filter((item) => allowed.has(String(item.locationId ?? "").trim())); const t = list.length; const start = (pageIndex - 1) * pageSize; setLabels(list.slice(start, start + pageSize)); setTotal(t); return; } const skipCount = skipCountForPage(pageIndex); const res = await getLabels( { skipCount, maxResultCount: pageSize, ...baseInput, locationId: locationFilter !== "all" ? locationFilter : undefined, }, ac.signal, ); setLabels(res.items ?? []); setTotal(res.totalCount ?? 0); } catch (e: any) { if (e?.name === "AbortError") return; toast.error("Failed to load labels.", { description: e?.message ? String(e.message) : "Please try again.", }); setLabels([]); setTotal(0); } finally { setLoading(false); } }; run(); return () => abortRef.current?.abort(); }, [ debouncedKeyword, regionFilter, locationFilter, locationsForToolbarFilter, labelCategoryFilter, labelTypeFilter, templateFilter, stateFilter, pageIndex, pageSize, refreshSeq, ]); const refreshList = () => setRefreshSeq((x) => x + 1); const openEdit = (label: LabelDto) => { setActionsOpenForId(null); setEditingLabel(label); setIsEditDialogOpen(true); }; const openDelete = (label: LabelDto) => { setActionsOpenForId(null); setDeletingLabel(label); setIsDeleteDialogOpen(true); }; useEffect(() => { if (!isBulkAddDialogOpen) return; let cancelled = false; (async () => { setBulkLoadingTemplates(true); try { const res = await getLabelTemplates({ skipCount: 1, maxResultCount: 500 }); if (cancelled) return; const items = (res.items ?? []).filter((t) => templateListCode(t)); setBulkTemplates(items); } catch (e: unknown) { if (cancelled) return; setBulkTemplates([]); toast.error("Failed to load templates.", { description: e instanceof Error ? e.message : "Please try again.", }); } finally { if (!cancelled) setBulkLoadingTemplates(false); } })(); return () => { cancelled = true; }; }, [isBulkAddDialogOpen]); useEffect(() => { if (!isBulkAddDialogOpen || !bulkTemplateCode) { setBulkPreviewTemplate(null); return; } let cancelled = false; (async () => { setBulkLoadingPreview(true); try { const tpl = await getLabelTemplate(bulkTemplateCode); if (cancelled) return; setBulkPreviewTemplate(dtoToEditorTemplate(tpl)); } catch (e: unknown) { if (cancelled) return; setBulkPreviewTemplate(null); toast.error("Failed to load template preview.", { description: e instanceof Error ? e.message : "Please try again.", }); } finally { if (!cancelled) setBulkLoadingPreview(false); } })(); return () => { cancelled = true; }; }, [isBulkAddDialogOpen, bulkTemplateCode]); const bulkTemplateOptions = useMemo( () => bulkTemplates.map((t) => ({ value: templateListCode(t), label: templateListLabel(t), })), [bulkTemplates], ); const handleBulkAddConfirm = () => { if (!bulkTemplateCode.trim()) { toast.error("Template required", { description: "Please select a template first.", }); return; } const tpl = bulkTemplates.find((x) => templateListCode(x) === bulkTemplateCode); const title = tpl ? templateListLabel(tpl) : bulkTemplateCode; setDataEntryContextHint(`Bulk Add template: ${title}`); setDataEntryTemplateCode(bulkTemplateCode); setIsBulkAddDialogOpen(false); }; const closeBulkAddDialog = (open: boolean) => { setIsBulkAddDialogOpen(open); if (!open) { setBulkTemplateCode(""); setBulkPreviewTemplate(null); } }; const closeDataEntry = () => { setDataEntryTemplateCode(null); setDataEntryContextHint(undefined); refreshList(); }; if (dataEntryTemplateCode) { return (
); } return (
setKeyword(e.target.value)} style={{ height: 40, boxSizing: 'border-box' }} className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" />
Label Name Product Location Label Category Product Category Label Type Template Status Last Edited Actions {loading ? ( Loading... ) : labels.length === 0 ? ( No results. ) : ( labels.map((item) => ( {toDisplay(item.labelName)} {labelRowProductsText(item)} {toDisplay(item.locationName ?? item.locationId)} {toDisplay(item.labelCategoryName ?? item.labelCategoryId)} {toDisplay(item.productCategoryName)} {toDisplay(item.labelTypeName ?? item.labelTypeId)} {toDisplay(item.templateName ?? item.templateCode)} {item.state === true ? "Active" : "Inactive"} {toDisplay(item.lastEdited)} setActionsOpenForId(open ? item.id : null)} > )) )}
Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- {Math.min(pageIndex * pageSize, total)} of {total}
{ e.preventDefault(); setPageIndex((p) => Math.max(1, p - 1)); }} aria-disabled={pageIndex <= 1} className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} /> e.preventDefault()} > Page {pageIndex} / {totalPages} { e.preventDefault(); setPageIndex((p) => Math.min(totalPages, p + 1)); }} aria-disabled={pageIndex >= totalPages} className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} />
{ setPageIndex(1); refreshList(); }} /> { setIsEditDialogOpen(open); if (!open) setEditingLabel(null); }} onUpdated={refreshList} /> { setIsDeleteDialogOpen(open); if (!open) setDeletingLabel(null); }} onDeleted={refreshList} /> Select a Template to Bulk Add
{bulkLoadingPreview ? (
Loading preview…
) : bulkPreviewTemplate ? (
) : (
Select a template to preview.
)}
); } function CreateLabelDialog({ open, onOpenChange, onCreated, }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void; }) { const { loading: refLoading, templates, locations, groups, categories, types, products, productCategories, productLocationMap, } = useLabelFormReferenceData(open); const [scopeCompany, setScopeCompany] = useState(""); const [scopeRegionGroupId, setScopeRegionGroupId] = useState(""); const [labelTypeIdsInRegion, setLabelTypeIdsInRegion] = useState | null>(null); const prevRegionForResetRef = useRef(null); const [productCatalogCategoryId, setProductCatalogCategoryId] = useState(""); const [submitting, setSubmitting] = useState(false); const [templateLoading, setTemplateLoading] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(null); const [templateDataValues, setTemplateDataValues] = useState>({}); /** 日期/时间类:单位 + 数值(相对当前时间) */ const [templateDateOffsets, setTemplateDateOffsets] = useState< Record >({}); /** NUTRITION 元素 id → 子字段(calories、fat、extra:…)手动值 */ const [nutritionByElementId, setNutritionByElementId] = useState>>({}); const [form, setForm] = useState({ labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); const resetForm = () => { setForm({ labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); setSelectedTemplate(null); setTemplateDataValues({}); setTemplateDateOffsets({}); setNutritionByElementId({}); setProductCatalogCategoryId(""); setScopeCompany(""); setScopeRegionGroupId(""); setLabelTypeIdsInRegion(null); prevRegionForResetRef.current = null; }; useEffect(() => { if (!open) { resetForm(); } }, [open]); useEffect(() => { if (!scopeCompany.trim()) { setScopeRegionGroupId(""); } }, [scopeCompany]); const locationsInScope = useMemo( () => locationsForRegionGroup(locations, groups, scopeRegionGroupId), [locations, groups, scopeRegionGroupId], ); const allowedLocIdsForScope = useMemo( () => new Set(locationsInScope.map((l) => l.id).filter(Boolean)), [locationsInScope], ); const regionScopeReady = !!(scopeCompany.trim() && scopeRegionGroupId.trim()); useEffect(() => { if (!open) return; const cur = scopeRegionGroupId.trim(); const prev = prevRegionForResetRef.current; prevRegionForResetRef.current = cur; if (prev === null) return; if (prev === cur) return; setProductCatalogCategoryId(""); setForm((p) => ({ ...p, templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], })); }, [open, scopeRegionGroupId]); useEffect(() => { if (!regionScopeReady) { setLabelTypeIdsInRegion(null); return; } let cancelled = false; (async () => { try { const res = await getLabels({ skipCount: 1, maxResultCount: 500 }); if (cancelled) return; const next = new Set(); for (const row of res.items ?? []) { const lid = String(row.locationId ?? "").trim(); if (!allowedLocIdsForScope.has(lid)) continue; const tid = String(row.labelTypeId ?? "").trim(); if (tid) next.add(tid); } setLabelTypeIdsInRegion(next); } catch { if (!cancelled) setLabelTypeIdsInRegion(new Set()); } })(); return () => { cancelled = true; }; }, [regionScopeReady, scopeRegionGroupId, allowedLocIdsForScope]); const partnerOptionsForScope = useMemo(() => { const s = new Set(); for (const l of locations) { const v = (l.partner ?? "").trim(); if (v) s.add(v); } return Array.from(s).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); }, [locations]); const regionGroupOptionsForScope = useMemo(() => { const pn = scopeCompany.trim(); if (!pn) return []; return groups .filter((g) => (g.partnerName ?? "").trim() === pn) .sort((a, b) => (a.groupName ?? "").localeCompare(b.groupName ?? "", undefined, { sensitivity: "base" }), ); }, [groups, scopeCompany]); useEffect(() => { if (!scopeRegionGroupId.trim()) return; if (!regionGroupOptionsForScope.some((g) => g.id === scopeRegionGroupId)) { setScopeRegionGroupId(""); } }, [scopeCompany, regionGroupOptionsForScope, scopeRegionGroupId]); const productCategoriesScoped = useMemo(() => { if (!regionScopeReady) return []; return productCategories.filter((c) => productCategoryMatchesRegion(c, allowedLocIdsForScope)); }, [productCategories, allowedLocIdsForScope, regionScopeReady]); const productsScoped = useMemo(() => { if (!regionScopeReady) return []; return products.filter((p) => { const lids = productLocationMap.get(p.id) ?? p.locationIds ?? []; return lids.some((id) => allowedLocIdsForScope.has(id)); }); }, [products, productLocationMap, allowedLocIdsForScope, regionScopeReady]); const templatesScoped = useMemo(() => { if (!regionScopeReady) return []; return templates.filter((t) => templateAppliesToRegion(t, allowedLocIdsForScope)); }, [templates, allowedLocIdsForScope, regionScopeReady]); const labelCategoriesScoped = useMemo(() => { if (!regionScopeReady) return []; return categories.filter((c) => labelCategoryMatchesRegion(c, allowedLocIdsForScope)); }, [categories, allowedLocIdsForScope, regionScopeReady]); const labelTypesScoped = useMemo(() => { if (!regionScopeReady) return []; if (labelTypeIdsInRegion === null) return types; if (labelTypeIdsInRegion.size === 0) return types; return types.filter((t) => labelTypeIdsInRegion.has(t.id)); }, [types, regionScopeReady, labelTypeIdsInRegion]); const typesLoadingForScope = regionScopeReady && labelTypeIdsInRegion === null; useEffect(() => { if (!open) return; const code = form.templateCode.trim(); if (!code) { setSelectedTemplate(null); setTemplateDataValues({}); setTemplateDateOffsets({}); setNutritionByElementId({}); return; } let cancelled = false; (async () => { setTemplateLoading(true); try { const tpl = await getLabelTemplate(code); if (cancelled) return; setSelectedTemplate(tpl); const els = getCreateLabelTemplateInputElements(tpl); const nextValues: Record = {}; const nextOffsets: Record = {}; for (const el of els) { nextValues[el.id] = ""; if (isDateTimeDataEntryField(el)) { nextOffsets[el.id] = { unit: "Days", value: "" }; } } setTemplateDataValues(nextValues); setTemplateDateOffsets(nextOffsets); const nuts = listNutritionElements((tpl.elements ?? []) as LabelElement[]); const nextNut: Record> = {}; for (const n of nuts) { nextNut[n.id] = nutritionManualValuesFromTemplateConfig(n); } setNutritionByElementId(nextNut); } catch (e: any) { if (cancelled) return; setSelectedTemplate(null); setTemplateDataValues({}); setTemplateDateOffsets({}); setNutritionByElementId({}); toast.error("Failed to load template fields.", { description: e?.message ? String(e.message) : "Please select another template.", }); } finally { if (!cancelled) setTemplateLoading(false); } })(); return () => { cancelled = true; }; }, [open, form.templateCode]); const submit = async () => { if (!regionScopeReady) { toast.error("Validation failed", { description: "Select company and region before other fields.", }); return; } if (typesLoadingForScope) { toast.error("Validation failed", { description: "Loading label types for this region. Please wait.", }); return; } if (!form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { toast.error("Validation failed", { description: "Fill all required fields and select template, location, category, and type.", }); return; } if (!productCatalogCategoryId.trim() || form.productIds.length === 0) { toast.error("Validation failed", { description: "Select a product category and one product.", }); return; } const saveTemplateDataAfterLabel = async () => { const code = form.templateCode.trim(); if (!code) return; if (!selectedTemplate) return; const labelTypeId = form.labelTypeId.trim(); if (!labelTypeId) return; const latest = await getLabelTemplate(code); const dataEls = getDataEntryElements(latest); const hasNutritionRows = listNutritionElements((latest.elements ?? []) as LabelElement[]).length > 0; if (dataEls.length === 0 && !hasNutritionRows) return; const productCodeValue = (() => { const pid = form.productIds[0]?.trim(); if (!pid) return ""; const p = productsScoped.find((x) => x.id === pid); return (p?.codeValue ?? "").trim(); })(); const inputDefaultValues = collectTemplateDefaultValuesForSave( latest, templateDataValues, templateDateOffsets, nutritionByElementId, productCodeValue, ); const defaultsMap = buildTemplateDefaultsMap(latest); for (const productId of form.productIds) { const key = `${productId}::${labelTypeId}`; defaultsMap.set(key, { productId, labelTypeId, defaultValues: { ...inputDefaultValues }, orderNum: defaultsMap.size + 1, }); } const mergedDefaults = Array.from(defaultsMap.values()).map((row, idx) => ({ ...row, orderNum: idx + 1, })); const elements = sortTemplateElementsForDisplay((latest.elements ?? []) as LabelElement[]); await updateLabelTemplate(code, { id: latest.id, name: (latest.name ?? latest.templateName ?? "").trim() || latest.id, labelType: (latest.labelType ?? "PRICE") as any, unit: (latest.unit ?? "inch") as any, width: Number(latest.width ?? 2), height: Number(latest.height ?? 2), appliedLocation: appliedLocationToEditor(latest), showRuler: latest.showRuler ?? true, showGrid: latest.showGrid ?? true, state: latest.state ?? true, elements: labelElementsToApiPayload(elements), appliedLocationIds: appliedLocationToEditor(latest) === "ALL" ? [] : (latest.appliedLocationIds ?? []), templateProductDefaults: mergedDefaults, }); }; setSubmitting(true); try { await createLabel(form); try { await saveTemplateDataAfterLabel(); } catch (e: any) { toast.warning("Label created, template data failed.", { description: e?.message ? String(e.message) : "Please edit template data manually.", }); } toast.success("Label created.", { description: "The label has been created successfully.", }); onOpenChange(false); onCreated(); } catch (e: any) { toast.error("Failed to create label.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; const companySelectOptionsForCreate = useMemo( () => partnerOptionsForScope.map((p) => ({ value: p, label: p })), [partnerOptionsForScope], ); const regionSelectOptionsForCreate = useMemo( () => regionGroupOptionsForScope.map((g) => ({ value: g.id, label: toDisplay(g.groupName), })), [regionGroupOptionsForScope], ); const templateOptions = useMemo(() => { const base = templatesScoped .filter((t) => templateListCode(t)) .map((t) => ({ value: templateListCode(t), label: templateListLabel(t), })); const c = form.templateCode.trim(); if (c && !base.some((o) => o.value === c)) { return [{ value: c, label: `${c} (current)` }, ...base]; } return base; }, [templatesScoped, form.templateCode]); const locationOptions = useMemo( () => locationsInScope.map((loc) => ({ value: loc.id, label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), })), [locationsInScope], ); const categoryOptions = useMemo(() => { const base = labelCategoriesScoped.map((c) => ({ value: c.id, label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), })); const id = form.labelCategoryId.trim(); if (id && !base.some((o) => o.value === id)) { const c0 = categories.find((x) => x.id === id); return [ { value: id, label: c0 ? toDisplay(c0.categoryName ?? c0.categoryCode ?? id) : `${id} (current)`, }, ...base, ]; } return base; }, [labelCategoriesScoped, categories, form.labelCategoryId]); const typeOptions = useMemo(() => { const base = labelTypesScoped.map((ty) => ({ value: ty.id, label: toDisplay(ty.typeName ?? ty.typeCode ?? ty.id), })); const id = form.labelTypeId.trim(); if (id && !base.some((o) => o.value === id)) { const t0 = types.find((x) => x.id === id); return [{ value: id, label: t0 ? toDisplay(t0.typeName ?? t0.typeCode ?? id) : `${id} (current)` }, ...base]; } return base; }, [labelTypesScoped, types, form.labelTypeId]); const dataEntryElements = useMemo( () => getCreateLabelTemplateInputElements(selectedTemplate), [selectedTemplate], ); const nutritionFieldBlocks = useMemo(() => { if (!selectedTemplate) return [] as Array<{ el: LabelElement; spec: NutritionManualFieldSpec }>; const out: Array<{ el: LabelElement; spec: NutritionManualFieldSpec }> = []; for (const nel of listNutritionElements((selectedTemplate.elements ?? []) as LabelElement[])) { for (const spec of listNutritionManualFieldSpecs(nel)) { out.push({ el: nel, spec }); } } return out; }, [selectedTemplate]); const showTemplateInputColumn = dataEntryElements.length > 0 || nutritionFieldBlocks.length > 0; const selectedProductCodeValue = useMemo(() => { const pid = (form.productIds[0] ?? "").trim(); if (!pid) return ""; const p = productsScoped.find((x) => x.id === pid); return (p?.codeValue ?? "").trim(); }, [form.productIds, productsScoped]); /** template_Barcode / template_QR Code:随所选产品 codeValue 同步,且输入框只读 */ useEffect(() => { if (!open || !selectedTemplate) return; const cv = selectedProductCodeValue; setTemplateDataValues((prev) => { let changed = false; const next = { ...prev }; for (const el of (selectedTemplate.elements ?? []) as LabelElement[]) { if (!isTemplateSectionBarcodeOrQrElement(el)) continue; if (next[el.id] === cv) continue; next[el.id] = cv; changed = true; } return changed ? next : prev; }); }, [open, selectedTemplate, selectedProductCodeValue]); const previewTemplate = useMemo( () => buildCreateLabelPreviewTemplate( selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId, selectedProductCodeValue, ), [ selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId, selectedProductCodeValue, ], ); const hasTemplateSelected = form.templateCode.trim().length > 0; return ( Add New Label Enter the details for the new label.
General Settings
{ setScopeCompany(v); setScopeRegionGroupId(""); }} options={companySelectOptionsForCreate} placeholder="Select company" searchPlaceholder="Search company…" emptyText="No companies from locations." disabled={refLoading} />
setForm((p) => ({ ...p, productIds: id.trim() ? [id.trim()] : [] })) } products={productsScoped} productCategories={productCategoriesScoped} disabled={refLoading || !regionScopeReady} />
setForm((p) => ({ ...p, labelName: e.target.value }))} />
setForm((p) => ({ ...p, templateCode: v }))} options={templateOptions} placeholder={ regionScopeReady ? "Select template" : "Select company and region first" } searchPlaceholder="Search template…" emptyText={ regionScopeReady ? "No templates for this region." : "Select company and region." } disabled={refLoading || !regionScopeReady} />
setForm((p) => ({ ...p, locationId: v }))} options={locationOptions} placeholder={ regionScopeReady ? "Select location" : "Select company and region first" } searchPlaceholder="Search location…" emptyText={ regionScopeReady ? "No locations in this region." : "Select company and region." } disabled={refLoading || !regionScopeReady} />
setForm((p) => ({ ...p, labelCategoryId: v }))} options={categoryOptions} placeholder={ regionScopeReady ? "Select category" : "Select company and region first" } searchPlaceholder="Search category…" emptyText={ regionScopeReady ? "No label categories for this region." : "Select company and region." } disabled={refLoading || !regionScopeReady} />
setForm((p) => ({ ...p, labelTypeId: v }))} options={typeOptions} placeholder={ typesLoadingForScope ? "Loading types…" : regionScopeReady ? "Select type" : "Select company and region first" } searchPlaceholder="Search type…" emptyText={ regionScopeReady ? typesLoadingForScope ? "Loading…" : "No types for this region." : "Select company and region." } disabled={refLoading || !regionScopeReady || typesLoadingForScope} />
Enabled
setForm((p) => ({ ...p, state: checked }))} />
{hasTemplateSelected && showTemplateInputColumn ? (
Template Input Data
{templateLoading ? (
Loading template fields...
) : !form.templateCode.trim() ? (
Select template first to load input fields.
) : (
{dataEntryElements.map((el) => { const templateScanLocked = isTemplateSectionBarcodeOrQrElement(el); return (
{templateScanLocked ? ( ) : isDateTimeDataEntryField(el) ? (
setTemplateDateOffsets((prev) => ({ ...prev, [el.id]: { unit: prev[el.id]?.unit ?? "Days", value: e.target.value, }, })) } placeholder="Value" />
) : ( setTemplateDataValues((prev) => ({ ...prev, [el.id]: e.target.value })) } placeholder={`Enter ${dataEntryColumnLabel(el)}`} /> )}
); })} {nutritionFieldBlocks.length > 0 ? (
Nutrition Facts (manual)
{nutritionFieldBlocks.map(({ el: nel, spec }) => (
setNutritionByElementId((prev) => ({ ...prev, [nel.id]: { ...(prev[nel.id] ?? {}), [spec.subKey]: e.target.value, }, })) } placeholder={`Enter ${spec.columnLabel}`} />
))}
) : null}
Date/time fields: preview uses the current time as base; leave empty or enter 0 for "now"; other numbers add that offset. Format follows each field's template setting. On save, values are written for the selected product. Nutrition columns follow the template's nutrient list; values are saved with the template defaults JSON for printing.
)}
) : null} {hasTemplateSelected ? (
Label Preview
{previewTemplate ? (
) : (
Select template to preview.
)}
) : null}
); } function EditLabelDialog({ open, label, onOpenChange, onUpdated, }: { open: boolean; label: LabelDto | null; onOpenChange: (open: boolean) => void; onUpdated: () => void; }) { const { loading: refLoading, templates, locations, groups, categories, types, products, productCategories, productLocationMap, } = useLabelFormReferenceData(open); const [productCatalogCategoryId, setProductCatalogCategoryId] = useState(""); const [labelTypeIdsInEditRegion, setLabelTypeIdsInEditRegion] = useState | null>(null); const [submitting, setSubmitting] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [form, setForm] = useState({ labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); useEffect(() => { if (!open || !label?.id) return; const id = label.id; setForm(labelDtoToUpdateForm(label)); const ac = new AbortController(); let cancelled = false; setDetailLoading(true); (async () => { try { const detail = await getLabel(id, ac.signal); if (cancelled) return; setForm(labelDtoToUpdateForm(detail)); } catch (e: any) { if (cancelled || e?.name === "AbortError") return; toast.error("Failed to load label details.", { description: e?.message ? String(e.message) : "Form shows list data only; check network.", }); } finally { if (!cancelled) setDetailLoading(false); } })(); return () => { cancelled = true; ac.abort(); }; }, [open, label]); useEffect(() => { if (!open) { setProductCatalogCategoryId(""); return; } const pid = (form.productIds[0] ?? "").trim(); if (!pid) { setProductCatalogCategoryId(""); return; } const local = products.find((x) => x.id === pid); if (local?.categoryId) { setProductCatalogCategoryId(String(local.categoryId).trim()); return; } let cancelled = false; (async () => { try { const dto = await getProduct(pid); if (cancelled) return; setProductCatalogCategoryId((dto.categoryId ?? "").trim()); } catch { if (!cancelled) setProductCatalogCategoryId(""); } })(); return () => { cancelled = true; }; }, [open, form.productIds, products]); const editRegionGroupId = useMemo(() => { const lid = (form.locationId ?? "").trim(); if (!lid) return ""; const loc = locations.find((x) => x.id === lid); if (!loc) return ""; const pn = (loc.partner ?? "").trim(); const gn = (loc.groupName ?? "").trim(); const g = groups.find( (x) => (x.partnerName ?? "").trim() === pn && (x.groupName ?? "").trim() === gn, ); return g?.id ?? ""; }, [form.locationId, locations, groups]); const editScopeReady = !!editRegionGroupId; const locationsInEditScope = useMemo( () => locationsForRegionGroup(locations, groups, editRegionGroupId), [locations, groups, editRegionGroupId], ); const allowedLocIdsEdit = useMemo( () => new Set(locationsInEditScope.map((l) => l.id).filter(Boolean)), [locationsInEditScope], ); useEffect(() => { if (!open || !editScopeReady) { setLabelTypeIdsInEditRegion(null); return; } let cancelled = false; (async () => { try { const res = await getLabels({ skipCount: 1, maxResultCount: 500 }); if (cancelled) return; const next = new Set(); for (const row of res.items ?? []) { const lid = String(row.locationId ?? "").trim(); if (!allowedLocIdsEdit.has(lid)) continue; const tid = String(row.labelTypeId ?? "").trim(); if (tid) next.add(tid); } setLabelTypeIdsInEditRegion(next); } catch { if (!cancelled) setLabelTypeIdsInEditRegion(new Set()); } })(); return () => { cancelled = true; }; }, [open, editScopeReady, editRegionGroupId, allowedLocIdsEdit]); const productCategoriesScopedEdit = useMemo(() => { if (!editScopeReady) return productCategories; return productCategories.filter((c) => productCategoryMatchesRegion(c, allowedLocIdsEdit)); }, [productCategories, allowedLocIdsEdit, editScopeReady]); const productsScopedEdit = useMemo(() => { if (!editScopeReady) return products; return products.filter((p) => { const lids = productLocationMap.get(p.id) ?? p.locationIds ?? []; return lids.some((id) => allowedLocIdsEdit.has(id)); }); }, [products, productLocationMap, allowedLocIdsEdit, editScopeReady]); const templatesScopedEdit = useMemo(() => { if (!editScopeReady) return templates; return templates.filter((t) => templateAppliesToRegion(t, allowedLocIdsEdit)); }, [templates, allowedLocIdsEdit, editScopeReady]); const labelCategoriesScopedEdit = useMemo(() => { if (!editScopeReady) return categories; return categories.filter((c) => labelCategoryMatchesRegion(c, allowedLocIdsEdit)); }, [categories, allowedLocIdsEdit, editScopeReady]); const labelTypesScopedEdit = useMemo(() => { if (!editScopeReady) return types; if (labelTypeIdsInEditRegion === null) return types; if (labelTypeIdsInEditRegion.size === 0) return types; return types.filter((t) => labelTypeIdsInEditRegion.has(t.id)); }, [types, editScopeReady, labelTypeIdsInEditRegion]); const typesLoadingEditScope = editScopeReady && labelTypeIdsInEditRegion === null; const editScopeLocation = useMemo(() => { const lid = (form.locationId ?? "").trim(); if (!lid) return null; return locations.find((x) => x.id === lid) ?? null; }, [form.locationId, locations]); const submit = async () => { if (!label?.id) return; if (editScopeReady && typesLoadingEditScope) { toast.error("Validation failed", { description: "Loading label types for this region. Please wait.", }); return; } if (!form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { toast.error("Validation failed", { description: "Fill all required fields and select template, location, category, and type.", }); return; } if (!productCatalogCategoryId.trim() || form.productIds.length === 0) { toast.error("Validation failed", { description: "Select a product category and one product.", }); return; } setSubmitting(true); try { await updateLabel(label.id, form); toast.success("Label updated.", { description: "The label has been updated successfully.", }); onOpenChange(false); onUpdated(); } catch (e: any) { toast.error("Failed to update label.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; const editTemplateOptions = useMemo(() => { const base = templatesScopedEdit .filter((t) => templateListCode(t)) .map((t) => ({ value: templateListCode(t), label: templateListLabel(t), })); const c = form.templateCode; if (c && !base.some((o) => o.value === c)) { return [{ value: c, label: `${c} (current)` }, ...base]; } return base; }, [templatesScopedEdit, form.templateCode]); const editLocationOptions = useMemo(() => { const src = editScopeReady ? locationsInEditScope : locations; const base = src.map((loc) => ({ value: loc.id, label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), })); const id = form.locationId; if (id && !base.some((o) => o.value === id)) { return [{ value: id, label: `${id} (current)` }, ...base]; } return base; }, [editScopeReady, locationsInEditScope, locations, form.locationId]); const editCategoryOptions = useMemo(() => { const base = labelCategoriesScopedEdit.map((c) => ({ value: c.id, label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), })); const id = form.labelCategoryId; if (id && !base.some((o) => o.value === id)) { return [{ value: id, label: `${id} (current)` }, ...base]; } return base; }, [labelCategoriesScopedEdit, form.labelCategoryId]); const editTypeOptions = useMemo(() => { const base = labelTypesScopedEdit.map((ty) => ({ value: ty.id, label: toDisplay(ty.typeName ?? ty.typeCode ?? ty.id), })); const id = form.labelTypeId; if (id && !base.some((o) => o.value === id)) { return [{ value: id, label: `${id} (current)` }, ...base]; } return base; }, [labelTypesScopedEdit, form.labelTypeId]); return ( Edit Label {detailLoading ? "Loading label details…" : "Update the label details."}
setForm((p) => ({ ...p, labelName: e.target.value }))} disabled={detailLoading} />
setForm((p) => ({ ...p, templateCode: v }))} options={editTemplateOptions} placeholder="Select template" searchPlaceholder="Search template…" emptyText={editScopeReady ? "No templates for this region." : "No templates found."} disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, locationId: v }))} options={editLocationOptions} placeholder="Select location" searchPlaceholder="Search location…" emptyText={ editScopeReady ? "No locations in this region." : "No locations found." } disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, labelCategoryId: v }))} options={editCategoryOptions} placeholder="Select category" searchPlaceholder="Search category…" emptyText={ editScopeReady ? "No label categories for this region." : "No categories found." } disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, labelTypeId: v }))} options={editTypeOptions} placeholder={typesLoadingEditScope ? "Loading types…" : "Select type"} searchPlaceholder="Search type…" emptyText={ editScopeReady ? typesLoadingEditScope ? "Loading…" : "No types for this region." : "No types found." } disabled={refLoading || detailLoading || typesLoadingEditScope} />
setForm((p) => ({ ...p, productIds: id.trim() ? [id.trim()] : [] })) } products={productsScopedEdit} productCategories={productCategoriesScopedEdit} disabled={refLoading || detailLoading} />
Enabled
setForm((p) => ({ ...p, state: checked }))} disabled={detailLoading} />
); } function DeleteLabelDialog({ open, label, onOpenChange, onDeleted, }: { open: boolean; label: LabelDto | null; onOpenChange: (open: boolean) => void; onDeleted: () => void; }) { const [submitting, setSubmitting] = useState(false); const name = useMemo(() => { const n = (label?.labelName ?? "").trim(); return n || label?.labelCode || label?.id || "this label"; }, [label]); const submit = async () => { if (!label?.id) return; setSubmitting(true); try { await deleteLabel(label.id); toast.success("Label deleted.", { description: "The label has been removed successfully.", }); onOpenChange(false); onDeleted(); } catch (e: any) { toast.error("Failed to delete label.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Delete Label This action cannot be undone.
Are you sure you want to delete {name}?
); }