import React, { useEffect, useState } from 'react'; import { Building2, Check, Mail, Map, MapPin, Mailbox } from 'lucide-react'; import { Input } from '../../ui/input'; import { Button } from '../../ui/button'; import { Label } from '../../ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../ui/select'; import { Switch } from '../../ui/switch'; import type { LabelTemplate, LabelElement, Unit, Rotation, Border, NutritionExtraItem, PrintOrientation, } from '../../../types/labelTemplate'; import { canonicalElementType, isAutoGeneratedElement, isBlankSpaceElement, isCompanyAutoElement, isTemplateSectionPersistedType, NUTRITION_FIXED_ITEMS, } from '../../../types/labelTemplate'; import { buildCompanyPrintFieldsConfigPatch, COMPANY_PRINT_FIELD_OPTIONS, readCompanyPrintFields, type CompanyPrintFieldKey, } from '../../../utils/companyPrintFields'; import { cn } from '../../ui/utils'; import { readInvertColors, isTextLikeElementForInvertColors } from '../../../utils/invertColorsConfig'; import { type PreviewRulerDisplayUnit, displayLengthToElementPx, elementPxToDisplayLength, formatPreviewRulerDisplayValue, previewRulerUnitLabel, } from '../../../utils/previewRulerUnits'; import { unitToPx } from '../../../utils/labelTemplateUnits'; import { ImageUrlUpload } from '../../ui/image-url-upload'; import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; import { Checkbox } from '../../ui/checkbox'; import { Trash2 } from 'lucide-react'; import { BARCODE_FORMAT_OPTIONS, DEFAULT_BARCODE_FORMAT, normalizeBarcodeType, } from '../../../lib/barcodeFormat'; const DATE_FORMAT_OPTIONS = [ 'DD/MM/YYYY', 'MM/DD/YYYY', 'DD/MM/YY', 'MM/DD/YY', 'MM/YY', 'MM/DD', 'MM', 'DD', 'YY', 'FULLY DAY(WEDNESDAY)', 'DAY (WED)', 'MONTH (DECEMBER)', 'YEAR (2025)', 'DD MONTH YEAR (25 DECEMBER 2025)', ] as const; const DATETIME_DEFAULT_FORMAT = 'YYYY-MM-DD HH:mm'; const DURATION_FORMAT_OPTIONS = [ 'Minutes', 'Hours', 'Days', 'Weeks', 'Months (30 Day)', 'Years', ] as const; interface PropertiesPanelProps { template: LabelTemplate; selectedElement: LabelElement | null; onTemplateChange: (patch: Partial) => void; onElementChange: (id: string, patch: Partial) => void; onDeleteElement?: (id: string) => void; /** 编辑已有模板时禁止修改 Template Code */ readOnlyTemplateCode?: boolean; /** 与预览区标尺单位一致,用于 W/H 读数后缀 */ previewRulerUnit?: PreviewRulerDisplayUnit; /** 竖打预览时 W/H 沿纸张坐标轴,不随预览旋转 */ printOrientation?: PrintOrientation; } export function PropertiesPanel({ template, selectedElement, onTemplateChange, onElementChange, onDeleteElement, readOnlyTemplateCode = false, previewRulerUnit = 'cm', printOrientation = 'vertical', }: PropertiesPanelProps) { void template; void onTemplateChange; void readOnlyTemplateCode; if (selectedElement) { const isBlankElement = isBlankSpaceElement(selectedElement); return (
Properties (Element)
onElementChange(selectedElement.id, { x: Number(e.target.value) || 0, }) } className="h-8 text-sm" />
onElementChange(selectedElement.id, { y: Number(e.target.value) || 0, }) } className="h-8 text-sm" />
onElementChange(selectedElement.id, { width: Math.max(1, width), }) } /> onElementChange(selectedElement.id, { height: Math.max(1, height), }) } />
{printOrientation === 'horizontal' ? (

Width / Height follow paper axes (W×H), not the rotated preview. Switch to vertical to align with what you see on screen.

) : null} {!isBlankElement ? (
) : null} {!isBlankElement ? (
) : null} {!isBlankElement && isTextLikeElementForInvertColors(canonicalElementType(selectedElement.type)) ? ( )} onChange={(v) => onElementChange(selectedElement.id, { config: { ...selectedElement.config, invertColors: v }, }) } /> ) : null}
onElementChange(selectedElement.id, { elementName: e.target.value, }) } className="h-8 text-sm mt-1" placeholder="e.g. text1" />

Required for save; used as data-entry column header (elementName).

onElementChange(selectedElement.id, { config: { ...selectedElement.config, ...config } }) } /> {isCompanyAutoElement(selectedElement) ? ( } onPatch={(patch) => onElementChange(selectedElement.id, { config: { ...selectedElement.config, ...patch }, }) } /> ) : null} {onDeleteElement && (
)}
); } return (
Properties (Element)
Select an element on the canvas to edit its properties.
); } const MULTIPLE_OPTION_NONE = '__none__'; /** 绑定「Multiple Options」页维护的字典:先选字典,再在该字典的值列表中多选 */ function MultipleOptionsDictionaryFields({ cfg, onPatch, }: { cfg: Record; onPatch: (patch: Record) => void; }) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { let cancelled = false; setLoading(true); getLabelMultipleOptions({ skipCount: 1, maxResultCount: 500 }) .then((res) => { if (!cancelled) setRows(res.items ?? []); }) .catch(() => { if (!cancelled) setRows([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, []); const selectedId = ((cfg.multipleOptionId as string) ?? '').trim(); const selectedVals = Array.isArray(cfg.selectedOptionValues) ? (cfg.selectedOptionValues as string[]) : []; const active = rows.find((r) => r.id === selectedId); const valueList = active?.optionValuesJson ?? []; /** 从服务端拉到的字典列表就绪后,为已绑定 id 的旧模板补上 multipleOptionName,画布才能显示「名称:」前缀 */ useEffect(() => { if (!selectedId || rows.length === 0) return; const row = rows.find((r) => r.id === selectedId); const name = String(row?.optionName ?? '').trim(); if (!row || !name) return; const current = String(cfg.multipleOptionName ?? '').trim(); if (name !== current) { onPatch({ multipleOptionName: name }); } }, [selectedId, rows, cfg.multipleOptionName, onPatch]); const selectValue = selectedId ? selectedId : MULTIPLE_OPTION_NONE; return ( <>

Data comes from the Multiple Options tab (label-multiple-option list).

{active && valueList.length > 0 ? (
{valueList.map((val) => (
{ const set = new Set(selectedVals); if (checked) set.add(val); else set.delete(val); onPatch({ selectedOptionValues: Array.from(set) }); }} /> {val}
))}
) : selectedId ? (

No values in this dictionary or still loading.

) : null} ); } const TEMPLATE_IMAGE_UPLOAD_SIZE_PX = 100; const COMPANY_PRINT_FIELD_ICONS: Record< CompanyPrintFieldKey, React.ComponentType<{ className?: string; strokeWidth?: number }> > = { address: MapPin, city: Building2, state: Map, zip: Mailbox, email: Mail, }; function CompanyPrintFieldsEditor({ cfg, onPatch, }: { cfg: Record; onPatch: (patch: Record) => void; }) { const fields = readCompanyPrintFields(cfg); const toggleField = (key: CompanyPrintFieldKey) => { const nextFields = { ...fields, [key]: !fields[key], }; onPatch(buildCompanyPrintFieldsConfigPatch(nextFields)); }; return (

Company name is always printed. Select which fields to include on the label.

{COMPANY_PRINT_FIELD_OPTIONS.map((item) => { const Icon = COMPANY_PRINT_FIELD_ICONS[item.key]; const selected = fields[item.key]; return ( ); })}
); } function ElementDimensionField({ label, pxValue, paperSizeTemplate, templateUnit, displayUnit, onPxChange, }: { label: string; pxValue: number; paperSizeTemplate: number; templateUnit: Unit; displayUnit: PreviewRulerDisplayUnit; onPxChange: (px: number) => void; }) { const basePaperPx = unitToPx(Number(paperSizeTemplate) || 0, templateUnit); const displayValue = elementPxToDisplayLength( pxValue, basePaperPx, Number(paperSizeTemplate) || 0, templateUnit, displayUnit, ); const unitLabel = previewRulerUnitLabel(displayUnit); return (
{ const nextDisplay = Number(e.target.value); if (!Number.isFinite(nextDisplay)) return; const nextPx = Math.round( displayLengthToElementPx( nextDisplay, basePaperPx, Number(paperSizeTemplate) || 0, templateUnit, displayUnit, ), ); onPxChange(Math.max(1, nextPx)); }} className="h-8 min-w-0 flex-1 text-sm" /> {unitLabel}
); } function InvertColorsField({ checked, onChange, }: { checked: boolean; onChange: (value: boolean) => void; }) { return (
); } function TextStaticStyleFields({ cfg, update, textAlignDefault, primaryTextLabel, hidePrimaryText = false, }: { cfg: Record; update: (key: string, value: unknown) => void; textAlignDefault: string; /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */ primaryTextLabel?: 'Text' | 'Value'; /** Auto-generated 控件:文案由系统自动填充,隐藏 Text/Value 输入 */ hidePrimaryText?: boolean; }) { const textLabel = primaryTextLabel ?? 'Text'; return ( <> {!hidePrimaryText ? (
update('text', e.target.value)} className="h-8 text-sm mt-1" />
) : null}
update('fontSize', Number(e.target.value) || 14)} className="h-8 text-sm mt-1" />
); } /** 读 config(兼容后端 PascalCase、数字以字符串下发) */ function cfgPickStr(cfg: Record, keys: string[], fallback: string): string { for (const k of keys) { const v = cfg[k]; if (v != null && String(v).trim() !== '') return String(v).trim(); } return fallback; } function cfgPickNum(cfg: Record, keys: string[], fallback: number): number { for (const k of keys) { const v = cfg[k]; if (v == null || v === '') continue; const n = typeof v === 'number' ? v : Number(v); if (Number.isFinite(n)) return n; } return fallback; } const WEIGHT_UNIT_OPTIONS: Array<{ value: string; label: string }> = [ { value: 'lb', label: 'Lb' }, { value: 'kg', label: 'Kg' }, { value: 'mg', label: 'Milligrams' }, { value: 'g', label: 'Grams' }, { value: 'oz', label: 'Ounces' }, ]; function normalizeWeightUnit(raw: unknown): string { const unit = String(raw ?? '').trim().toLowerCase(); if (unit === 'milligrams') return 'mg'; if (unit === 'grams') return 'g'; if (unit === 'ounces') return 'oz'; if (unit === 'pounds') return 'lb'; if (unit === 'kilograms') return 'kg'; if (WEIGHT_UNIT_OPTIONS.some((item) => item.value === unit)) return unit; return 'g'; } function nutritionExtraRows(cfg: Record): NutritionExtraItem[] { const raw = cfg.extraNutrients; if (!Array.isArray(raw)) return []; return raw.map((item, idx) => { const row = item as Record; return { id: String(row.id ?? `extra-${idx}`), name: String(row.name ?? ''), value: String(row.value ?? ''), unit: String(row.unit ?? ''), }; }); } function nutritionFixedField( cfg: Record, key: string, field: 'value' | 'unit', ): string { const directKey = field === 'value' ? key : `${key}Unit`; const direct = cfg[directKey]; if (direct != null && String(direct).trim() !== '') return String(direct).trim(); const fixedRows = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const row = fixedRows.find((item) => String(item.key ?? '').trim() === key); return String(row?.[field] ?? '').trim(); } function ElementConfigFields({ element, onChange, }: { element: LabelElement; onChange: (config: Record) => void; }) { const cfg = element.config as Record; const elementType = canonicalElementType(element.type); const update = (key: string, value: unknown) => onChange({ [key]: value }); const fromTemplatePalette = isTemplateSectionPersistedType(element); const staticTextLabel = fromTemplatePalette ? ('Value' as const) : ('Text' as const); const hidePrimaryText = isAutoGeneratedElement(element); switch (elementType) { case 'TEXT_STATIC': if (cfg.inputType === 'options') { return ( <> ); } return ( ); case 'TEXT_PRODUCT': case 'TEXT_PRICE': return ( ); case 'BARCODE': return ( <>
update('data', e.target.value)} className="h-8 text-sm mt-1" />
update('fontSize', Number(e.target.value) || 14)} className="h-8 text-sm mt-1" />

Rotation and border use the common fields above.

update('showText', v)} />
); case 'QRCODE': return (
update('data', e.target.value)} className="h-8 text-sm mt-1" />
); case 'IMAGE': { if (fromTemplatePalette) { const src = String(cfg.src ?? '').trim(); return ( <>
update('src', url)} uploadSubDir="label-template-editor" oneImageOnly boxSizePx={TEMPLATE_IMAGE_UPLOAD_SIZE_PX} hint="Stored in template; print uses this URL (empty if cleared)." />
); } return ( <>
update('src', e.target.value)} className="h-8 text-sm mt-1" placeholder="https://... or /picture/..." />
); } case 'DATE': { const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase(); const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date'; const dateFormat = cfgPickStr( cfg, ['format', 'Format'], inputTypeNorm === 'datetime' ? DATETIME_DEFAULT_FORMAT : 'DD/MM/YYYY', ); const formatOptions = inputTypeNorm === 'datetime' ? [DATETIME_DEFAULT_FORMAT, ...DATE_FORMAT_OPTIONS] : [...DATE_FORMAT_OPTIONS]; return ( <>
{isPrintDate ? (

Shown as placeholder on the label until the app fills the date at print time.

) : null}
update('fontSize', Number(e.target.value) || 14)} className="h-8 text-sm mt-1" />
); } case 'TIME': return ( <>
update('fontSize', Number(e.target.value) || 14)} className="h-8 text-sm mt-1" />
); case 'DURATION': return ( <>
update('fontSize', Number(e.target.value) || 14)} className="h-8 text-sm mt-1" />
); case 'WEIGHT': { const weightUnit = normalizeWeightUnit(cfgPickStr(cfg, ['unit', 'Unit'], 'g')); const textAlign = cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left'); const fontSize = cfgPickNum(cfg, ['fontSize', 'FontSize'], 14); return ( <>
update('value', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
update('fontSize', Math.max(1, Number(e.target.value) || 14))} className="h-8 text-sm mt-1" />
); } case 'WEIGHT_PRICE': return ( <>
update('unitPrice', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
update('weight', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
update('currency', e.target.value)} className="h-8 text-sm mt-1" />
); case 'NUTRITION': { const extraRows = nutritionExtraRows(cfg); const applyFixedNutrientUnit = (key: string, nextUnit: string) => { const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => { const unit = nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? ''); return { key: item.key, label: item.label, value: '', unit: item.key === key ? nextUnit : unit, }; }); const keyPatch: Record = { fixedNutrients: fixedRows }; for (const item of NUTRITION_FIXED_ITEMS) { keyPatch[item.key] = ''; const row = fixedRows.find((r) => r.key === item.key); if (row?.unit) keyPatch[`${item.key}Unit`] = row.unit; } keyPatch.calories = ''; keyPatch.servingsPerContainer = ''; keyPatch.servingSize = ''; onChange(keyPatch); }; const addExtraNutrient = () => { const next: NutritionExtraItem[] = [ ...extraRows, { id: `extra-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name: '', value: '', unit: '', }, ]; update('extraNutrients', next); }; const updateExtraNutrient = ( id: string, field: 'name' | 'unit', nextValue: string, ) => { const next = extraRows.map((item) => item.id === id ? { ...item, [field]: nextValue, value: '' } : item, ); update('extraNutrients', next); }; const removeExtraNutrient = (id: string) => { update( 'extraNutrients', extraRows.filter((item) => item.id !== id), ); }; return ( <>
Nutrition Facts title (px) update('nutritionTitleFontSize', Math.max(10, Number(e.target.value) || 16)) } className="h-8 text-sm" />

Servings, calories and nutrient values are entered when creating labels or in Bulk Add.

Name Unit
{NUTRITION_FIXED_ITEMS.map((item) => (
{item.label} applyFixedNutrientUnit(item.key, e.target.value)} className="h-8 text-sm" placeholder="Unit" />
))} {extraRows.map((row) => (
updateExtraNutrient(row.id, 'name', e.target.value)} className="h-8 text-sm" placeholder="Nutrient name" /> updateExtraNutrient(row.id, 'unit', e.target.value)} className="h-8 text-sm" placeholder="Unit" />
))}
Unit is appended after the value when printing.
); } case 'BLANK': return (
Blank spacer; no configuration needed.
); default: return (
Config for {elementType} (edit in code if needed)
); } }