import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { EntityMetadata, FieldDefinition, FileMetadata, StructuredAddress, GeoLocation, normalizeFieldType, } from '../../entities/types'; import { LayoutDefinition, LayoutSection } from '../../services/contracts/metadata'; import { RecordEntity } from '../../services/storage/indexedDb'; import { Button } from '../../shared/ui/Button'; import { Badge } from '../../shared/ui/Badge'; import { formatCurrencyMinor, parseCurrencyToMinor } from '../../shared/lib/currency'; import { getDefaultFieldValue, validateFieldValue, evaluateSafeFormula, serializeFieldValue, } from '../../shared/lib/fieldEngine'; import { ulid } from '../../shared/lib/ulid'; import { useToast } from '../../shared/ui/useToast'; import { AlertCircle, Star, Upload, X, ExternalLink, MapPin, Bold, Italic, List as ListIcon, Quote, FileText, Binary, Code as CodeIcon, Navigation, } from 'lucide-react'; export interface DynamicFormRendererProps { entity: EntityMetadata; record?: RecordEntity | null; layout?: LayoutDefinition | null; mode?: 'create' | 'edit' | 'readonly'; onSave: (values: Record) => Promise | void; onCancel?: () => void; isLoading?: boolean; } // Preset relation options when field definition doesn't have custom options const DEFAULT_RELATION_OPTIONS: Record = { user: [ { value: 'usr_01', label: 'Иван Иванов (Администратор)' }, { value: 'usr_02', label: 'Анна Смирнова (Менеджер)' }, { value: 'usr_03', label: 'Михаил Соколов (Аналитик)' }, { value: 'usr_04', label: 'Елена Кузнецова (Оператор)' }, ], employee: [ { value: 'emp_01', label: 'Дмитрий Кузнецов (Ведущий инженер)' }, { value: 'emp_02', label: 'Ольга Морозова (Менеджер проекта)' }, { value: 'emp_03', label: 'Сергей Попов (Разработчик)' }, ], team: [ { value: 'team_01', label: 'Отдел продаж и работы с клиентами' }, { value: 'team_02', label: 'Департамент разработки ПО' }, { value: 'team_03', label: 'Служба логистики и снабжения' }, { value: 'team_04', label: 'Финансово-экономический отдел' }, ], company: [ { value: 'comp_01', label: 'ООО «Северная Звезда»' }, { value: 'comp_02', label: 'АО «ПромИнвестХолдинг»' }, { value: 'comp_03', label: 'ПАО «ТехноСфера»' }, { value: 'comp_04', label: 'ИП Сидоров А.В.' }, ], contact: [ { value: 'cont_01', label: 'Алексей Петров (+7 900 111-22-33)' }, { value: 'cont_02', label: 'Екатерина Васильева (e.vas@prom.ru)' }, { value: 'cont_03', label: 'Николай Федоров (+7 911 444-55-66)' }, ], product: [ { value: 'prod_01', label: 'Лицензия Krona Enterprise (Годовая)' }, { value: 'prod_02', label: 'Модуль расширенной бизнес-аналитики' }, { value: 'prod_03', label: 'Консалтинг по интеграции ERP/CRM' }, { value: 'prod_04', label: 'Пакет технической поддержки 24/7' }, ], entity_relation: [ { value: 'rel_01', label: 'Связанный документ #01' }, { value: 'rel_02', label: 'Связанный договор #02' }, { value: 'rel_03', label: 'Связанный контрагент #03' }, ], }; const COLOR_PRESETS = ['#2F684C', '#2563EB', '#D97706', '#DC2626', '#7C3AED', '#4B5563', '#059669', '#0891B2']; export const DynamicFormRenderer: React.FC = ({ entity, record, layout, mode = 'create', onSave, onCancel, isLoading = false, }) => { const { showToast } = useToast(); const getInitialValues = useCallback((ent: EntityMetadata, rec?: RecordEntity | null) => { const initial: Record = {}; ent.fields.forEach((field) => { if (rec && rec[field.key] !== undefined) { initial[field.key] = rec[field.key]; } else { initial[field.key] = getDefaultFieldValue(field.type, field); } }); return initial; }, []); const [formValues, setFormValues] = useState>(() => getInitialValues(entity, record)); const [errors, setErrors] = useState>({}); const prevRecordIdRef = useRef(record?.id); // Sync values on record switch useEffect(() => { if (record?.id !== prevRecordIdRef.current) { prevRecordIdRef.current = record?.id; void Promise.resolve().then(() => { setFormValues(getInitialValues(entity, record)); setErrors({}); }); } }, [entity, record, getInitialValues]); const fieldKeyMap = useMemo(() => { const map = new Map(); entity.fields.forEach((f) => map.set(f.key, f)); return map; }, [entity]); // Form Sections from Layout or Fallback const sections = useMemo(() => { if (layout?.sections && layout.sections.length > 0) { return layout.sections; } return [ { id: 'sec_default', title: 'Основные данные', columns: 2, fieldKeys: entity.fields.map((f) => f.key), }, ]; }, [layout, entity]); // Check conditional visibility const isFieldVisible = useCallback((field: FieldDefinition): boolean => { if (field.hidden) return false; if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true; const { fieldKey, operator, value } = field.conditionalVisibility; const currentVal = formValues[fieldKey]; switch (operator) { case 'eq': return String(currentVal ?? '') === String(value ?? ''); case 'neq': return String(currentVal ?? '') !== String(value ?? ''); case 'contains': return String(currentVal ?? '').includes(String(value ?? '')); default: return true; } }, [formValues]); const handleFieldChange = (key: string, val: unknown) => { setFormValues((prev) => ({ ...prev, [key]: val })); if (errors[key]) { setErrors((prev) => { const copy = { ...prev }; delete copy[key]; return copy; }); } }; // Validation const validate = (): boolean => { const newErrors: Record = {}; entity.fields.forEach((field) => { if (!isFieldVisible(field)) return; const val = formValues[field.key]; const result = validateFieldValue(field.type, val, field.validation, field); if (!result.valid) { if (result.error === 'Обязательное поле для заполнения') { newErrors[field.key] = `Поле «${field.label}» обязательно для заполнения`; } else { newErrors[field.key] = result.error || `Поле «${field.label}» заполнено неверно`; } } }); setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (mode === 'readonly') return; if (!validate()) { showToast({ type: 'error', title: 'Проверьте правильность заполнения формы' }); return; } try { const serializedValues: Record = {}; entity.fields.forEach((f) => { if (formValues[f.key] !== undefined) { serializedValues[f.key] = serializeFieldValue(f.type, formValues[f.key]); } }); Object.keys(formValues).forEach((k) => { if (serializedValues[k] === undefined) { serializedValues[k] = formValues[k]; } }); await onSave(serializedValues); } catch (err) { showToast({ type: 'error', title: 'Ошибка сохранения записи', message: err instanceof Error ? err.message : String(err), }); } }; // --------------------------------------------------------------------------- // Specialized Input Renderers for All Canonical Field Types // --------------------------------------------------------------------------- const renderFieldInput = (field: FieldDefinition) => { const rawVal = formValues[field.key]; const isReadOnly = mode === 'readonly' || field.readOnly; const hasError = !!errors[field.key]; const canonical = normalizeFieldType(field.type); // 1. Money in minor units (integer kopecks) if (canonical === 'money') { const minorVal = typeof rawVal === 'number' ? rawVal : 0; const majorVal = (minorVal / 100).toString(); if (isReadOnly) { return (
{formatCurrencyMinor(minorVal)}
); } return (
{ const parsed = parseCurrencyToMinor(e.target.value); handleFieldChange(field.key, parsed); }} placeholder={field.placeholder || '0.00'} disabled={isReadOnly} className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green pr-8 ${ hasError ? 'border-krona-danger' : 'border-krona-border' }`} /> ₽
); } // 2. Select & Status (Single choice) if (canonical === 'select' || canonical === 'status') { if (isReadOnly) { const opt = field.options?.find((o) => o.value === rawVal); return (
{opt?.label || String(rawVal || '—')}
); } return ( ); } // 3. Radio Buttons if (canonical === 'radio') { const options = field.options || [ { value: 'option_1', label: 'Вариант 1' }, { value: 'option_2', label: 'Вариант 2' }, ]; if (isReadOnly) { const opt = options.find((o) => o.value === rawVal); return (
{opt?.label || String(rawVal || '—')}
); } return (
{options.map((opt) => ( ))}
); } // 4. Multi-Select with Chips if (canonical === 'multi_select') { const selected = Array.isArray(rawVal) ? (rawVal as string[]) : []; const options = field.options || []; if (isReadOnly) { return (
{selected.length > 0 ? ( selected.map((val) => { const label = options.find((o) => o.value === val)?.label || val; return ( {label} ); }) ) : ( Ничего не выбрано )}
); } const unselectedOptions = options.filter((o) => !selected.includes(o.value)); return (
{selected.length > 0 && (
{selected.map((val) => { const label = options.find((o) => o.value === val)?.label || val; return ( {label} ); })}
)}
); } // 5. Checkbox Group if (canonical === 'checkbox_group') { const selected = Array.isArray(rawVal) ? (rawVal as string[]) : []; const options = field.options || [ { value: 'opt_1', label: 'Вариант А' }, { value: 'opt_2', label: 'Вариант Б' }, { value: 'opt_3', label: 'Вариант В' }, ]; return (
{options.map((opt) => { const isChecked = selected.includes(opt.value); return ( ); })}
); } // 6. Rating (1–5 Stars) if (canonical === 'rating') { const rating = Math.min(5, Math.max(1, Number(rawVal) || 5)); return (
{[1, 2, 3, 4, 5].map((star) => ( ))} {rating} / 5
); } // 7. Boolean / Checkbox if (canonical === 'boolean') { return ( ); } // 8. Date, Datetime, Time if (canonical === 'date' || canonical === 'datetime' || canonical === 'time') { const inputType = canonical === 'datetime' ? 'datetime-local' : canonical === 'time' ? 'time' : 'date'; return ( handleFieldChange(field.key, e.target.value)} disabled={isReadOnly} className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${ hasError ? 'border-krona-danger' : 'border-krona-border' }`} /> ); } // 9. URL with External Link Preview if (canonical === 'url') { return (
handleFieldChange(field.key, e.target.value)} disabled={isReadOnly} placeholder={field.placeholder || 'https://example.com'} className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green pr-8 ${ hasError ? 'border-krona-danger' : 'border-krona-border' }`} /> {rawVal ? ( ) : null}
); } // 10. Color Picker + Hex Input + Swatches if (canonical === 'color') { const hex = String(rawVal || '#2F684C'); const validHex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) ? hex : '#2F684C'; if (isReadOnly) { return (
{hex}
); } return (
handleFieldChange(field.key, e.target.value)} disabled={isReadOnly} className="w-8 h-8 rounded cursor-pointer border border-krona-border bg-transparent p-0.5" /> handleFieldChange(field.key, e.target.value)} disabled={isReadOnly} placeholder="#2F684C" maxLength={7} className={`w-28 font-mono text-xs uppercase bg-krona-surface text-krona-primary border rounded-md px-2.5 py-1.5 focus:outline-none focus:border-krona-green ${ hasError ? 'border-krona-danger' : 'border-krona-border' }`} />
{COLOR_PRESETS.map((preset) => (
); } // 11. File & Image Upload Abstraction if (canonical === 'file' || canonical === 'image') { const fileData = (rawVal && typeof rawVal === 'object' ? (rawVal as FileMetadata) : null); const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (uploadEv) => { const meta: FileMetadata = { id: ulid(), name: file.name, size: file.size, mimeType: file.type || 'application/octet-stream', url: String(uploadEv.target?.result || ''), uploadedAt: new Date().toISOString(), }; handleFieldChange(field.key, meta); }; reader.readAsDataURL(file); }; if (fileData) { return (
{canonical === 'image' && fileData.url ? ( {fileData.name} ) : ( )}

{fileData.name}

{(fileData.size / 1024).toFixed(1)} КБ • {fileData.mimeType}

{!isReadOnly && ( )}
); } if (isReadOnly) { return
Файл не прикреплен
; } return ( ); } // 12. Structured Address if (canonical === 'address') { const addr: StructuredAddress = typeof rawVal === 'object' && rawVal !== null ? (rawVal as StructuredAddress) : { street: String(rawVal || ''), city: '', postalCode: '', country: 'Россия' }; if (isReadOnly) { const parts = [addr.city, addr.street, addr.postalCode, addr.country].filter(Boolean); return (
{parts.join(', ') || '—'}
); } return (
handleFieldChange(field.key, { ...addr, city: e.target.value })} className="bg-krona-surface text-krona-primary border border-krona-border rounded px-2 py-1 text-xs focus:outline-none focus:border-krona-green" /> handleFieldChange(field.key, { ...addr, postalCode: e.target.value })} className="bg-krona-surface text-krona-primary border border-krona-border rounded px-2 py-1 text-xs focus:outline-none focus:border-krona-green" />
handleFieldChange(field.key, { ...addr, street: e.target.value })} className="w-full bg-krona-surface text-krona-primary border border-krona-border rounded px-2 py-1 text-xs focus:outline-none focus:border-krona-green" />
); } // 13. Location (Latitude, Longitude) if (canonical === 'location') { const loc: GeoLocation = typeof rawVal === 'object' && rawVal !== null ? (rawVal as GeoLocation) : { lat: 0, lng: 0 }; if (isReadOnly) { return (
Широта: {loc.lat.toFixed(4)}, Долгота: {loc.lng.toFixed(4)}
); } return (
handleFieldChange(field.key, { ...loc, lat: parseFloat(e.target.value) || 0 })} className="w-full bg-krona-surface text-krona-primary border border-krona-border rounded-md px-2.5 py-1.5 text-xs focus:outline-none focus:border-krona-green" /> LAT
handleFieldChange(field.key, { ...loc, lng: parseFloat(e.target.value) || 0 })} className="w-full bg-krona-surface text-krona-primary border border-krona-border rounded-md px-2.5 py-1.5 text-xs focus:outline-none focus:border-krona-green" /> LNG
); } // 14. Entity Relations & References (user, company, contact, etc.) if ( canonical === 'user' || canonical === 'employee' || canonical === 'team' || canonical === 'company' || canonical === 'contact' || canonical === 'product' || canonical === 'entity_relation' ) { const options = field.options && field.options.length > 0 ? field.options : DEFAULT_RELATION_OPTIONS[canonical] || []; if (isReadOnly) { const opt = options.find((o) => o.value === rawVal); return (
{opt?.label || String(rawVal || '—')}
); } return ( ); } // 15. Formula (Computed in real time via safe AST parser) if (canonical === 'formula') { const computedResult = evaluateSafeFormula(field.formula || '', formValues); return (
{computedResult !== null ? computedResult.toLocaleString('ru-RU') : '—'}
f(x) = {field.formula || 'не задана'}
Вычисляемое поле
); } // 16. Autonumber (Read-only sequence) if (canonical === 'autonumber') { const displayVal = String(rawVal || 'AUTO-[Генерируется]'); return (
{displayVal}
); } // 17. JSON Editor with Live Syntax Validation if (canonical === 'json') { const strVal = typeof rawVal === 'object' ? JSON.stringify(rawVal, null, 2) : String(rawVal ?? '{}'); let isValidJson = true; try { JSON.parse(strVal); } catch { isValidJson = false; } return (
JSON редактор {isValidJson ? 'JSON валиден' : 'Ошибка синтаксиса JSON'}