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 { 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 { 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 { 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"; function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); return s ? s : "None"; } /** 列表行:标签编码(接口可能只返回 id 为 LabelCode) */ function labelRowCode(item: LabelDto): string { const c = (item.labelCode ?? item.id ?? "").trim(); return c || "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"; } /** 详情 / 列表行 → 编辑表单(列表接口可能缺 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, ); } function buildCreateLabelPreviewTemplate( apiTpl: LabelTemplateDto | null, textValues: Record, dateOffsets: Record, nutritionByElementId: Record>, ): 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"]; } return tmpl; } function collectTemplateDefaultValuesForSave( latest: LabelTemplateDto, textValues: Record, dateOffsets: Record, nutritionByElementId: Record>, ): 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; } 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 [categories, setCategories] = useState([]); const [types, setTypes] = useState([]); const [products, setProducts] = useState([]); /** 商品分类(Menu / fl_product_category),用于筛选商品 */ const [productCategories, setProductCategories] = useState([]); useEffect(() => { if (!open) return; let cancelled = false; (async () => { setLoading(true); try { const [tplRes, locRes, catRes, typeRes, prodRes, menuCatRes] = 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" }), ]); if (cancelled) return; setTemplates(tplRes.items ?? []); setLocations(locRes.items ?? []); setCategories(catRes.items ?? []); setTypes(typeRes.items ?? []); setProducts(prodRes.items ?? []); setProductCategories(menuCatRes.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([]); setCategories([]); setTypes([]); setProducts([]); setProductCategories([]); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [open]); return { loading, templates, locations, categories, types, products, productCategories }; } /** 先选商品分类,再在分类下搜索并单选商品(提交仍用 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[]; 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="Select product category first" searchPlaceholder="Search product category…" emptyText="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 [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 [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, 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, locationId: locationFilter !== "all" ? locationFilter : undefined, labelCategoryId: labelCategoryFilter !== "all" ? labelCategoryFilter : undefined, labelTypeId: labelTypeFilter !== "all" ? labelTypeFilter : undefined, templateCode: templateFilter !== "all" ? templateFilter : undefined, state: stateFilter === "all" ? undefined : stateFilter === "true", }, 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, locationFilter, 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 Code Label Name Location Category Type Template Products State Actions {loading ? ( Loading... ) : labels.length === 0 ? ( No results. ) : ( labels.map((item) => ( {labelRowCode(item)} {toDisplay(item.labelName)} {toDisplay(item.locationName ?? item.locationId)} {toDisplay(item.labelCategoryName ?? item.labelCategoryId)} {toDisplay(item.labelTypeName ?? item.labelTypeId)} {toDisplay(item.templateName ?? item.templateCode)} {labelRowProductsText(item)} {item.state === true ? "Active" : "Inactive"} 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, categories, types, products, productCategories } = useLabelFormReferenceData(open); 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({ labelCode: "", labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); const resetForm = () => { setForm({ labelCode: "", labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); setSelectedTemplate(null); setTemplateDataValues({}); setTemplateDateOffsets({}); setNutritionByElementId({}); setProductCatalogCategoryId(""); }; useEffect(() => { if (!open) { resetForm(); } }, [open]); 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 = getDataEntryElements(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 (!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 inputDefaultValues = collectTemplateDefaultValuesForSave( latest, templateDataValues, templateDateOffsets, nutritionByElementId, ); 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 templateOptions = useMemo( () => templates .filter((t) => templateListCode(t)) .map((t) => ({ value: templateListCode(t), label: templateListLabel(t), })), [templates], ); const locationOptions = useMemo( () => locations.map((loc) => ({ value: loc.id, label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), })), [locations], ); const categoryOptions = useMemo( () => categories.map((c) => ({ value: c.id, label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), })), [categories], ); const typeOptions = useMemo( () => types.map((ty) => ({ value: ty.id, label: toDisplay(ty.typeName ?? ty.typeCode ?? ty.id), })), [types], ); const dataEntryElements = useMemo( () => getDataEntryElements(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 previewTemplate = useMemo( () => buildCreateLabelPreviewTemplate( selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId, ), [selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId], ); const hasTemplateSelected = form.templateCode.trim().length > 0; return ( Add New Label Enter the details for the new label.
General Settings
setForm((p) => ({ ...p, productIds: id.trim() ? [id.trim()] : [] })) } products={products} productCategories={productCategories} disabled={refLoading} />
setForm((p) => ({ ...p, labelCode: e.target.value }))} />
setForm((p) => ({ ...p, labelName: e.target.value }))} />
setForm((p) => ({ ...p, templateCode: v }))} options={templateOptions} placeholder="Select template" searchPlaceholder="Search template…" emptyText="No templates found." disabled={refLoading} />
setForm((p) => ({ ...p, locationId: v }))} options={locationOptions} placeholder="Select location" searchPlaceholder="Search location…" emptyText="No locations found." disabled={refLoading} />
setForm((p) => ({ ...p, labelCategoryId: v }))} options={categoryOptions} placeholder="Select category" searchPlaceholder="Search category…" emptyText="No categories found." disabled={refLoading} />
setForm((p) => ({ ...p, labelTypeId: v }))} options={typeOptions} placeholder="Select type" searchPlaceholder="Search type…" emptyText="No types found." disabled={refLoading} />
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) => (
{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, categories, types, products, productCategories } = useLabelFormReferenceData(open); const [productCatalogCategoryId, setProductCatalogCategoryId] = useState(""); 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 submit = async () => { if (!label?.id) 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 = templates .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; }, [templates, form.templateCode]); const editLocationOptions = useMemo(() => { const base = locations.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; }, [locations, form.locationId]); const editCategoryOptions = useMemo(() => { const base = categories.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; }, [categories, form.labelCategoryId]); const editTypeOptions = useMemo(() => { const base = types.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; }, [types, 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="No templates found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, locationId: v }))} options={editLocationOptions} placeholder="Select location" searchPlaceholder="Search location…" emptyText="No locations found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, labelCategoryId: v }))} options={editCategoryOptions} placeholder="Select category" searchPlaceholder="Search category…" emptyText="No categories found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, labelTypeId: v }))} options={editTypeOptions} placeholder="Select type" searchPlaceholder="Search type…" emptyText="No types found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, productIds: id.trim() ? [id.trim()] : [] })) } products={products} productCategories={productCategories} 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}?
); }