import React, { useEffect, useState } from '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, } from '../../../types/labelTemplate'; import { canonicalElementType, isBlankSpaceElement, isTemplateSectionPersistedType, NUTRITION_FIXED_ITEMS, } from '../../../types/labelTemplate'; 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'; 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; } export function PropertiesPanel({ template, selectedElement, onTemplateChange, onElementChange, onDeleteElement, readOnlyTemplateCode = false, }: 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, Number(e.target.value) || 0), }) } className="h-8 text-sm" />
onElementChange(selectedElement.id, { height: Math.max(1, Number(e.target.value) || 0), }) } className="h-8 text-sm" />
{!isBlankElement ? (
) : null} {!isBlankElement ? (
) : 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 } }) } /> {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_BOX = 'box-border h-[150px] w-[150px] min-h-[150px] min-w-[150px] max-h-[150px] max-w-[150px] shrink-0'; function TextStaticStyleFields({ cfg, update, textAlignDefault, primaryTextLabel, }: { cfg: Record; update: (key: string, value: unknown) => void; textAlignDefault: string; /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */ primaryTextLabel?: 'Text' | 'Value'; }) { const textLabel = primaryTextLabel ?? 'Text'; return ( <>
update('text', e.target.value)} className="h-8 text-sm mt-1" />
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); 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('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 boxClassName={TEMPLATE_IMAGE_UPLOAD_BOX} 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 applyFixedNutrients = ( key: string, field: 'value' | 'unit', nextValue: string, ) => { const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => { const current = { key: item.key, label: item.label, value: nutritionFixedField(cfg, item.key, 'value'), unit: nutritionFixedField(cfg, item.key, 'unit'), }; if (item.key !== key) return current; return { ...current, [field]: nextValue }; }); const keyPatch: Record = { fixedNutrients: fixedRows }; const target = fixedRows.find((item) => item.key === key); if (target) { keyPatch[key] = target.value; keyPatch[`${key}Unit`] = target.unit; } 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: keyof NutritionExtraItem, nextValue: string, ) => { const next = extraRows.map((item) => item.id === id ? { ...item, [field]: nextValue } : 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 Per Container update('servingsPerContainer', e.target.value)} className="h-8 text-sm" placeholder="e.g. 8" />
Serving Size update('servingSize', e.target.value)} className="h-8 text-sm" placeholder="e.g. 1 cup" />
Calories update('calories', e.target.value)} className="h-8 text-sm" placeholder="e.g. 120" />
Name Value Unit
{NUTRITION_FIXED_ITEMS.map((item) => (
{item.label} applyFixedNutrients(item.key, 'value', e.target.value) } className="h-8 text-sm" placeholder="Value" /> applyFixedNutrients(item.key, 'unit', e.target.value) } className="h-8 text-sm" placeholder="Unit" />
))}
{extraRows.length === 0 ? (

No custom nutrients yet.

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