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 { preventDialogDismissOnOverlay } from "../../lib/formDialogLayout"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { SearchableSelect } from "../ui/searchable-select"; import { SearchableMultiSelect } from "../ui/searchable-multi-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 { getPartners } from "../../services/partnerService"; 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 { PartnerListItem } from "../../types/partner"; 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 { NutritionManualEntryForm } from "./NutritionManualEntryForm"; import { LabelPreviewOnly } from "./LabelTemplateEditor/LabelCanvas"; import { appliedLocationToEditor, buildTemplateContentsFromElements, canonicalElementType, dataEntryColumnLabel, isDataEntryTableColumnElement, isDateTimeDataEntryField, labelElementsToApiPayload, normalizeTemplateBorder, sortTemplateElementsForDisplay, } from "../../types/labelTemplate"; import { applyLiveDateTimePreviewToElements, LABEL_FORM_OFFSET_UNITS, normalizeLabelFormOffsetInput, offsetFieldUiStateFromStored, serializePrintInputOffset, } from "../../lib/labelFormDatePreview"; import { listNutritionElements, listNutritionManualFieldSpecs, mergeNutritionManualIntoConfig, nutritionDefaultValuesJsonForSave, nutritionManualValuesFromTemplateConfig, parseNutritionManualFromDefaults, type NutritionManualFieldSpec, } from "../../lib/nutritionManualEntry"; import { applyProductCodeValueToLabelElements, buildTemplateBarcodeQrDefaultsFromCodeValue, buildTemplateLabelBindingDefaults, isTemplateLabelNameElement, isTemplateLabelTypeElement, isTemplateSectionBarcodeOrQrElement, } from "../../lib/productCodeValueTemplate"; import { useCategoryScopeAuth } from "../../hooks/useCategoryScopeAuth"; import { effectiveScopePartnerId, hydrateCategoryScopeFromLocationIds, locationsScopedForCategoryForm, regionNamesToGroupIds, regionOptionsForPartner, scopePartnerValidationMessage, buildSpecifiedLocationPayload, } from "../../lib/categoryScopeForm"; function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); return s ? s : "None"; } /** 列表行:适用 Region 展示 */ function labelRowRegionText(item: LabelDto, groups: GroupListItem[]): string { const display = (item.region ?? "").trim(); if (display) return display; const at = String(item.appliedRegionType ?? "").trim().toUpperCase(); if (at === "ALL") return "All Regions"; const ids = [ ...new Set( [...(item.regionIds ?? []), ...(item.groupIds ?? [])] .map((x) => String(x).trim()) .filter(Boolean), ), ]; if (ids.length) { const names = ids .map((id) => (groups.find((g) => g.id === id)?.groupName ?? "").trim()) .filter(Boolean); if (names.length) return [...new Set(names)].join(", "); } return "None"; } function hydrateLabelRegionScopeFromDto( dto: Pick, groups: GroupListItem[], partners: PartnerListItem[], locations: LocationDto[], ): { partnerId: string; regionNames: string[] } { const mode = String(dto.appliedRegionType ?? "ALL").trim().toUpperCase(); const groupIds = [ ...new Set( [...(dto.regionIds ?? []), ...(dto.groupIds ?? [])] .map((x) => String(x).trim()) .filter(Boolean), ), ]; let partnerId = ""; if (groupIds.length) { partnerId = (groups.find((g) => groupIds.includes(g.id))?.partnerId ?? "").trim(); } if (!partnerId && (dto.locationId ?? "").trim()) { partnerId = hydrateCategoryScopeFromLocationIds( [String(dto.locationId).trim()], locations, partners, groups, ).partnerId; } if (mode === "ALL" && partnerId) { return { partnerId, regionNames: regionOptionsForPartner(groups, partnerId).map((o) => o.value), }; } const regionNames = groupIds.filter(Boolean); return { partnerId, regionNames: [...new Set(regionNames)] }; } function buildLabelRegionScopePayload( selectedRegionNames: string[], groups: GroupListItem[], partnerId: string, ): { appliedRegionType: "ALL" | "SPECIFIED"; regionIds: string[] } { const pid = partnerId.trim(); const selected = selectedRegionNames.map((x) => x.trim()).filter(Boolean); if (!selected.length) { return { appliedRegionType: "SPECIFIED", regionIds: [] }; } const allNames = regionOptionsForPartner(groups, pid).map((o) => o.value); const isAll = allNames.length > 0 && allNames.length === selected.length && allNames.every((n) => selected.includes(n)); if (isAll) { return { appliedRegionType: "ALL", regionIds: [] }; } const regionIds = regionNamesToGroupIds(selected, groups, pid); return { appliedRegionType: "SPECIFIED", regionIds }; } function mergeUniqueById(lists: T[][]): T[] { const map = new Map(); for (const list of lists) { for (const item of list) { const id = (item.id ?? "").trim(); if (id) map.set(id, item); } } return [...map.values()]; } /** 列表行:产品列(接口可能返回 `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 locationsForRegionGroups( locations: LocationDto[], groups: GroupListItem[], regionGroupIds: string[], ): LocationDto[] { const seen = new Set(); const out: LocationDto[] = []; for (const gid of regionGroupIds.map((x) => x.trim()).filter(Boolean)) { for (const loc of locationsForRegionGroup(locations, groups, gid)) { if (!seen.has(loc.id)) { seen.add(loc.id); out.push(loc); } } } return out; } 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 ?? "", locationIds: (() => { const fromList = (d.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean); if (fromList.length) return [...new Set(fromList)]; const single = (d.locationId ?? "").trim(); return single ? [single] : []; })(), labelCategoryId: d.labelCategoryId ?? "", labelTypeId: d.labelTypeId ?? "", /** 编辑表单仅支持单商品:多商品时取第一个 */ productIds: arr.length ? [arr[0]] : [], labelInfoJson: d.labelInfoJson ?? null, state: d.state ?? true, appliedRegionType: d.appliedRegionType ?? undefined, regionIds: d.regionIds ?? undefined, groupIds: d.groupIds ?? undefined, }; } function getDataEntryElements(template: LabelTemplateDto | null): LabelElement[] { if (!template) return []; return sortTemplateElementsForDisplay((template.elements ?? []) as LabelElement[]).filter( isDataEntryTableColumnElement, ); } /** 新建/编辑标签弹窗「Template Input Data」:Label 可编辑列 + Template 条码/二维码只读列(不含 Label Name,由左侧 General Settings 统一录入) */ 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 (isTemplateLabelNameElement(el)) continue; 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) { const cfg = { ...(el.config as Record) }; delete cfg.__previewFormatted; const id = el.id; const type = canonicalElementType(el.type); if (!isDataEntryTableColumnElement(el)) { el.config = cfg; continue; } 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.text = ""; } else if (norm.kind === "zero") { cfg.text = serializePrintInputOffset(unit, "0"); } else { cfg.text = serializePrintInputOffset(unit, norm.storeValue); } } 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; } tmpl.elements = applyLiveDateTimePreviewToElements(tmpl.elements, now); 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, labelBindings?: { labelName?: string | null; labelTypeName?: 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, ), ); Object.assign( out, buildTemplateLabelBindingDefaults((latest.elements ?? []) as LabelElement[], labelBindings ?? {}), ); return out; } /** 编辑标签:从模板 templateProductDefaults 恢复当前产品+类型的录入与预览状态 */ function hydrateLabelFormInputFromProductDefaults( template: LabelTemplateDto, productId: string, labelTypeId: string, ): { textValues: Record; dateOffsets: Record; nutritionByElementId: Record>; } { const pid = productId.trim(); const ltid = labelTypeId.trim(); const key = `${pid}::${ltid}`; const row = (template.templateProductDefaults ?? []).find( (r) => `${String(r.productId ?? "").trim()}::${String(r.labelTypeId ?? "").trim()}` === key, ); const stored = row?.defaultValues ?? {}; const elements = getCreateLabelTemplateInputElements(template); const textValues: Record = {}; const dateOffsets: Record = {}; const nutritionByElementId: Record> = {}; for (const el of elements) { if (isDateTimeDataEntryField(el)) { dateOffsets[el.id] = offsetFieldUiStateFromStored(String(stored[el.id] ?? "")); textValues[el.id] = ""; } else { textValues[el.id] = String(stored[el.id] ?? ""); } } for (const nel of listNutritionElements((template.elements ?? []) as LabelElement[])) { const raw = stored[nel.id]; if (typeof raw === "string" && raw.trim().startsWith("{")) { nutritionByElementId[nel.id] = parseNutritionManualFromDefaults(raw); } else { nutritionByElementId[nel.id] = nutritionManualValuesFromTemplateConfig(nel); } } return { textValues, dateOffsets, nutritionByElementId }; } function mergeLabelBindingsIntoTextValues( textValues: Record, elements: LabelElement[], bindings: { labelName?: string | null; labelTypeName?: string | null }, options?: { onlyWhenEmpty?: boolean; storedValues?: Record }, ): Record { const out = { ...textValues }; const binds = buildTemplateLabelBindingDefaults(elements, bindings); for (const el of elements) { if (!isTemplateLabelNameElement(el) && !isTemplateLabelTypeElement(el)) continue; const bindVal = binds[el.id]; if (!bindVal) continue; if (options?.onlyWhenEmpty) { const stored = String(options.storedValues?.[el.id] ?? out[el.id] ?? "").trim(); if (stored) { out[el.id] = stored; continue; } } out[el.id] = bindVal; } return out; } function clearTemplateLabelNameFields( textValues: Record, elements: LabelElement[], ): Record { const out = { ...textValues }; for (const el of elements) { if (isTemplateLabelNameElement(el)) out[el.id] = ""; } return out; } function resolveLabelFormTemplateTextValues( template: LabelTemplateDto, productId: string, labelTypeId: string, labelTypeName: string, labelName?: string, ): Record { const empty = emptyLabelFormTemplateInputState(template); const pid = productId.trim(); const ltid = labelTypeId.trim(); const elements = (template.elements ?? []) as LabelElement[]; const trimmedLabelName = (labelName ?? "").trim(); if (pid && ltid) { const hydrated = hydrateLabelFormInputFromProductDefaults(template, pid, ltid); let textValues = { ...empty.textValues, ...hydrated.textValues }; textValues = clearTemplateLabelNameFields(textValues, elements); textValues = mergeLabelBindingsIntoTextValues( textValues, elements, { labelTypeName }, { onlyWhenEmpty: true, storedValues: hydrated.textValues }, ); if (trimmedLabelName) { textValues = mergeLabelBindingsIntoTextValues(textValues, elements, { labelName: trimmedLabelName, }); } return textValues; } let textValues = { ...empty.textValues }; const bindings: { labelName?: string; labelTypeName?: string } = {}; if (trimmedLabelName) bindings.labelName = trimmedLabelName; if (ltid && labelTypeName) bindings.labelTypeName = labelTypeName; if (Object.keys(bindings).length) { textValues = mergeLabelBindingsIntoTextValues(textValues, elements, bindings); } return textValues; } function emptyLabelFormTemplateInputState(template: LabelTemplateDto | null): { textValues: Record; dateOffsets: Record; nutritionByElementId: Record>; } { if (!template) { return { textValues: {}, dateOffsets: {}, nutritionByElementId: {} }; } const textValues: Record = {}; const dateOffsets: Record = {}; for (const el of getCreateLabelTemplateInputElements(template)) { textValues[el.id] = ""; if (isDateTimeDataEntryField(el)) { dateOffsets[el.id] = { unit: "Days", value: "" }; } } const nutritionByElementId: Record> = {}; for (const n of listNutritionElements((template.elements ?? []) as LabelElement[])) { nutritionByElementId[n.id] = nutritionManualValuesFromTemplateConfig(n); } return { textValues, dateOffsets, nutritionByElementId }; } function templateListCode(t: LabelTemplateDto): string { return (t.templateCode ?? t.id ?? "").trim(); } function templateListLabel(t: LabelTemplateDto): string { return (t.templateName ?? t.name ?? "").trim() || "None"; } 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, border: normalizeTemplateBorder(apiTemplate.border), 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; } /** 新建/编辑标签:先选 Company + 适用 Region 后再拉取下拉数据 */ export type LabelFormReferenceScopeIds = { partnerId: string; /** Region 展示名(`fl_group.GroupName`),至少一项 */ regionNames: string[]; }; function clearLabelFormScopedOptions(setters: { setTemplates: (v: LabelTemplateDto[]) => void; setLocations: (v: LocationDto[]) => void; setCategories: (v: LabelCategoryDto[]) => void; setTypes: (v: LabelTypeDto[]) => void; setProducts: (v: ProductDto[]) => void; setProductCategories: (v: ProductCategoryDto[]) => void; setProductLocationMap: (v: Map) => void; }) { setters.setTemplates([]); setters.setLocations([]); setters.setCategories([]); setters.setTypes([]); setters.setProducts([]); setters.setProductCategories([]); setters.setProductLocationMap(new Map()); } /** * @param scopeIds `null`:一次性加载全量;有 partnerId + groupIds 时按所选 Region 并集请求各下拉接口。 */ function useLabelFormReferenceData(open: boolean, scopeIds: LabelFormReferenceScopeIds | null) { const [bootstrapLoading, setBootstrapLoading] = useState(false); const [scopedLoading, setScopedLoading] = useState(false); const [templates, setTemplates] = useState([]); const [locations, setLocations] = useState([]); const [groups, setGroups] = useState([]); const [partners, setPartners] = useState([]); const [categories, setCategories] = useState([]); const [types, setTypes] = useState([]); const [products, setProducts] = useState([]); const [productCategories, setProductCategories] = useState([]); const [productLocationMap, setProductLocationMap] = useState>(() => new Map()); const scopeReady = !!(scopeIds?.partnerId?.trim() && (scopeIds?.regionNames?.length ?? 0) > 0); const loadAllOnOpen = scopeIds === null; useEffect(() => { if (!open) return; let cancelled = false; (async () => { setBootstrapLoading(true); try { const [grpRes, partnerRes] = await Promise.all([ getGroups({ skipCount: 1, maxResultCount: 500 }), getPartners({ skipCount: 1, maxResultCount: 500 }), ]); if (cancelled) return; setGroups(grpRes.items ?? []); setPartners(partnerRes.items ?? []); } catch (e: unknown) { if (!cancelled) { toast.error("Failed to load company / region options", { description: e instanceof Error ? e.message : "Check network or sign-in.", }); setGroups([]); setPartners([]); } } finally { if (!cancelled) setBootstrapLoading(false); } })(); return () => { cancelled = true; }; }, [open]); useEffect(() => { if (!open) return; const scopedSetters = { setTemplates, setLocations, setCategories, setTypes, setProducts, setProductCategories, setProductLocationMap, }; if (!loadAllOnOpen && !scopeReady) { clearLabelFormScopedOptions(scopedSetters); return; } let cancelled = false; (async () => { setScopedLoading(true); try { if (loadAllOnOpen) { const [tplRes, locRes, catRes, typeRes, prodRes, menuCatRes, plRes] = await Promise.all([ getLabelTemplates({ skipCount: 1, maxResultCount: 500 }), getLocations({ 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 ?? []); setCategories(catRes.items ?? []); setTypes(typeRes.items ?? []); setProducts(prodRes.items ?? []); setProductCategories(menuCatRes.items ?? []); const rawMap = buildProductLocationMap(plRes.items ?? []); setProductLocationMap(mergeProductLocationOverlay(rawMap, prodRes.items ?? [])); return; } const partnerId = scopeIds!.partnerId.trim(); const regionNames = scopeIds!.regionNames.map((x) => x.trim()).filter(Boolean); const groupIds = regionNamesToGroupIds(regionNames, groups, partnerId); const p = partners.find((x) => (x.id ?? "").trim() === partnerId); const partnerName = (p?.partnerName ?? "").trim(); if (!partnerName || !regionNames.length || !groupIds.length) { clearLabelFormScopedOptions(scopedSetters); return; } const [tplRes, locRes, plRes] = await Promise.all([ getLabelTemplates({ skipCount: 1, maxResultCount: 500 }), getLocations({ skipCount: 1, maxResultCount: 500, partner: partnerName }), getProductLocations({ skipCount: 1, maxResultCount: 2000 }), ]); const locAll = locRes.items ?? []; const locItems = locationsScopedForCategoryForm( locAll, partners, partnerId, regionNames, groups, ); const allowedLocIds = new Set(locItems.map((l) => l.id).filter(Boolean)); const [catLists, typeLists, prodLists, menuCatLists] = await Promise.all([ Promise.all( groupIds.map((gid) => getLabelCategories({ skipCount: 1, maxResultCount: 500, groupId: gid }), ), ), Promise.all( groupIds.map((gid) => getLabelTypes({ skipCount: 1, maxResultCount: 500, groupId: gid })), ), Promise.all( groupIds.map((gid) => getProducts({ skipCount: 1, maxResultCount: 500, partnerId, groupId: gid }), ), ), Promise.all( groupIds.map((gid) => getProductCategories({ skipCount: 1, maxResultCount: 500, sorting: "OrderNum desc", groupId: gid, }), ), ), ]); if (cancelled) return; const tplItems = (tplRes.items ?? []).filter((t) => templateAppliesToRegion(t, allowedLocIds), ); setLocations(locItems); setTemplates(tplItems); setCategories(mergeUniqueById(catLists.map((r) => r.items ?? []))); setTypes(mergeUniqueById(typeLists.map((r) => r.items ?? []))); const prodItems = mergeUniqueById(prodLists.map((r) => r.items ?? [])); setProducts(prodItems); setProductCategories(mergeUniqueById(menuCatLists.map((r) => r.items ?? []))); const rawMap = buildProductLocationMap(plRes.items ?? []); setProductLocationMap(mergeProductLocationOverlay(rawMap, prodItems)); } catch (e: unknown) { if (!cancelled) { toast.error("Failed to load options", { description: e instanceof Error ? e.message : "Check network or sign-in.", }); clearLabelFormScopedOptions(scopedSetters); } } finally { if (!cancelled) setScopedLoading(false); } })(); return () => { cancelled = true; }; }, [open, loadAllOnOpen, scopeReady, scopeIds?.partnerId, scopeIds?.regionNames?.join("\u0001"), groups, partners]); return { loading: bootstrapLoading || scopedLoading, bootstrapLoading, scopedLoading, templates, locations, groups, partners, 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} />
); } 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 scopeAuth = useCategoryScopeAuth(); const [keyword, setKeyword] = useState(""); const [companyFilter, setCompanyFilter] = useState("all"); 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 [filterPartners, setFilterPartners] = useState([]); const [filterGroups, setFilterGroups] = useState([]); const [filterLabelCategories, setFilterLabelCategories] = useState([]); const [filterLabelTypes, setFilterLabelTypes] = useState([]); useEffect(() => { let cancelled = false; (async () => { try { const [locRes, grpRes, catRes, typeRes, partnerRes] = await Promise.all([ getLocations({ skipCount: 1, maxResultCount: 500 }), getGroups({ skipCount: 1, maxResultCount: 500 }), getLabelCategories({ skipCount: 1, maxResultCount: 500 }), getLabelTypes({ skipCount: 1, maxResultCount: 500 }), getPartners({ skipCount: 1, maxResultCount: 500, state: true }), ]); if (cancelled) return; setFilterLocations(locRes.items ?? []); setFilterPartners(partnerRes.items ?? []); setFilterGroups(grpRes.items ?? []); setFilterLabelCategories(catRes.items ?? []); setFilterLabelTypes(typeRes.items ?? []); } catch (e: unknown) { if (!cancelled) { setFilterLocations([]); setFilterPartners([]); setFilterGroups([]); setFilterLabelCategories([]); setFilterLabelTypes([]); toast.error("Failed to load filters.", { description: e instanceof Error ? e.message : "Please try again.", }); } } })(); return () => { cancelled = true; }; }, []); const companySelectOptions = useMemo( () => [...filterPartners] .filter((p) => (p.partnerName ?? "").trim()) .sort((a, b) => (a.partnerName ?? "").localeCompare(b.partnerName ?? "", undefined, { sensitivity: "base" })), [filterPartners], ); const regionSelectOptions = useMemo(() => { let list = filterGroups; if (companyFilter !== "all") { list = list.filter((g) => g.partnerId === companyFilter); } const m = new Map(); for (const g of list) { 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, companyFilter]); const selectedToolbarPartner = useMemo( () => (companyFilter === "all" ? null : filterPartners.find((p) => p.id === companyFilter) ?? null), [companyFilter, filterPartners], ); const selectedToolbarGroup = useMemo( () => (regionFilter === "all" ? null : filterGroups.find((g) => g.id === regionFilter) ?? null), [regionFilter, filterGroups], ); const locationsForToolbarFilter = useMemo(() => { return filterLocations.filter((loc) => { if (selectedToolbarPartner) { const pn = (selectedToolbarPartner.partnerName ?? "").trim(); if (pn && (loc.partner ?? "").trim() !== pn) return false; } if (selectedToolbarGroup) { const gn = (selectedToolbarGroup.groupName ?? "").trim(); if (gn && (loc.groupName ?? "").trim() !== gn) return false; } return true; }); }, [filterLocations, selectedToolbarPartner, selectedToolbarGroup]); const labelScopeQuery = useMemo( () => ({ partnerId: companyFilter !== "all" ? companyFilter : undefined, groupId: regionFilter !== "all" ? regionFilter : undefined, locationId: locationFilter !== "all" ? locationFilter : undefined, }), [companyFilter, regionFilter, locationFilter], ); useEffect(() => { setRegionFilter("all"); setLocationFilter("all"); }, [companyFilter]); useEffect(() => { setLocationFilter("all"); }, [regionFilter]); useEffect(() => { if (companyFilter === "all") return; if (!companySelectOptions.some((p) => p.id === companyFilter)) setCompanyFilter("all"); }, [companyFilter, companySelectOptions]); useEffect(() => { if (regionFilter === "all") return; if (!regionSelectOptions.some((g) => g.id === regionFilter)) setRegionFilter("all"); }, [companyFilter, regionSelectOptions, 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, companyFilter, 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 skipCount = skipCountForPage(pageIndex); const res = await getLabels( { skipCount, maxResultCount: pageSize, keyword: debouncedKeyword || undefined, labelCategoryId: labelCategoryFilter !== "all" ? labelCategoryFilter : undefined, labelTypeId: labelTypeFilter !== "all" ? labelTypeFilter : undefined, templateCode: templateFilter !== "all" ? templateFilter : undefined, state: stateFilter === "all" ? undefined : stateFilter === "true", ...labelScopeQuery, }, 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, labelScopeQuery, 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" /> {scopeAuth.requireCompanySelection ? ( ) : null}
Label Name Product Region 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)} {labelRowRegionText(item, filterGroups)} {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 scopeAuth = useCategoryScopeAuth(); const [scopePartnerId, setScopePartnerId] = useState(""); const [selectedRegionNames, setSelectedRegionNames] = useState([]); const prevRegionNamesForResetRef = useRef(null); const prevPartnerForResetRef = useRef(null); const effectivePartnerId = useMemo( () => effectiveScopePartnerId( scopeAuth.requireCompanySelection, scopePartnerId, scopeAuth.fixedPartnerId, ), [scopeAuth.requireCompanySelection, scopePartnerId, scopeAuth.fixedPartnerId], ); useEffect(() => { if (!open || scopeAuth.requireCompanySelection) return; const pid = scopeAuth.fixedPartnerId.trim(); if (!pid || scopePartnerId === pid) return; setScopePartnerId(pid); }, [open, scopeAuth.requireCompanySelection, scopeAuth.fixedPartnerId, scopePartnerId]); const formReferenceScopeIds = useMemo((): LabelFormReferenceScopeIds | null => { const partnerId = effectivePartnerId; const names = selectedRegionNames.map((x) => x.trim()).filter(Boolean); if (!partnerId || !names.length) return null; return { partnerId, regionNames: names }; }, [effectivePartnerId, selectedRegionNames]); const { loading: refLoading, scopedLoading, templates, locations, groups, partners, categories, types, products, productCategories, productLocationMap, } = useLabelFormReferenceData(open, formReferenceScopeIds); 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: "", locationIds: [], labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); const resetForm = () => { setForm({ labelName: "", templateCode: "", locationIds: [], labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); setSelectedTemplate(null); setTemplateDataValues({}); setTemplateDateOffsets({}); setNutritionByElementId({}); setProductCatalogCategoryId(""); setScopePartnerId(""); setSelectedRegionNames([]); prevRegionNamesForResetRef.current = null; prevPartnerForResetRef.current = null; }; useEffect(() => { if (!open) { resetForm(); } }, [open]); useEffect(() => { if (!effectivePartnerId) { setSelectedRegionNames([]); } }, [effectivePartnerId]); const createScopeGroupIds = useMemo( () => regionNamesToGroupIds(selectedRegionNames, groups, effectivePartnerId), [selectedRegionNames, groups, effectivePartnerId], ); const regionScopeReady = !!( effectivePartnerId && selectedRegionNames.length > 0 && createScopeGroupIds.length > 0 ); const scopeBootstrapLoading = refLoading || (!scopeAuth.requireCompanySelection && scopeAuth.loadingPartner); const scopeGateHint = scopeAuth.requireCompanySelection ? "Select company and applicable region(s) first" : "Select applicable region(s) first"; const scopeGateEmpty = scopeAuth.requireCompanySelection ? "Select company and applicable region(s)." : "Select applicable region(s)."; const locationsInScope = useMemo(() => { if (!regionScopeReady) return []; return locationsForRegionGroups(locations, groups, createScopeGroupIds); }, [locations, groups, createScopeGroupIds, regionScopeReady]); useEffect(() => { if (!open) return; const cur = selectedRegionNames.join("\u0001"); const prev = prevRegionNamesForResetRef.current; prevRegionNamesForResetRef.current = cur; if (prev === null) return; if (prev === cur) return; setProductCatalogCategoryId(""); setForm((p) => ({ ...p, templateCode: "", locationIds: [], labelCategoryId: "", labelTypeId: "", productIds: [], })); }, [open, selectedRegionNames]); useEffect(() => { if (!open) return; const cur = scopePartnerId.trim(); const prev = prevPartnerForResetRef.current; prevPartnerForResetRef.current = cur; if (prev === null) return; if (prev === cur) return; setProductCatalogCategoryId(""); setForm((p) => ({ ...p, templateCode: "", locationIds: [], labelCategoryId: "", labelTypeId: "", productIds: [], })); }, [open, scopePartnerId]); useEffect(() => { if (!regionScopeReady) return; const allowed = new Set(locationsInScope.map((l) => l.id)); setForm((p) => { const next = (p.locationIds ?? []).filter((id) => allowed.has(id)); if (next.length === (p.locationIds ?? []).length) return p; return { ...p, locationIds: next }; }); }, [locationsInScope, regionScopeReady]); const companySelectOptionsForCreate = useMemo( () => [...partners] .filter((p) => (p.id ?? "").trim()) .sort((a, b) => (a.partnerName ?? "").localeCompare(b.partnerName ?? "", undefined, { sensitivity: "base" }), ) .map((p) => ({ value: (p.id ?? "").trim(), label: toDisplay(p.partnerName), })), [partners], ); const regionOptionsForCreate = useMemo( () => regionOptionsForPartner(groups, effectivePartnerId), [groups, effectivePartnerId], ); useEffect(() => { if (!effectivePartnerId || !selectedRegionNames.length) return; const allowed = new Set(regionOptionsForCreate.map((o) => o.value)); const next = selectedRegionNames.filter((n) => allowed.has(n)); if (next.length !== selectedRegionNames.length) { setSelectedRegionNames(next); } }, [effectivePartnerId, regionOptionsForCreate, selectedRegionNames]); const productCategoriesScoped = useMemo(() => { if (!regionScopeReady) return []; return productCategories; }, [productCategories, regionScopeReady]); const productsScoped = useMemo(() => { if (!regionScopeReady) return []; return products; }, [products, regionScopeReady]); const templatesScoped = useMemo(() => { if (!regionScopeReady) return []; return templates; }, [templates, regionScopeReady]); const labelCategoriesScoped = useMemo(() => { if (!regionScopeReady) return []; return categories; }, [categories, regionScopeReady]); const labelTypesScoped = useMemo(() => { if (!regionScopeReady) return []; return types; }, [types, regionScopeReady]); const scopedOptionsLoading = regionScopeReady && scopedLoading; 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 pid = (form.productIds[0] ?? "").trim(); const ltid = form.labelTypeId.trim(); const labelTypeName = (() => { if (!ltid) return ""; const t = labelTypesScoped.find((x) => x.id === ltid) ?? types.find((x) => x.id === ltid); return (t?.typeName ?? t?.typeCode ?? "").trim(); })(); const empty = emptyLabelFormTemplateInputState(tpl); if (pid && ltid) { const hydrated = hydrateLabelFormInputFromProductDefaults(tpl, pid, ltid); setTemplateDataValues( resolveLabelFormTemplateTextValues(tpl, pid, ltid, labelTypeName, form.labelName), ); setTemplateDateOffsets({ ...empty.dateOffsets, ...hydrated.dateOffsets }); setNutritionByElementId({ ...empty.nutritionByElementId, ...hydrated.nutritionByElementId }); } else { setTemplateDataValues( resolveLabelFormTemplateTextValues(tpl, pid, ltid, labelTypeName, form.labelName), ); setTemplateDateOffsets(empty.dateOffsets); setNutritionByElementId(empty.nutritionByElementId); } } 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]); useEffect(() => { if (!open || !selectedTemplate) return; const pid = (form.productIds[0] ?? "").trim(); const ltid = form.labelTypeId.trim(); if (!pid && !ltid) return; const labelTypeName = (() => { if (!ltid) return ""; const t = labelTypesScoped.find((x) => x.id === ltid) ?? types.find((x) => x.id === ltid); return (t?.typeName ?? t?.typeCode ?? "").trim(); })(); if (pid && ltid) { const hydrated = hydrateLabelFormInputFromProductDefaults(selectedTemplate, pid, ltid); const empty = emptyLabelFormTemplateInputState(selectedTemplate); setTemplateDataValues( resolveLabelFormTemplateTextValues( selectedTemplate, pid, ltid, labelTypeName, form.labelName, ), ); setTemplateDateOffsets({ ...empty.dateOffsets, ...hydrated.dateOffsets }); setNutritionByElementId({ ...empty.nutritionByElementId, ...hydrated.nutritionByElementId }); return; } const elements = (selectedTemplate.elements ?? []) as LabelElement[]; setTemplateDataValues((prev) => mergeLabelBindingsIntoTextValues(prev, elements, { labelTypeName: ltid ? labelTypeName : undefined, labelName: form.labelName.trim() || undefined, }), ); }, [open, selectedTemplate, form.productIds, form.labelTypeId, form.labelName, productsScoped, labelTypesScoped, types]); const submit = async () => { const partnerErr = scopePartnerValidationMessage( scopeAuth.requireCompanySelection, effectivePartnerId, ); if (partnerErr) { toast.error("Validation failed", { description: partnerErr }); return; } if (!regionScopeReady) { toast.error("Validation failed", { description: scopeAuth.requireCompanySelection ? "Select company and applicable region(s) before other fields." : "Select applicable region(s) before other fields.", }); return; } const regionPayload = buildLabelRegionScopePayload( selectedRegionNames, groups, effectivePartnerId, ); if (regionPayload.appliedRegionType === "SPECIFIED" && !regionPayload.regionIds.length) { toast.error("Validation failed", { description: "Select at least one applicable region.", }); return; } if (scopedOptionsLoading) { toast.error("Validation failed", { description: "Loading options for this region. Please wait.", }); return; } const locPayload = buildSpecifiedLocationPayload( locations, partners, groups, effectivePartnerId, selectedRegionNames, form.locationIds, ); if (!locPayload.ok) { toast.error("Validation failed", { description: locPayload.message }); return; } if (!form.labelName.trim() || !form.templateCode.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { toast.error("Validation failed", { description: "Fill all required fields and select template, location(s), 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 pid = form.productIds[0]?.trim() ?? ""; const product = productsScoped.find((x) => x.id === pid); const labelType = labelTypesScoped.find((x) => x.id === labelTypeId) ?? types.find((x) => x.id === labelTypeId); const inputDefaultValues = collectTemplateDefaultValuesForSave( latest, templateDataValues, templateDateOffsets, nutritionByElementId, productCodeValue, { labelName: form.labelName, labelTypeName: labelType?.typeName ?? labelType?.typeCode, }, ); 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), contents: buildTemplateContentsFromElements(elements), appliedLocationIds: appliedLocationToEditor(latest) === "ALL" ? [] : (latest.appliedLocationIds ?? []), templateProductDefaults: mergedDefaults, }); }; setSubmitting(true); try { await createLabel({ ...form, locationIds: locPayload.locationIds, appliedRegionType: regionPayload.appliedRegionType, regionIds: regionPayload.regionIds, groupIds: regionPayload.regionIds, }); 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 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]); /** Label Name 模板控件:随 General Settings 中 Label Name 输入框同步 */ useEffect(() => { if (!open || !selectedTemplate) return; const name = form.labelName; setTemplateDataValues((prev) => { let changed = false; const next = { ...prev }; for (const el of (selectedTemplate.elements ?? []) as LabelElement[]) { if (!isTemplateLabelNameElement(el)) continue; if (next[el.id] === name) continue; next[el.id] = name; changed = true; } return changed ? next : prev; }); }, [open, selectedTemplate, form.labelName]); 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
{scopeAuth.requireCompanySelection ? (
{ setScopePartnerId(v); setSelectedRegionNames([]); }} options={companySelectOptionsForCreate} placeholder="Select company" searchPlaceholder="Search company…" emptyText="No companies available." disabled={scopeBootstrapLoading} />
) : null}

{effectivePartnerId ? "Select All saves as all regions for this company." : scopeAuth.requireCompanySelection ? "Select a company first." : "Your account company is loading or not assigned."}

setForm((p) => ({ ...p, productIds: id.trim() ? [id.trim()] : [] })) } products={productsScoped} productCategories={productCategoriesScoped} disabled={scopeBootstrapLoading || !regionScopeReady || scopedOptionsLoading} />
setForm((p) => ({ ...p, labelName: e.target.value }))} />
setForm((p) => ({ ...p, templateCode: v }))} options={templateOptions} placeholder={regionScopeReady ? "Select template" : scopeGateHint} searchPlaceholder="Search template…" emptyText={regionScopeReady ? "No templates for this region." : scopeGateEmpty} disabled={scopeBootstrapLoading || !regionScopeReady || scopedOptionsLoading} />
setForm((p) => ({ ...p, locationIds: ids }))} options={locationOptions} placeholder={regionScopeReady ? "Select location(s)…" : scopeGateHint} searchPlaceholder="Search location…" selectAllRowLabel="Select All" emptyText={regionScopeReady ? "No locations in this region." : scopeGateEmpty} disabled={scopeBootstrapLoading || !regionScopeReady || scopedOptionsLoading} />

{regionScopeReady ? "Select All applies this label to every location in the selected region(s)." : scopeGateEmpty}

setForm((p) => ({ ...p, labelCategoryId: v }))} options={categoryOptions} placeholder={regionScopeReady ? "Select category" : scopeGateHint} searchPlaceholder="Search category…" emptyText={ regionScopeReady ? "No label categories for this region." : scopeGateEmpty } disabled={scopeBootstrapLoading || !regionScopeReady || scopedOptionsLoading} />
setForm((p) => ({ ...p, labelTypeId: v }))} options={typeOptions} placeholder={ scopedOptionsLoading ? "Loading types…" : regionScopeReady ? "Select type" : scopeGateHint } searchPlaceholder="Search type…" emptyText={ regionScopeReady ? scopedOptionsLoading ? "Loading…" : "No types for this region." : scopeGateEmpty } disabled={scopeBootstrapLoading || !regionScopeReady || scopedOptionsLoading} />
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, (selectedTemplate?.elements ?? []) as LabelElement[])}`} /> )}
); })} {nutritionFieldBlocks.length > 0 ? (
Nutrition Facts
{listNutritionElements((selectedTemplate.elements ?? []) as LabelElement[]).map((nel) => ( setNutritionByElementId((prev) => ({ ...prev, [nel.id]: { ...(prev[nel.id] ?? {}), [subKey]: next, }, })) } className="space-y-4" /> ))}
) : 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 scopeAuth = useCategoryScopeAuth(); const [scopePartnerId, setScopePartnerId] = useState(""); const [selectedRegionNames, setSelectedRegionNames] = useState([]); const [productCatalogCategoryId, setProductCatalogCategoryId] = useState(""); const [labelTypeIdsInEditRegion, setLabelTypeIdsInEditRegion] = useState | null>(null); const [submitting, setSubmitting] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [loadedDetail, setLoadedDetail] = useState(null); const effectivePartnerId = useMemo( () => effectiveScopePartnerId( scopeAuth.requireCompanySelection, scopePartnerId, scopeAuth.fixedPartnerId, ), [scopeAuth.requireCompanySelection, scopePartnerId, scopeAuth.fixedPartnerId], ); useEffect(() => { if (!open || scopeAuth.requireCompanySelection) return; const pid = scopeAuth.fixedPartnerId.trim(); if (!pid || scopePartnerId === pid) return; setScopePartnerId(pid); }, [open, scopeAuth.requireCompanySelection, scopeAuth.fixedPartnerId, scopePartnerId]); const formReferenceScopeIds = useMemo((): LabelFormReferenceScopeIds | null => { const partnerId = effectivePartnerId; const names = selectedRegionNames.map((x) => x.trim()).filter(Boolean); if (!partnerId || !names.length) return null; return { partnerId, regionNames: names }; }, [effectivePartnerId, selectedRegionNames]); const { loading: refLoading, scopedLoading: editScopedLoading, templates, locations, groups, partners, categories, types, products, productCategories, productLocationMap, } = useLabelFormReferenceData(open, formReferenceScopeIds); const [form, setForm] = useState({ labelName: "", templateCode: "", locationIds: [], labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); const [templateLoading, setTemplateLoading] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(null); const [templateDataValues, setTemplateDataValues] = useState>({}); const [templateDateOffsets, setTemplateDateOffsets] = useState< Record >({}); const [nutritionByElementId, setNutritionByElementId] = useState< Record> >({}); useEffect(() => { if (!open || !label?.id) return; const id = label.id; setForm(labelDtoToUpdateForm(label)); setScopePartnerId(""); setSelectedRegionNames([]); setLoadedDetail(null); const ac = new AbortController(); let cancelled = false; setDetailLoading(true); (async () => { try { const detail = await getLabel(id, ac.signal); if (cancelled) return; setLoadedDetail(detail); 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 || !loadedDetail || !groups.length) return; const scope = hydrateLabelRegionScopeFromDto(loadedDetail, groups, partners, locations); if (scope.partnerId) setScopePartnerId(scope.partnerId); setSelectedRegionNames(scope.regionNames); }, [open, loadedDetail, groups, partners, locations]); 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 editScopeGroupIds = useMemo( () => regionNamesToGroupIds(selectedRegionNames, groups, effectivePartnerId), [selectedRegionNames, groups, effectivePartnerId], ); const editScopeReady = !!(effectivePartnerId && selectedRegionNames.length > 0 && editScopeGroupIds.length > 0); const regionOptionsForEdit = useMemo( () => regionOptionsForPartner(groups, effectivePartnerId), [groups, effectivePartnerId], ); const companySelectOptionsForEdit = useMemo( () => [...partners] .filter((p) => (p.id ?? "").trim()) .sort((a, b) => (a.partnerName ?? "").localeCompare(b.partnerName ?? "", undefined, { sensitivity: "base" }), ) .map((p) => ({ value: (p.id ?? "").trim(), label: toDisplay(p.partnerName), })), [partners], ); const locationsInEditScope = useMemo( () => locationsForRegionGroups(locations, groups, editScopeGroupIds), [locations, groups, editScopeGroupIds], ); 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, editScopeGroupIds.join(","), 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 || editScopedLoading); useEffect(() => { if (!open) { setSelectedTemplate(null); setTemplateDataValues({}); setTemplateDateOffsets({}); setNutritionByElementId({}); 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 pid = (form.productIds[0] ?? "").trim(); const ltid = form.labelTypeId.trim(); const labelTypeName = (() => { if (!ltid) return ""; const t = labelTypesScopedEdit.find((x) => x.id === ltid) ?? types.find((x) => x.id === ltid); return (t?.typeName ?? t?.typeCode ?? "").trim(); })(); const empty = emptyLabelFormTemplateInputState(tpl); if (pid && ltid) { const hydrated = hydrateLabelFormInputFromProductDefaults(tpl, pid, ltid); setTemplateDataValues( resolveLabelFormTemplateTextValues(tpl, pid, ltid, labelTypeName, form.labelName), ); setTemplateDateOffsets({ ...empty.dateOffsets, ...hydrated.dateOffsets }); setNutritionByElementId({ ...empty.nutritionByElementId, ...hydrated.nutritionByElementId }); } else { setTemplateDataValues( resolveLabelFormTemplateTextValues(tpl, pid, ltid, labelTypeName, form.labelName), ); setTemplateDateOffsets(empty.dateOffsets); setNutritionByElementId(empty.nutritionByElementId); } } 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, form.productIds, form.labelTypeId, productsScopedEdit, labelTypesScopedEdit, types]); useEffect(() => { if (!open || !selectedTemplate) return; const pid = (form.productIds[0] ?? "").trim(); const ltid = form.labelTypeId.trim(); if (!pid && !ltid) return; const labelTypeName = (() => { if (!ltid) return ""; const t = labelTypesScopedEdit.find((x) => x.id === ltid) ?? types.find((x) => x.id === ltid); return (t?.typeName ?? t?.typeCode ?? "").trim(); })(); if (pid && ltid) { const hydrated = hydrateLabelFormInputFromProductDefaults(selectedTemplate, pid, ltid); const empty = emptyLabelFormTemplateInputState(selectedTemplate); setTemplateDataValues( resolveLabelFormTemplateTextValues( selectedTemplate, pid, ltid, labelTypeName, form.labelName, ), ); setTemplateDateOffsets({ ...empty.dateOffsets, ...hydrated.dateOffsets }); setNutritionByElementId({ ...empty.nutritionByElementId, ...hydrated.nutritionByElementId }); return; } const elements = (selectedTemplate.elements ?? []) as LabelElement[]; setTemplateDataValues((prev) => mergeLabelBindingsIntoTextValues(prev, elements, { labelTypeName: ltid ? labelTypeName : undefined, labelName: form.labelName.trim() || undefined, }), ); }, [ open, selectedTemplate, form.productIds, form.labelTypeId, form.labelName, productsScopedEdit, labelTypesScopedEdit, types, ]); const submit = async () => { if (!label?.id) return; const partnerErr = scopePartnerValidationMessage( scopeAuth.requireCompanySelection, effectivePartnerId, ); if (partnerErr) { toast.error("Validation failed", { description: partnerErr }); return; } if (!editScopeReady) { toast.error("Validation failed", { description: scopeAuth.requireCompanySelection ? "Select company and applicable region(s)." : "Select applicable region(s).", }); return; } const regionPayload = buildLabelRegionScopePayload( selectedRegionNames, groups, effectivePartnerId, ); if (regionPayload.appliedRegionType === "SPECIFIED" && !regionPayload.regionIds.length) { toast.error("Validation failed", { description: "Select at least one applicable region.", }); return; } if (editScopeReady && typesLoadingEditScope) { toast.error("Validation failed", { description: "Loading label types for this region. Please wait.", }); return; } const locPayload = buildSpecifiedLocationPayload( locations, partners, groups, effectivePartnerId, selectedRegionNames, form.locationIds, ); if (!locPayload.ok) { toast.error("Validation failed", { description: locPayload.message }); return; } if (!form.labelName.trim() || !form.templateCode.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { toast.error("Validation failed", { description: "Fill all required fields and select template, location(s), 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 || !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 = productsScopedEdit.find((x) => x.id === pid); return (p?.codeValue ?? "").trim(); })(); const pid = form.productIds[0]?.trim() ?? ""; const product = productsScopedEdit.find((x) => x.id === pid); const labelType = labelTypesScopedEdit.find((x) => x.id === labelTypeId) ?? types.find((x) => x.id === labelTypeId); const inputDefaultValues = collectTemplateDefaultValuesForSave( latest, templateDataValues, templateDateOffsets, nutritionByElementId, productCodeValue, { labelName: form.labelName, labelTypeName: labelType?.typeName ?? labelType?.typeCode, }, ); 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), contents: buildTemplateContentsFromElements(elements), appliedLocationIds: appliedLocationToEditor(latest) === "ALL" ? [] : (latest.appliedLocationIds ?? []), templateProductDefaults: mergedDefaults, }); }; setSubmitting(true); try { await updateLabel(label.id, { ...form, locationIds: locPayload.locationIds, appliedRegionType: regionPayload.appliedRegionType, regionIds: regionPayload.regionIds, groupIds: regionPayload.regionIds, }); try { await saveTemplateDataAfterLabel(); } catch (e: any) { toast.warning("Label updated, template data failed.", { description: e?.message ? String(e.message) : "Please edit template data manually.", }); } 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 missing = (form.locationIds ?? []).filter((id) => id && !base.some((o) => o.value === id)); const extras = missing.map((id) => ({ value: id, label: `${id} (current)` })); return extras.length ? [...extras, ...base] : base; }, [editScopeReady, locationsInEditScope, locations, form.locationIds]); useEffect(() => { if (!editScopeReady) return; const allowed = new Set(locationsInEditScope.map((l) => l.id)); setForm((p) => { const next = (p.locationIds ?? []).filter((id) => allowed.has(id)); if (next.length === (p.locationIds ?? []).length) return p; return { ...p, locationIds: next }; }); }, [locationsInEditScope, editScopeReady]); 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]); const editDataEntryElements = useMemo( () => getCreateLabelTemplateInputElements(selectedTemplate), [selectedTemplate], ); const editNutritionFieldBlocks = 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 editShowTemplateInputColumn = editDataEntryElements.length > 0 || editNutritionFieldBlocks.length > 0; const editSelectedProductCodeValue = useMemo(() => { const pid = (form.productIds[0] ?? "").trim(); if (!pid) return ""; const p = productsScopedEdit.find((x) => x.id === pid); return (p?.codeValue ?? "").trim(); }, [form.productIds, productsScopedEdit]); useEffect(() => { if (!open || !selectedTemplate) return; const cv = editSelectedProductCodeValue; 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, editSelectedProductCodeValue]); /** Label Name 模板控件:随 General Settings 中 Label Name 输入框同步 */ useEffect(() => { if (!open || !selectedTemplate) return; const name = form.labelName; setTemplateDataValues((prev) => { let changed = false; const next = { ...prev }; for (const el of (selectedTemplate.elements ?? []) as LabelElement[]) { if (!isTemplateLabelNameElement(el)) continue; if (next[el.id] === name) continue; next[el.id] = name; changed = true; } return changed ? next : prev; }); }, [open, selectedTemplate, form.labelName]); const editPreviewTemplate = useMemo( () => buildCreateLabelPreviewTemplate( selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId, editSelectedProductCodeValue, ), [ selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId, editSelectedProductCodeValue, ], ); const editHasTemplateSelected = form.templateCode.trim().length > 0; return ( Edit Label {detailLoading ? "Loading label details…" : "Update the label details."}
General Settings
{scopeAuth.requireCompanySelection ? (
{ setScopePartnerId(v); setSelectedRegionNames([]); }} options={companySelectOptionsForEdit} placeholder="Select company" searchPlaceholder="Search company…" emptyText="No companies available." disabled={refLoading || detailLoading} />
) : null}
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 || !editScopeReady || editScopedLoading} />
setForm((p) => ({ ...p, locationIds: ids }))} options={editLocationOptions} placeholder={editScopeReady ? "Select location(s)…" : "Select applicable region(s) first"} searchPlaceholder="Search location…" selectAllRowLabel="Select All" emptyText={ editScopeReady ? "No locations in selected regions." : "Select applicable region(s) first." } disabled={refLoading || detailLoading || !editScopeReady || editScopedLoading} />

{editScopeReady ? "Select All applies this label to every location in the selected region(s)." : "Select applicable region(s) first."}

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 || !editScopeReady || editScopedLoading} />
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 || !editScopeReady || editScopedLoading} />
Enabled
setForm((p) => ({ ...p, state: checked }))} disabled={detailLoading} />
{editHasTemplateSelected && editShowTemplateInputColumn ? (
Template Input Data
{templateLoading ? (
Loading template fields...
) : !form.templateCode.trim() ? (
Select template first to load input fields.
) : (
{editDataEntryElements.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, (selectedTemplate?.elements ?? []) as LabelElement[])}`} /> )}
); })} {editNutritionFieldBlocks.length > 0 ? (
Nutrition Facts
{listNutritionElements((selectedTemplate.elements ?? []) as LabelElement[]).map((nel) => ( setNutritionByElementId((prev) => ({ ...prev, [nel.id]: { ...(prev[nel.id] ?? {}), [subKey]: next, }, })) } className="space-y-4" /> ))}
) : null}
Values shown are for the selected product and label type on this label. Date/time fields preview from the current time plus offset. Changes are saved to the template defaults on Update.
)}
) : null} {editHasTemplateSelected ? (
Label Preview
{editPreviewTemplate ? (
) : (
Select template to preview.
)}
) : null}
); } 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}?
); }