import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ArrowLeft, Plus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table'; import { SearchableSelect } from '../ui/searchable-select'; import { ImageUrlUpload } from '../ui/image-url-upload'; import { getLabelTemplate, updateLabelTemplate } from '../../services/labelTemplateService'; import { getProducts } from '../../services/productService'; import { getLabelTypes } from '../../services/labelTypeService'; import { skipCountForPage } from '../../lib/paginationQuery'; import type { LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate'; import { appliedLocationToEditor, canonicalElementType, dataEntryColumnLabel, isDataEntryTableColumnElement, isDateTimeDataEntryField, labelElementsToApiPayload, sortTemplateElementsForDisplay, } from '../../types/labelTemplate'; import { LABEL_FORM_OFFSET_UNITS, offsetFieldUiStateFromStored, serializePrintInputOffset, tryParsePrintInputOffsetStored, } from '../../lib/labelFormDatePreview'; import { foldNutritionCompositeKeysIntoDefaults, hydrateRowFieldValuesWithNutritionColumns, listNutritionManualFieldSpecs, nutritionCompositeFieldKey, type NutritionManualFieldSpec, } from '../../lib/nutritionManualEntry'; import type { ProductDto } from '../../types/product'; import type { LabelTypeDto } from '../../types/labelType'; import { buildTemplateBarcodeQrDefaultsFromCodeValue, isTemplateSectionBarcodeOrQrElement, } from '../../lib/productCodeValueTemplate'; export type TemplateDataEntryRow = { id: string; productId: string; labelTypeId: string; /** elementId -> 管理端录入的关联/默认值(真正打印时输入仍在 App) */ fieldValues: Record; }; function newRowId(): string { try { return crypto.randomUUID(); } catch { return `row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } } /** 保存前:日期类已是 JSON 则保留;纯数字等转为 JSON;其它旧文案原样保留 */ function normalizeDateTimeFieldForSave(el: LabelElement, raw: string): string { if (!isDateTimeDataEntryField(el)) return raw ?? ''; const t = String(raw ?? '').trim(); if (!t) return ''; if (tryParsePrintInputOffsetStored(t)) return t; const { unit, value } = offsetFieldUiStateFromStored(t); if (!String(value).trim()) return t; const amount = Number(String(value).trim()); if (!Number.isFinite(amount)) return t; return serializePrintInputOffset(unit, String(value).trim()); } /** 模板录入表:图片与二维码(及名称含 qrcode 的控件)用上传组件,预览区固定 100×100 */ const DATA_ENTRY_IMAGE_BOX = 'h-[100px] w-[100px] min-h-[100px] min-w-[100px] max-h-[100px] max-w-[100px] shrink-0 aspect-auto'; function dataEntryUsesImageUpload(element: LabelElement): boolean { const type = canonicalElementType(element.type); if (type === 'IMAGE' || type === 'QRCODE') return true; const n = (element.elementName ?? '').trim().toLowerCase(); return n.includes('qrcode'); } function DataEntryOffsetCell({ value, onValueChange, }: { value: string; onValueChange: (next: string) => void; }) { const { unit, value: num } = offsetFieldUiStateFromStored(value); return (
onValueChange(serializePrintInputOffset(unit, e.target.value))} placeholder="Val" />
); } function DataEntryValueCell({ element, value, onValueChange, readOnly, }: { element: LabelElement; value: string; onValueChange: (next: string) => void; readOnly?: boolean; }) { if (readOnly || isTemplateSectionBarcodeOrQrElement(element)) { return ( ); } if (dataEntryUsesImageUpload(element)) { return ( ); } if (isDateTimeDataEntryField(element)) { return ; } return ( onValueChange(e.target.value)} placeholder="—" className="h-10 border-gray-300 max-w-[220px]" /> ); } export function LabelTemplateDataEntryView({ templateCode, onBack, contextHint, }: { templateCode: string; onBack: () => void; /** 从 Labels 进入时展示:当前编辑的是哪条标签绑定的模板 */ contextHint?: string; }) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [templateTitle, setTemplateTitle] = useState(''); /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */ const [templateDto, setTemplateDto] = useState(null); const [products, setProducts] = useState([]); const [types, setTypes] = useState([]); const [rows, setRows] = useState([]); const sortedTemplateElements = useMemo( () => sortTemplateElementsForDisplay((templateDto?.elements ?? []) as LabelElement[]), [templateDto], ); const dataColumns = useMemo(() => { const cols: Array< | { kind: "element"; el: LabelElement } | { kind: "nutrition"; parent: LabelElement; spec: NutritionManualFieldSpec } > = []; for (const el of sortedTemplateElements) { if (isDataEntryTableColumnElement(el)) cols.push({ kind: "element", el }); if (canonicalElementType(el.type) === "NUTRITION") { for (const spec of listNutritionManualFieldSpecs(el)) { cols.push({ kind: "nutrition", parent: el, spec }); } } } return cols; }, [sortedTemplateElements]); const productOptions = useMemo( () => products.map((p) => { const label = (p.productName ?? p.productCode ?? '').trim() || p.id; return { value: p.id, label }; }), [products], ); const labelTypeOptions = useMemo( () => types.map((t) => { const label = (t.typeName ?? t.typeCode ?? '').trim() || t.id; return { value: t.id, label }; }), [types], ); useEffect(() => { let cancelled = false; (async () => { setLoading(true); try { const [tpl, prodRes, typeRes] = await Promise.all([ getLabelTemplate(templateCode), getProducts({ skipCount: skipCountForPage(1), maxResultCount: 500 }), getLabelTypes({ skipCount: skipCountForPage(1), maxResultCount: 500 }), ]); if (cancelled) return; const title = (tpl.templateName ?? tpl.name ?? '').trim() || (tpl.templateCode ?? tpl.id ?? '').trim() || templateCode; setTemplateTitle(title); setProducts(prodRes.items ?? []); setTypes(typeRes.items ?? []); setTemplateDto(tpl); const defaults = tpl.templateProductDefaults ?? []; const fromApi = defaults.length > 0 ? [...defaults].sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0)) : []; if (fromApi.length > 0) { setRows( fromApi.map((d) => ({ id: newRowId(), productId: d.productId, labelTypeId: d.labelTypeId, fieldValues: hydrateRowFieldValuesWithNutritionColumns( { ...d.defaultValues }, (tpl.elements ?? []) as LabelElement[], ), })), ); } else { setRows([ { id: newRowId(), productId: '', labelTypeId: '', fieldValues: {}, }, ]); } } catch (e: unknown) { if (!cancelled) { toast.error('Failed to load template or options.', { description: e instanceof Error ? e.message : 'Please try again.', }); setTemplateTitle(templateCode); setRows([]); setTemplateDto(null); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [templateCode]); const addRow = useCallback(() => { setRows((prev) => [ ...prev, { id: newRowId(), productId: '', labelTypeId: '', fieldValues: {} }, ]); }, []); const removeRow = useCallback((id: string) => { setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== id))); }, []); const updateRow = useCallback((id: string, patch: Partial) => { setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)), ); }, []); const applyProductCodeValueToRow = useCallback( (rowId: string, productId: string) => { const pid = productId.trim(); const product = products.find((p) => p.id === pid); const cv = (product?.codeValue ?? '').trim(); const scanDefaults = buildTemplateBarcodeQrDefaultsFromCodeValue( (templateDto?.elements ?? []) as LabelElement[], cv, ); setRows((prev) => prev.map((r) => { if (r.id !== rowId) return r; return { ...r, productId: pid, fieldValues: { ...r.fieldValues, ...scanDefaults }, }; }), ); }, [products, templateDto], ); const setFieldValue = useCallback( (rowId: string, elementId: string, value: string) => { setRows((prev) => prev.map((r) => { if (r.id !== rowId) return r; return { ...r, fieldValues: { ...r.fieldValues, [elementId]: value }, }; }), ); }, [], ); const handleSave = useCallback(async () => { if (!templateDto) { toast.error('Template not loaded', { description: 'Please reload the page and try again.' }); return; } const touched = rows.filter((r) => r.productId.trim() || r.labelTypeId.trim()); const incomplete = touched.some( (r) => !r.productId.trim() || !r.labelTypeId.trim(), ); if (incomplete) { toast.error('Product and label type required', { description: 'Each row that you started must have both Product and Label type selected.', }); return; } const validRows = rows.filter((r) => r.productId.trim() && r.labelTypeId.trim()); const fullElements = sortTemplateElementsForDisplay( (templateDto.elements ?? []) as LabelElement[], ); const templateProductDefaults = validRows.map((r, i) => { const folded = foldNutritionCompositeKeysIntoDefaults(r.fieldValues, fullElements); const defaultValues: Record = {}; for (const col of dataColumns) { if (col.kind === "element") { defaultValues[col.el.id] = normalizeDateTimeFieldForSave(col.el, folded[col.el.id] ?? ""); } } for (const el of fullElements) { if (canonicalElementType(el.type) === "NUTRITION") { const j = folded[el.id]; if (j) defaultValues[el.id] = j; } } const product = products.find((p) => p.id === r.productId.trim()); Object.assign( defaultValues, buildTemplateBarcodeQrDefaultsFromCodeValue(fullElements, product?.codeValue), ); return { productId: r.productId.trim(), labelTypeId: r.labelTypeId.trim(), defaultValues, orderNum: i + 1, }; }); if (fullElements.length === 0) { toast.error('Template has no elements', { description: 'Cannot save this template.' }); return; } const loc = appliedLocationToEditor(templateDto); setSaving(true); try { const updated = await updateLabelTemplate(templateCode, { id: templateDto.id, name: (templateDto.name ?? templateDto.templateName ?? '').trim() || templateCode, labelType: (templateDto.labelType ?? 'PRICE') as LabelType, unit: (templateDto.unit ?? 'inch') as Unit, width: Number(templateDto.width ?? 2), height: Number(templateDto.height ?? 2), appliedLocation: loc, showRuler: templateDto.showRuler ?? true, showGrid: templateDto.showGrid ?? true, state: templateDto.state ?? true, elements: labelElementsToApiPayload(fullElements), appliedLocationIds: loc === 'ALL' ? [] : (templateDto.appliedLocationIds ?? []), templateProductDefaults, }); setTemplateDto(updated); toast.success('Saved', { description: 'Template product defaults were updated on the server.', }); } catch (e: unknown) { toast.error('Save failed', { description: e instanceof Error ? e.message : 'Please try again.', }); } finally { setSaving(false); } }, [templateCode, templateDto, rows, dataColumns, products]); return (
Label template

{templateTitle}

{contextHint ? (

{contextHint}

) : null}

Bind product and label type per row. Values are saved with the template (edit API) as{' '} templateProductDefaults (interface doc section 4.4). Columns cover{' '} Label group defaults, PRINT_INPUT / Duration fields, and (when present) Nutrition Facts manual cells.{' '} Template panel elements are edited only in the label template editor (not here). Date / time / duration columns use unit + value; stored as JSON with unit and value keys. Nutrition values are stored as JSON under the nutrition element id for App print preview.

{loading ? (
Loading…
) : dataColumns.length === 0 ? (
No manual input or nutrition columns in this template.
) : ( Product Label type {dataColumns.map((col) => ( {col.kind === 'element' ? dataEntryColumnLabel(col.el) : col.spec.columnLabel} ))} {rows.map((row) => ( applyProductCodeValueToRow(row.id, v)} options={productOptions} placeholder="Select product" searchPlaceholder="Search product…" /> updateRow(row.id, { labelTypeId: v })} options={labelTypeOptions} placeholder="Select label type" searchPlaceholder="Search type…" /> {dataColumns.map((col) => ( {col.kind === 'element' ? ( setFieldValue(row.id, col.el.id, v)} /> ) : ( setFieldValue( row.id, nutritionCompositeFieldKey(col.parent.id, col.spec.subKey), e.target.value, ) } placeholder="—" className="h-10 border-gray-300 max-w-[220px]" /> )} ))} ))}
)}
); }