krona-front/src/features/metadata/DynamicFormRenderer.tsx

1087 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>) => Promise<void> | void;
onCancel?: () => void;
isLoading?: boolean;
}
// Preset relation options when field definition doesn't have custom options
const DEFAULT_RELATION_OPTIONS: Record<string, { value: string; label: string }[]> = {
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<DynamicFormRendererProps> = ({
entity,
record,
layout,
mode = 'create',
onSave,
onCancel,
isLoading = false,
}) => {
const { showToast } = useToast();
const getInitialValues = useCallback((ent: EntityMetadata, rec?: RecordEntity | null) => {
const initial: Record<string, unknown> = {};
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<Record<string, unknown>>(() => getInitialValues(entity, record));
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, FieldDefinition>();
entity.fields.forEach((f) => map.set(f.key, f));
return map;
}, [entity]);
// Form Sections from Layout or Fallback
const sections = useMemo<LayoutSection[]>(() => {
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<string, string> = {};
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<string, unknown> = {};
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 (
<div className="font-semibold text-xs text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
{formatCurrencyMinor(minorVal)}
</div>
);
}
return (
<div className="relative">
<input
type="number"
step="0.01"
value={majorVal === '0' && rawVal === '' ? '' : majorVal}
onChange={(e) => {
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'
}`}
/>
<span className="absolute right-3 top-2.5 text-xs text-krona-secondary font-medium">
₽
</span>
</div>
);
}
// 2. Select & Status (Single choice)
if (canonical === 'select' || canonical === 'status') {
if (isReadOnly) {
const opt = field.options?.find((o) => o.value === rawVal);
return (
<div className="text-xs font-medium text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
{opt?.label || String(rawVal || '—')}
</div>
);
}
return (
<select
value={String(rawVal ?? '')}
onChange={(e) => 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'
}`}
>
<option value="">Выберите значение...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// 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 (
<div className="text-xs font-medium text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
{opt?.label || String(rawVal || '—')}
</div>
);
}
return (
<div className="space-y-1.5 pt-1">
{options.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer text-xs text-krona-primary">
<input
type="radio"
name={`radio_${field.key}`}
value={opt.value}
checked={rawVal === opt.value}
onChange={() => handleFieldChange(field.key, opt.value)}
disabled={isReadOnly}
className="text-krona-green focus:ring-krona-green"
/>
<span>{opt.label}</span>
</label>
))}
</div>
);
}
// 4. Multi-Select with Chips
if (canonical === 'multi_select') {
const selected = Array.isArray(rawVal) ? (rawVal as string[]) : [];
const options = field.options || [];
if (isReadOnly) {
return (
<div className="flex flex-wrap gap-1.5 p-2 bg-krona-workspace rounded border border-krona-border/60">
{selected.length > 0 ? (
selected.map((val) => {
const label = options.find((o) => o.value === val)?.label || val;
return (
<Badge key={val} variant="neutral" size="sm">
{label}
</Badge>
);
})
) : (
<span className="text-xs text-krona-secondary">Ничего не выбрано</span>
)}
</div>
);
}
const unselectedOptions = options.filter((o) => !selected.includes(o.value));
return (
<div className="space-y-2">
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5 p-2 bg-krona-workspace/60 rounded border border-krona-border/60">
{selected.map((val) => {
const label = options.find((o) => o.value === val)?.label || val;
return (
<span
key={val}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-krona-green/10 text-krona-green border border-krona-green/20"
>
{label}
<button
type="button"
onClick={() => handleFieldChange(field.key, selected.filter((s) => s !== val))}
className="hover:text-krona-danger ml-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
);
})}
</div>
)}
<select
value=""
onChange={(e) => {
if (e.target.value) {
handleFieldChange(field.key, [...selected, 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'
}`}
>
<option value="">+ Добавить вариант...</option>
{unselectedOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
// 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 (
<div className="space-y-1.5 pt-1">
{options.map((opt) => {
const isChecked = selected.includes(opt.value);
return (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer text-xs text-krona-primary">
<input
type="checkbox"
checked={isChecked}
disabled={isReadOnly}
onChange={(e) => {
if (e.target.checked) {
handleFieldChange(field.key, [...selected, opt.value]);
} else {
handleFieldChange(field.key, selected.filter((s) => s !== opt.value));
}
}}
className="rounded text-krona-green focus:ring-krona-green"
/>
<span>{opt.label}</span>
</label>
);
})}
</div>
);
}
// 6. Rating (1–5 Stars)
if (canonical === 'rating') {
const rating = Math.min(5, Math.max(1, Number(rawVal) || 5));
return (
<div className="flex items-center gap-1.5 py-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
disabled={isReadOnly}
onClick={() => handleFieldChange(field.key, star)}
aria-label={`Оценка ${star} из 5 звёзд`}
className={`p-1 rounded transition-transform ${
!isReadOnly ? 'hover:scale-125 cursor-pointer' : 'cursor-default'
}`}
>
<Star
className={`w-5 h-5 ${
star <= rating
? 'fill-amber-400 text-amber-400'
: 'text-krona-secondary opacity-30'
}`}
/>
</button>
))}
<span className="text-xs font-semibold text-krona-primary ml-2">{rating} / 5</span>
</div>
);
}
// 7. Boolean / Checkbox
if (canonical === 'boolean') {
return (
<label className="flex items-center gap-2.5 cursor-pointer text-xs font-medium text-krona-primary py-1">
<input
type="checkbox"
checked={Boolean(rawVal)}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
disabled={isReadOnly}
className="w-4 h-4 rounded text-krona-green focus:ring-krona-green"
/>
<span>{rawVal ? 'Да (активно)' : 'Нет'}</span>
</label>
);
}
// 8. Date, Datetime, Time
if (canonical === 'date' || canonical === 'datetime' || canonical === 'time') {
const inputType = canonical === 'datetime' ? 'datetime-local' : canonical === 'time' ? 'time' : 'date';
return (
<input
type={inputType}
value={String(rawVal ?? '')}
onChange={(e) => 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 (
<div className="relative flex items-center">
<input
type="url"
value={String(rawVal ?? '')}
onChange={(e) => 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 ? (
<a
href={String(rawVal)}
target="_blank"
rel="noopener noreferrer"
className="absolute right-2.5 text-krona-secondary hover:text-krona-green"
title="Открыть ссылку"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
) : null}
</div>
);
}
// 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 (
<div className="flex items-center gap-2 p-1.5 bg-krona-workspace rounded border border-krona-border/60">
<div className="w-5 h-5 rounded border border-krona-border/60" style={{ backgroundColor: validHex }} />
<span className="font-mono text-xs text-krona-primary">{hex}</span>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={validHex.length === 7 ? validHex : '#2F684C'}
onChange={(e) => 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"
/>
<input
type="text"
value={hex}
onChange={(e) => 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'
}`}
/>
<div className="flex items-center gap-1.5 ml-2">
{COLOR_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => handleFieldChange(field.key, preset)}
style={{ backgroundColor: preset }}
className="w-5 h-5 rounded-full border border-krona-border/60 hover:scale-110 transition-transform"
title={preset}
/>
))}
</div>
</div>
</div>
);
}
// 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<HTMLInputElement>) => {
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 (
<div className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
{canonical === 'image' && fileData.url ? (
<img
src={fileData.url}
alt={fileData.name}
className="w-10 h-10 rounded object-cover border border-krona-border shrink-0"
/>
) : (
<FileText className="w-6 h-6 text-krona-green shrink-0" />
)}
<div className="truncate">
<p className="text-xs font-semibold text-krona-primary truncate">{fileData.name}</p>
<p className="text-[11px] text-krona-secondary font-mono">
{(fileData.size / 1024).toFixed(1)} КБ • {fileData.mimeType}
</p>
</div>
</div>
{!isReadOnly && (
<button
type="button"
onClick={() => handleFieldChange(field.key, null)}
className="p-1 text-krona-secondary hover:text-krona-danger rounded transition-colors"
title="Удалить файл"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
if (isReadOnly) {
return <div className="text-xs text-krona-secondary p-2 bg-krona-workspace rounded">Файл не прикреплен</div>;
}
return (
<label className="flex flex-col items-center justify-center p-4 border-2 border-dashed border-krona-border rounded-lg cursor-pointer hover:bg-krona-workspace/40 transition-colors">
<Upload className="w-5 h-5 text-krona-secondary mb-1.5" />
<span className="text-xs font-medium text-krona-primary">
{canonical === 'image' ? 'Нажмите для загрузки изображения' : 'Нажмите для загрузки документа'}
</span>
<span className="text-[10px] text-krona-secondary mt-0.5">PNG, JPG, PDF, DOCX до 10 МБ</span>
<input
type="file"
accept={canonical === 'image' ? 'image/*' : undefined}
onChange={handleFileUpload}
className="hidden"
/>
</label>
);
}
// 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 (
<div className="flex items-center gap-1.5 text-xs text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
<MapPin className="w-3.5 h-3.5 text-krona-green shrink-0" />
<span>{parts.join(', ') || '—'}</span>
</div>
);
}
return (
<div className="space-y-2 p-2.5 bg-krona-workspace/40 rounded-lg border border-krona-border/60">
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Город (напр. Москва)"
value={addr.city || ''}
onChange={(e) => 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"
/>
<input
type="text"
placeholder="Индекс (напр. 101000)"
value={addr.postalCode || ''}
onChange={(e) => 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"
/>
</div>
<input
type="text"
placeholder="Улица, дом, офис (напр. Тверская ул., д. 12, оф. 405)"
value={addr.street || ''}
onChange={(e) => 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"
/>
</div>
);
}
// 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 (
<div className="flex items-center gap-1.5 text-xs text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
<Navigation className="w-3.5 h-3.5 text-krona-green shrink-0" />
<span>Широта: {loc.lat.toFixed(4)}, Долгота: {loc.lng.toFixed(4)}</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="number"
step="0.0001"
min="-90"
max="90"
placeholder="Широта (Lat)"
value={loc.lat || ''}
onChange={(e) => 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"
/>
<span className="absolute right-2 top-2 text-[10px] text-krona-secondary font-mono">LAT</span>
</div>
<div className="relative flex-1">
<input
type="number"
step="0.0001"
min="-180"
max="180"
placeholder="Долгота (Lng)"
value={loc.lng || ''}
onChange={(e) => 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"
/>
<span className="absolute right-2 top-2 text-[10px] text-krona-secondary font-mono">LNG</span>
</div>
</div>
);
}
// 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 (
<div className="text-xs font-medium text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
{opt?.label || String(rawVal || '—')}
</div>
);
}
return (
<select
value={String(rawVal ?? '')}
onChange={(e) => 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'
}`}
>
<option value="">Выберите запись...</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// 15. Formula (Computed in real time via safe AST parser)
if (canonical === 'formula') {
const computedResult = evaluateSafeFormula(field.formula || '', formValues);
return (
<div className="p-2.5 bg-krona-workspace/80 border border-krona-border rounded-md flex items-center justify-between">
<div className="flex items-center gap-2">
<Binary className="w-4 h-4 text-krona-green shrink-0" />
<div>
<div className="text-xs font-bold text-krona-primary font-mono">
{computedResult !== null ? computedResult.toLocaleString('ru-RU') : '—'}
</div>
<div className="text-[10px] text-krona-secondary font-mono">
f(x) = {field.formula || 'не задана'}
</div>
</div>
</div>
<Badge variant="neutral" size="sm">
Вычисляемое поле
</Badge>
</div>
);
}
// 16. Autonumber (Read-only sequence)
if (canonical === 'autonumber') {
const displayVal = String(rawVal || 'AUTO-[Генерируется]');
return (
<div className="p-2 bg-krona-workspace rounded border border-krona-border/60 font-mono text-xs font-semibold text-krona-primary">
{displayVal}
</div>
);
}
// 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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono text-krona-secondary flex items-center gap-1">
<CodeIcon className="w-3 h-3" /> JSON редактор
</span>
<span
className={`text-[10px] font-medium px-2 py-0.5 rounded ${
isValidJson
? 'bg-krona-green/10 text-krona-green'
: 'bg-krona-danger/10 text-krona-danger'
}`}
>
{isValidJson ? 'JSON валиден' : 'Ошибка синтаксиса JSON'}
</span>
</div>
<textarea
rows={4}
value={strVal}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={isReadOnly}
className={`w-full font-mono text-xs bg-krona-surface text-krona-primary border rounded-md p-2.5 focus:outline-none focus:border-krona-green ${
!isValidJson ? 'border-krona-danger' : 'border-krona-border'
}`}
/>
</div>
);
}
// 18. Rich Text with Formatting Controls
if (canonical === 'rich_text') {
const textVal = String(rawVal ?? '');
const applyFormat = (prefix: string, suffix: string = prefix) => {
handleFieldChange(field.key, `${textVal} ${prefix}текст${suffix}`);
};
return (
<div className="space-y-1.5">
{!isReadOnly && (
<div className="flex items-center gap-1 border border-krona-border/60 rounded p-1 bg-krona-workspace/60 w-fit">
<button
type="button"
onClick={() => applyFormat('**', '**')}
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
title="Жирный"
>
<Bold className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => applyFormat('*', '*')}
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
title="Курсив"
>
<Italic className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => applyFormat('\n- ')}
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
title="Список"
>
<ListIcon className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => applyFormat('\n> ')}
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
title="Цитата"
>
<Quote className="w-3 h-3" />
</button>
</div>
)}
<textarea
rows={3}
value={textVal}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={isReadOnly}
placeholder={field.placeholder || 'Введите форматированный текст...'}
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'
}`}
/>
</div>
);
}
// 19. Long text
if (canonical === 'long_text') {
return (
<textarea
rows={3}
value={String(rawVal ?? '')}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={isReadOnly}
placeholder={field.placeholder || 'Введите текст...'}
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'
}`}
/>
);
}
// 20. Numeric types (Integer, Decimal, Percentage)
if (canonical === 'integer' || canonical === 'decimal' || canonical === 'percentage') {
const step = canonical === 'integer' ? '1' : '0.01';
return (
<div className="relative">
<input
type="number"
step={step}
value={rawVal !== undefined && rawVal !== null ? String(rawVal) : ''}
onChange={(e) =>
handleFieldChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
}
disabled={isReadOnly}
placeholder={field.placeholder || '0'}
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 ${
canonical === 'percentage' ? 'pr-8' : ''
} ${hasError ? 'border-krona-danger' : 'border-krona-border'}`}
/>
{canonical === 'percentage' && (
<span className="absolute right-3 top-2.5 text-xs text-krona-secondary font-medium">
%
</span>
)}
</div>
);
}
// 21. Default string inputs (text, email, phone)
return (
<input
type={canonical === 'email' ? 'email' : canonical === 'phone' ? 'tel' : 'text'}
value={String(rawVal ?? '')}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={isReadOnly}
placeholder={field.placeholder || ''}
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'
}`}
/>
);
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{sections.map((sec) => {
const visibleFields = sec.fieldKeys
.map((k) => fieldKeyMap.get(k))
.filter((f): f is FieldDefinition => Boolean(f && isFieldVisible(f)));
if (visibleFields.length === 0) return null;
const colClass = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-4',
}[sec.columns || 2];
return (
<div
key={sec.id}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
>
{sec.title && (
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider pb-2 border-b border-krona-border/60">
{sec.title}
</h3>
)}
<div className={`grid ${colClass} gap-3`}>
{visibleFields.map((field) => (
<div key={field.key} className="space-y-1 text-left">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-krona-primary select-none flex items-center gap-1">
{field.label}
{field.required && (
<span className="text-krona-danger font-bold" title="Обязательное поле">
*
</span>
)}
</label>
</div>
{renderFieldInput(field)}
{errors[field.key] ? (
<p className="text-[11px] text-krona-danger flex items-center gap-1 mt-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
{errors[field.key]}
</p>
) : field.description ? (
<p className="text-[10px] text-krona-secondary mt-0.5">{field.description}</p>
) : null}
</div>
))}
</div>
</div>
);
})}
{/* Action buttons (sticky on mobile for easy reachability) */}
{mode !== 'readonly' && (
<div className="sticky bottom-2 z-10 bg-krona-surface/95 backdrop-blur-sm p-3 rounded-xl border border-krona-border shadow-krona-card flex items-center justify-end gap-2.5">
{onCancel && (
<Button variant="outline" size="md" type="button" onClick={onCancel} className="min-h-[44px]">
Отмена
</Button>
)}
<Button variant="primary" size="md" type="submit" data-testid="dynamic-form-submit" disabled={isLoading} className="min-h-[44px]">
{isLoading ? 'Сохранение...' : mode === 'create' ? 'Создать запись' : 'Сохранить изменения'}
</Button>
</div>
)}
</form>
);
};
export default DynamicFormRenderer;