import React, { useState, useEffect, useMemo } from 'react'; import { EntityMetadata, FieldDefinition, FileMetadata, StructuredAddress, GeoLocation, normalizeFieldType, } from '../../entities/types'; import { LayoutDefinition } from '../../services/contracts/metadata'; import { RecordEntity, getDb } from '../../services/storage/indexedDb'; import { Button } from '../../shared/ui/Button'; import { Badge } from '../../shared/ui/Badge'; import { formatCurrencyMinor } from '../../shared/lib/currency'; import { formatDate, formatDateTime } from '../../shared/lib/date'; import { evaluateSafeFormula } from '../../shared/lib/fieldEngine'; import { Edit2, Trash2, X, Clock, Code, Layers, Copy, Check, Star, ExternalLink, Mail, Phone, MapPin, Navigation, FileText, Binary, } from 'lucide-react'; import { useToast } from '../../shared/ui/useToast'; export interface AuditLogItem { id: string; entityType: string; entityId: string; action: string; userId: string; userName?: string; timestamp: string; summary: string; } export interface DynamicCardRendererProps { entity: EntityMetadata; record: RecordEntity; layout?: LayoutDefinition | null; onEdit?: () => void; onDelete?: () => void; onClose?: () => void; } export const DynamicCardRenderer: React.FC = ({ entity, record, layout, onEdit, onDelete, onClose, }) => { const { showToast } = useToast(); const [activeTab, setActiveTab] = useState<'overview' | 'timeline' | 'json'>('overview'); const [auditLogs, setAuditLogs] = useState([]); const [copiedJson, setCopiedJson] = useState(false); // Sorted and enabled card blocks from layout config const cardBlocks = useMemo(() => { if (layout?.cardBlocks && layout.cardBlocks.length > 0) { return [...layout.cardBlocks] .filter((b) => b.enabled !== false) .sort((a, b) => a.order - b.order); } return []; }, [layout]); // Fetch audit logs for this specific record useEffect(() => { async function loadAudit() { try { const db = await getDb(); const tx = db.transaction('audit_log', 'readonly'); const index = tx.store.index('by_entity_id'); const logs = (await index.getAll(record.id)) as AuditLogItem[]; logs.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1)); setAuditLogs(logs); } catch { // Ignore fallback } } loadAudit(); }, [record.id]); const handleCopyJson = () => { navigator.clipboard.writeText(JSON.stringify(record, null, 2)); setCopiedJson(true); showToast({ type: 'success', title: 'JSON скопирован в буфер обмена' }); setTimeout(() => setCopiedJson(false), 2000); }; const recordTitle = String( record.name || record.title || record[entity.primaryFieldKey || 'name'] || record.id ); // Rich render function for any field const renderFieldValue = (f: FieldDefinition) => { const val = record[f.key]; const canonical = normalizeFieldType(f.type); if (val === undefined || val === null || val === '') { return —; } // Rating if (canonical === 'rating') { const r = Math.min(5, Math.max(1, Number(val) || 5)); return (
{[1, 2, 3, 4, 5].map((i) => ( ))} {r}/5
); } // Color if (canonical === 'color') { const hex = String(val); return (
{hex}
); } // URL if (canonical === 'url') { const urlStr = String(val); return ( {urlStr} ); } // Email if (canonical === 'email') { const emailStr = String(val); return ( {emailStr} ); } // Phone if (canonical === 'phone') { const phoneStr = String(val); return ( {phoneStr} ); } // Multi-Select or Checkbox Group if (canonical === 'multi_select' || canonical === 'checkbox_group') { if (Array.isArray(val)) { if (val.length === 0) return —; return (
{val.map((item) => { const opt = f.options?.find((o) => o.value === item); return ( {opt?.label || String(item)} ); })}
); } return {String(val)}; } // Money if (canonical === 'money') { const minor = typeof val === 'number' ? val : 0; return {formatCurrencyMinor(minor)}; } // Percentage if (canonical === 'percentage') { return {String(val)}%; } // Boolean if (canonical === 'boolean') { return ( {val ? 'Да' : 'Нет'} ); } // Date & Datetime if (canonical === 'date' || canonical === 'datetime') { return {canonical === 'datetime' ? formatDateTime(String(val)) : formatDate(String(val))}; } // Time if (canonical === 'time') { return {String(val)}; } // Status if (canonical === 'status') { const opt = f.options?.find((o) => o.value === val); return ( {opt?.label || String(val)} ); } // Select & Radio if (canonical === 'select' || canonical === 'radio') { const opt = f.options?.find((o) => o.value === val); return {opt?.label || String(val)}; } // File / Image if (canonical === 'file' || canonical === 'image') { if (typeof val === 'object' && val !== null && 'name' in val) { const fileMeta = val as FileMetadata; return (
{canonical === 'image' && fileMeta.url ? ( {fileMeta.name} ) : ( )}
{fileMeta.name} {(fileMeta.size / 1024).toFixed(1)} КБ
); } return {String(val)}; } // Address if (canonical === 'address') { if (typeof val === 'object' && val !== null) { const a = val as StructuredAddress; const parts = [a.city, a.street, a.postalCode, a.country].filter(Boolean); return (
{parts.join(', ') || '—'}
); } return (
{String(val)}
); } // Location if (canonical === 'location') { if (typeof val === 'object' && val !== null) { const loc = val as GeoLocation; return (
{loc.lat.toFixed(4)}, {loc.lng.toFixed(4)}
); } return {String(val)}; } // Formula if (canonical === 'formula') { const computed = f.formula ? evaluateSafeFormula(f.formula, record) : val; return (
{computed !== null ? String(computed) : String(val)}
); } // JSON if (canonical === 'json') { const jsonStr = typeof val === 'object' ? JSON.stringify(val) : String(val); return (
          {jsonStr}
        
); } // Autonumber if (canonical === 'autonumber') { return {String(val)}; } // Fallback default string representation return {String(val)}; }; return (
{/* Header Bar */}
{recordTitle.slice(0, 1).toUpperCase()}

{recordTitle}

{entity.code} {record.status !== undefined && record.status !== null && ( {String(record.status)} )}

ID: {record.id}

{onEdit && ( )} {onDelete && ( )} {onClose && ( )}
{/* Tabs Navigation */}
{/* Tab: Overview */} {activeTab === 'overview' && (
{cardBlocks.length > 0 ? ( cardBlocks.map((block) => { if (block.type === 'header') { return (
{block.settings?.showAvatar !== false && (
{recordTitle.slice(0, 1).toUpperCase()}
)}

{recordTitle}

ID: {record.id}

); } if (block.type === 'badges') { return (
{entity.code} {record.status !== undefined && record.status !== null && ( {String(record.status)} )}
); } if (block.type === 'fields_grid') { const specifiedKeys = block.settings?.fieldKeys; const targetFields = entity.fields .filter((f) => !f.hidden) .filter( (f) => !specifiedKeys || specifiedKeys.length === 0 || specifiedKeys.includes(f.key) ); const colsCount = block.settings?.columnsCount || 2; const gridClass = colsCount === 1 ? 'grid-cols-1' : colsCount === 3 ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2'; return (

{block.title || 'Атрибуты'}

{targetFields.map((f) => (
{f.label}
{renderFieldValue(f)}
))}
); } if (block.type === 'timeline') { return (

{block.title || 'Лента активности'}

{auditLogs.slice(0, block.settings?.maxItems || 5).map((log) => (
{log.summary} {log.action} • {log.userName || log.userId}
{formatDateTime(log.timestamp)}
))} {auditLogs.length === 0 && (
Записей активности нет.
)}
); } if (block.type === 'tasks_notes') { return (

{block.title || 'Заметки и задачи'}

Связанные задачи отсутствуют
); } return null; }) ) : (

Все атрибуты записи

{entity.fields .filter((f) => !f.hidden) .map((f) => (
{f.label}
{renderFieldValue(f)}
))}
)} {/* System Audit Information */}
Создано:{' '} {record.audit?.createdAt ? formatDateTime(String(record.audit.createdAt)) : record.created_at ? formatDateTime(String(record.created_at)) : '—'}
Обновлено:{' '} {formatDateTime(String(record.updatedAt || new Date().toISOString()))}
Версия:{' '} v{record.audit?.version || 1}
)} {/* Tab: Timeline / Audit Log */} {activeTab === 'timeline' && (

История изменений записи

{auditLogs.map((log) => (

{log.summary}

Действие: {log.action} • Пользователь: {log.userName || log.userId}

{formatDateTime(log.timestamp)}
))} {auditLogs.length === 0 && (
Лента аудита пока пуста для этой записи.
)}
)} {/* Tab: Raw JSON */} {activeTab === 'json' && (
Исходный документ (IndexedDB JSON)
            {JSON.stringify(record, null, 2)}
          
)}
); }; export default DynamicCardRenderer;