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 { 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 { ElementType, LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate'; import { appliedLocationToEditor, dataEntryColumnLabel, isDataEntryTableColumnElement, labelElementsToApiPayload, sortTemplateElementsForDisplay, } from '../../types/labelTemplate'; import type { ProductDto } from '../../types/product'; import type { LabelTypeDto } from '../../types/labelType'; 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)}`; } } function DataEntryValueCell({ elementType, value, onValueChange, }: { elementType: ElementType; value: string; onValueChange: (next: string) => void; }) { if (elementType === 'IMAGE') { return ( ); } return ( onValueChange(e.target.value)} placeholder="—" className="h-10 border-gray-300" /> ); } export function LabelTemplateDataEntryView({ templateCode, onBack, }: { templateCode: string; onBack: () => void; }) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [templateTitle, setTemplateTitle] = useState(''); /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */ const [templateDto, setTemplateDto] = useState(null); const [printFields, setPrintFields] = useState([]); const [products, setProducts] = useState([]); const [types, setTypes] = useState([]); const [rows, setRows] = useState([]); 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); const elements = sortTemplateElementsForDisplay( (tpl.elements ?? []) as LabelElement[], ).filter(isDataEntryTableColumnElement); setPrintFields(elements); 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: { ...d.defaultValues }, })), ); } 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); setPrintFields([]); 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 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 templateProductDefaults = validRows.map((r, i) => { const defaultValues: Record = {}; for (const f of printFields) { defaultValues[f.id] = r.fieldValues[f.id] ?? ''; } return { productId: r.productId.trim(), labelTypeId: r.labelTypeId.trim(), defaultValues, orderNum: i + 1, }; }); const fullElements = sortTemplateElementsForDisplay( (templateDto.elements ?? []) as LabelElement[], ); 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, printFields]); return (
Label template

{templateTitle}

Bind product and label type per row. Values are saved with the template (edit API) as{' '} templateProductDefaults (interface doc section 4.4). Only{' '} FIXED fields appear here.{' '} AUTO_DB and PRINT_INPUT{' '} are resolved at print time in the app. Column headers use{' '} elementName.

{loading ? (
Loading…
) : printFields.length === 0 ? (
No FIXED elements in this template (or none besides blanks). AUTO_DB / PRINT_INPUT columns are hidden here by design.
) : ( Product Label type {printFields.map((f) => ( {dataEntryColumnLabel(f)} ))} {rows.map((row) => ( updateRow(row.id, { productId: v })} options={productOptions} placeholder="Select product" searchPlaceholder="Search product…" /> updateRow(row.id, { labelTypeId: v })} options={labelTypeOptions} placeholder="Select label type" searchPlaceholder="Search type…" /> {printFields.map((f) => ( setFieldValue(row.id, f.id, v)} /> ))} ))}
)}
); }