import React, { useCallback, useEffect, useState } from 'react'; import { Building2, Check, Mail, Map, MapPin, Mailbox, Trash2 } 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, isLabelSectionPersistedType, isPrintInputSectionPersistedType, elementEditorDisplayName, readElementPositionLocked, NUTRITION_FIXED_ITEMS, LABEL_EDITOR_FONT_OPTIONS, readLabelEditorFontFamilyChoice, } 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 { readVerticalAlign, readElementRotation, readFontWeight, readFontStyle, readTextDecoration } from '../../../utils/textElementLayout'; import { type PreviewRulerDisplayUnit, displayLengthToElementPx, elementPxToDisplayLength, formatPreviewRulerDisplayValue, previewRulerUnitLabel, } from '../../../utils/previewRulerUnits'; import { unitToPx } from '../../../utils/labelTemplateUnits'; import { ImageUrlUpload } from '../../ui/image-url-upload'; import { readImageScaleMode } from '../../../utils/imageScaleMode'; import { formatWeightDisplay, readWeightInputMode, WEIGHT_INPUT_MODE_OPTIONS, } from '../../../utils/weightElement'; import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; import { Checkbox } from '../../ui/checkbox'; import { NutritionManualEntryForm, applyNutritionValuesToElementConfig, nutritionValuesFromElementConfig, } from '../NutritionManualEntryForm'; 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 onTemplateChange; void readOnlyTemplateCode; if (selectedElement) { const isBlankElement = isBlankSpaceElement(selectedElement); const elementDisplayName = elementEditorDisplayName( selectedElement, template.elements ?? [], ); const positionLocked = readElementPositionLocked(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" />
{positionLocked ? (

Position is locked. Unlock in Available Elements to move or resize.

) : null}
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, { 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, disabled = false, }: { label: string; pxValue: number; paperSizeTemplate: number; templateUnit: Unit; displayUnit: PreviewRulerDisplayUnit; onPxChange: (px: number) => void; disabled?: boolean; }) { const basePaperPx = unitToPx(Number(paperSizeTemplate) || 0, templateUnit); const paperSize = Number(paperSizeTemplate) || 0; const displayValue = elementPxToDisplayLength( pxValue, basePaperPx, paperSize, templateUnit, displayUnit, ); const formatted = formatPreviewRulerDisplayValue(displayValue, displayUnit); const unitLabel = previewRulerUnitLabel(displayUnit); const step = displayUnit === 'mm' ? 1 : displayUnit === 'inch' ? 0.0001 : 0.001; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); const commitDisplay = useCallback( (raw: string): boolean => { const trimmed = raw.trim(); if (trimmed === '' || trimmed === '-' || trimmed === '.') return false; const nextDisplay = Number(trimmed); if (!Number.isFinite(nextDisplay)) return false; const nextPx = Math.round( displayLengthToElementPx( nextDisplay, basePaperPx, paperSize, templateUnit, displayUnit, ), ); onPxChange(Math.max(1, nextPx)); return true; }, [basePaperPx, displayUnit, onPxChange, paperSize, templateUnit], ); return (
{ setEditing(true); setDraft(formatted); }} onBlur={() => { commitDisplay(draft); setEditing(false); }} onChange={(e) => { const raw = e.target.value; setDraft(raw); const trimmed = raw.trim(); if ( trimmed !== '' && trimmed !== '-' && !trimmed.endsWith('.') && !trimmed.endsWith('-') ) { commitDisplay(raw); } }} onKeyDown={(e) => { if (e.key === 'Enter') { commitDisplay(draft); setEditing(false); e.currentTarget.blur(); } if (e.key === 'Escape') { setEditing(false); e.currentTarget.blur(); } }} className="h-8 min-w-0 flex-1 text-sm" /> {unitLabel}
); } function InvertColorsField({ checked, onChange, }: { checked: boolean; onChange: (value: boolean) => void; }) { return (
); } function VerticalAlignField({ cfg, update, }: { cfg: Record; update: (key: string, value: unknown) => void; }) { return (
); } function FontFamilyField({ cfg, update, }: { cfg: Record; update: (key: string, value: unknown) => void; }) { const current = readLabelEditorFontFamilyChoice(cfg); return (
); } function BiuStyleFields({ cfg, update, }: { cfg: Record; update: (key: string, value: unknown) => void; }) { const bold = readFontWeight(cfg) === 'bold'; const italic = readFontStyle(cfg) === 'italic'; const underline = readTextDecoration(cfg) === 'underline'; const btnBase = 'h-8 w-8 p-0 text-xs font-semibold shrink-0'; return (
); } const DEMO_VALUE_HINT = 'Value entered here for demo only.'; function TextStaticStyleFields({ cfg, update, textAlignDefault, primaryTextLabel, primaryTextHint, hidePrimaryText = false, }: { cfg: Record; update: (key: string, value: unknown) => void; textAlignDefault: string; /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */ primaryTextLabel?: 'Text' | 'Value'; /** Value/Text 输入框下方提示(如 Price 演示值说明) */ primaryTextHint?: string; /** Auto-generated 控件:文案由系统自动填充,隐藏 Text/Value 输入 */ hidePrimaryText?: boolean; }) { const textLabel = primaryTextLabel ?? 'Text'; return ( <> {!hidePrimaryText ? (
update('text', e.target.value)} className="h-8 text-sm mt-1" /> {primaryTextHint ? (

{primaryTextHint}

) : null}
) : 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 fromLabelPalette = isLabelSectionPersistedType(element); const fromPrintPalette = isPrintInputSectionPersistedType(element); const staticTextLabel = fromTemplatePalette ? ('Value' as const) : ('Text' as const); const hidePrimaryText = isAutoGeneratedElement(element); const demoValueHint = !hidePrimaryText && (fromLabelPalette || fromPrintPalette) ? DEMO_VALUE_HINT : undefined; switch (elementType) { case 'TEXT_STATIC': if (cfg.inputType === 'options') { return ( <> ); } return ( ); case 'TEXT_PRODUCT': return ( ); 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" boxSizePx={TEMPLATE_IMAGE_UPLOAD_SIZE_PX} />
); } 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); const weightInputMode = readWeightInputMode(cfg); const editorWeightInputMode = weightInputMode === 'tare' ? 'net' : weightInputMode; return ( <>
update('value', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />

{fromPrintPalette ? DEMO_VALUE_HINT : 'Demo value for editor preview only. App users enter weight at print time.'}

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 readLessThan = (key: string): boolean => { if (key === 'calories') return Boolean(cfg.caloriesLessThan); const baseFixed = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const row = baseFixed.find((r) => String(r.key ?? '').trim() === key); return Boolean(row?.lessThan ?? cfg[`${key}LessThan`]); }; const setLessThan = (key: string, lessThan: boolean) => { if (key === 'calories') { onChange({ ...cfg, caloriesLessThan: lessThan }); return; } const baseFixed = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => { const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === item.key); const unit = nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? ''); const nextLessThan = item.key === key ? lessThan : Boolean(baseRow?.lessThan ?? cfg[`${item.key}LessThan`]); return { key: item.key, label: item.label, value: String(baseRow?.value ?? cfg[item.key] ?? ''), unit, dailyValuePercent: String( baseRow?.dailyValuePercent ?? baseRow?.percent ?? cfg[`${item.key}Percent`] ?? '', ), lessThan: nextLessThan, }; }); const patch: Record = { fixedNutrients: fixedRows, [`${key}LessThan`]: lessThan }; for (const item of NUTRITION_FIXED_ITEMS) { const row = fixedRows.find((r) => r.key === item.key); patch[`${item.key}LessThan`] = Boolean(row?.lessThan); } onChange({ ...cfg, ...patch }); }; const applyFixedNutrientUnit = (key: string, nextUnit: string) => { const baseFixed = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const previewValues = nutritionValuesFromElementConfig(cfg); const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => { const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === item.key); const unit = nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? ''); return { key: item.key, label: item.label, value: String(baseRow?.value ?? cfg[item.key] ?? ''), unit: item.key === key ? nextUnit : unit, dailyValuePercent: String( baseRow?.dailyValuePercent ?? baseRow?.percent ?? cfg[`${item.key}Percent`] ?? '', ), lessThan: Boolean(baseRow?.lessThan ?? cfg[`${item.key}LessThan`]), }; }); const keyPatch: Record = { fixedNutrients: fixedRows }; for (const item of NUTRITION_FIXED_ITEMS) { const row = fixedRows.find((r) => r.key === item.key); if (row?.value) keyPatch[item.key] = row.value; else keyPatch[item.key] = ''; if (row?.unit) keyPatch[`${item.key}Unit`] = row.unit; } onChange(applyNutritionValuesToElementConfig({ ...cfg, ...keyPatch }, previewValues)); }; 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" />
{element.height < 240 ? (

Tip: increase element height to at least 280px to show the full Nutrition Facts panel.

) : null}
{ const merged = applyNutritionValuesToElementConfig(cfg, { ...nutritionValuesFromElementConfig(cfg), [subKey]: next, }); onChange(merged); }} className="space-y-3 mt-2" />
Name Unit <
Calories
setLessThan('calories', v === true)} aria-label="Calories less-than prefix" />
{NUTRITION_FIXED_ITEMS.map((item) => (
{item.label} applyFixedNutrientUnit(item.key, e.target.value)} className="h-8 text-sm" placeholder="Unit" />
setLessThan(item.key, v === true)} aria-label={`${item.label} less-than prefix`} />
))} {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" />
onChange({ ...cfg, [`extra:${row.id}:lessThan`]: v === true }) } aria-label={`${row.name || 'Extra nutrient'} less-than prefix`} />
))}
Reference unit only (not auto-appended). < prefix applies when enabled here; label data only enters amount and %DV.
); } case 'BLANK': return (
Blank spacer; no configuration needed.
); default: return (
Config for {elementType} (edit in code if needed)
); } }