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

705 lines
25 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 } 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<DynamicCardRendererProps> = ({
entity,
record,
layout,
onEdit,
onDelete,
onClose,
}) => {
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState<'overview' | 'timeline' | 'json'>('overview');
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
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 <span className="text-krona-secondary/60 font-normal">—</span>;
}
// Rating
if (canonical === 'rating') {
const r = Math.min(5, Math.max(1, Number(val) || 5));
return (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
className={`w-3.5 h-3.5 ${
i <= r ? 'fill-amber-400 text-amber-400' : 'text-krona-secondary opacity-30'
}`}
/>
))}
<span className="text-[11px] font-semibold text-krona-primary ml-1">{r}/5</span>
</div>
);
}
// Color
if (canonical === 'color') {
const hex = String(val);
return (
<div className="flex items-center gap-2">
<span
className="w-4 h-4 rounded border border-krona-border/60 shrink-0"
style={{ backgroundColor: hex }}
/>
<span className="font-mono text-xs">{hex}</span>
</div>
);
}
// URL
if (canonical === 'url') {
const urlStr = String(val);
return (
<a
href={urlStr}
target="_blank"
rel="noopener noreferrer"
className="text-krona-green hover:underline inline-flex items-center gap-1 break-all"
>
{urlStr}
<ExternalLink className="w-3 h-3 shrink-0" />
</a>
);
}
// Email
if (canonical === 'email') {
const emailStr = String(val);
return (
<a
href={`mailto:${emailStr}`}
className="text-krona-green hover:underline inline-flex items-center gap-1"
>
<Mail className="w-3 h-3 shrink-0" />
{emailStr}
</a>
);
}
// Phone
if (canonical === 'phone') {
const phoneStr = String(val);
return (
<a
href={`tel:${phoneStr.replace(/\D/g, '')}`}
className="text-krona-green hover:underline inline-flex items-center gap-1"
>
<Phone className="w-3 h-3 shrink-0" />
{phoneStr}
</a>
);
}
// Multi-Select or Checkbox Group
if (canonical === 'multi_select' || canonical === 'checkbox_group') {
if (Array.isArray(val)) {
if (val.length === 0) return <span className="text-krona-secondary/60">—</span>;
return (
<div className="flex flex-wrap gap-1">
{val.map((item) => {
const opt = f.options?.find((o) => o.value === item);
return (
<Badge key={String(item)} variant="neutral" size="sm">
{opt?.label || String(item)}
</Badge>
);
})}
</div>
);
}
return <span>{String(val)}</span>;
}
// Money
if (canonical === 'money') {
const minor = typeof val === 'number' ? val : 0;
return <span className="font-semibold text-krona-primary">{formatCurrencyMinor(minor)}</span>;
}
// Percentage
if (canonical === 'percentage') {
return <span className="font-semibold text-krona-primary">{String(val)}%</span>;
}
// Boolean
if (canonical === 'boolean') {
return (
<Badge variant={val ? 'green' : 'neutral'} size="sm">
{val ? 'Да' : 'Нет'}
</Badge>
);
}
// Date & Datetime
if (canonical === 'date' || canonical === 'datetime') {
return <span>{canonical === 'datetime' ? formatDateTime(String(val)) : formatDate(String(val))}</span>;
}
// Time
if (canonical === 'time') {
return <span className="font-mono">{String(val)}</span>;
}
// Status
if (canonical === 'status') {
const opt = f.options?.find((o) => o.value === val);
return (
<Badge variant="green" size="sm">
{opt?.label || String(val)}
</Badge>
);
}
// Select & Radio
if (canonical === 'select' || canonical === 'radio') {
const opt = f.options?.find((o) => o.value === val);
return <span>{opt?.label || String(val)}</span>;
}
// File / Image
if (canonical === 'file' || canonical === 'image') {
if (typeof val === 'object' && val !== null && 'name' in val) {
const fileMeta = val as FileMetadata;
return (
<div className="flex items-center gap-2">
{canonical === 'image' && fileMeta.url ? (
<img
src={fileMeta.url}
alt={fileMeta.name}
className="w-8 h-8 rounded object-cover border border-krona-border shrink-0"
/>
) : (
<FileText className="w-4 h-4 text-krona-green shrink-0" />
)}
<div className="truncate">
<span className="font-semibold text-krona-primary truncate block">{fileMeta.name}</span>
<span className="text-[10px] text-krona-secondary font-mono">
{(fileMeta.size / 1024).toFixed(1)} КБ
</span>
</div>
</div>
);
}
return <span>{String(val)}</span>;
}
// 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 (
<div className="flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-krona-green shrink-0" />
<span>{parts.join(', ') || '—'}</span>
</div>
);
}
return (
<div className="flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-krona-green shrink-0" />
<span>{String(val)}</span>
</div>
);
}
// Location
if (canonical === 'location') {
if (typeof val === 'object' && val !== null) {
const loc = val as GeoLocation;
return (
<div className="flex items-center gap-1.5 font-mono text-xs">
<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 <span>{String(val)}</span>;
}
// Formula
if (canonical === 'formula') {
const computed = f.formula ? evaluateSafeFormula(f.formula, record) : val;
return (
<div className="flex items-center gap-1.5">
<Binary className="w-3.5 h-3.5 text-krona-green shrink-0" />
<span className="font-mono font-bold text-krona-primary">
{computed !== null ? String(computed) : String(val)}
</span>
</div>
);
}
// JSON
if (canonical === 'json') {
const jsonStr = typeof val === 'object' ? JSON.stringify(val) : String(val);
return (
<pre className="p-1 bg-krona-workspace rounded text-[11px] font-mono text-krona-primary truncate max-w-xs">
{jsonStr}
</pre>
);
}
// Autonumber
if (canonical === 'autonumber') {
return <span className="font-mono font-bold text-krona-green">{String(val)}</span>;
}
// Fallback default string representation
return <span>{String(val)}</span>;
};
return (
<div className="space-y-4 text-left">
{/* Header Bar */}
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-krona-green/10 text-krona-green flex items-center justify-center font-bold text-base border border-krona-green/20">
{recordTitle.slice(0, 1).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-bold text-krona-primary">{recordTitle}</h2>
<Badge variant="neutral" size="sm">
<code>{entity.code}</code>
</Badge>
{record.status !== undefined && record.status !== null && (
<Badge variant="green" size="sm">
{String(record.status)}
</Badge>
)}
</div>
<p className="text-xs font-mono text-krona-secondary">ID: {record.id}</p>
</div>
</div>
<div className="flex items-center gap-2">
{onEdit && (
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit2 className="w-3.5 h-3.5 mr-1" />
Редактировать
</Button>
)}
{onDelete && (
<Button
variant="outline"
size="sm"
onClick={onDelete}
className="text-krona-danger hover:bg-krona-danger/10"
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
Удалить
</Button>
)}
{onClose && (
<button
onClick={onClose}
className="p-1.5 text-krona-secondary hover:text-krona-primary rounded-lg"
title="Закрыть"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Tabs Navigation */}
<div className="flex items-center gap-1 border-b border-krona-border/80 pb-1">
<button
onClick={() => setActiveTab('overview')}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all flex items-center gap-1.5 ${
activeTab === 'overview'
? 'bg-krona-green text-white shadow-sm'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<Layers className="w-3.5 h-3.5" />
Обзор реквизитов
</button>
<button
onClick={() => setActiveTab('timeline')}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all flex items-center gap-1.5 ${
activeTab === 'timeline'
? 'bg-krona-green text-white shadow-sm'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<Clock className="w-3.5 h-3.5" />
Аудит и история ({auditLogs.length})
</button>
<button
onClick={() => setActiveTab('json')}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all flex items-center gap-1.5 ${
activeTab === 'json'
? 'bg-krona-green text-white shadow-sm'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<Code className="w-3.5 h-3.5" />
Сырой JSON
</button>
</div>
{/* Tab: Overview */}
{activeTab === 'overview' && (
<div className="space-y-4">
{cardBlocks.length > 0 ? (
cardBlocks.map((block) => {
if (block.type === 'header') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft flex items-center justify-between"
>
<div className="flex items-center gap-3">
{block.settings?.showAvatar !== false && (
<div className="w-10 h-10 rounded-lg bg-krona-green/10 text-krona-green flex items-center justify-center font-bold text-sm border border-krona-green/20">
{recordTitle.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<h2 className="text-base font-bold text-krona-primary">{recordTitle}</h2>
<p className="text-xs font-mono text-krona-secondary">ID: {record.id}</p>
</div>
</div>
</div>
);
}
if (block.type === 'badges') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-3 bg-krona-surface border border-krona-border rounded-xl flex items-center gap-2"
>
<Badge variant="neutral" size="sm">
<code>{entity.code}</code>
</Badge>
{record.status !== undefined && record.status !== null && (
<Badge variant="green" size="sm">
{String(record.status)}
</Badge>
)}
</div>
);
}
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 (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
>
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
{block.title || 'Атрибуты'}
</h3>
<div className={`grid ${gridClass} gap-3 text-xs`}>
{targetFields.map((f) => (
<div
key={f.key}
data-testid={`card-field-${f.key}`}
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
>
<span className="text-[11px] text-krona-secondary block mb-1">
{f.label}
</span>
<div className="font-semibold text-krona-primary break-words">
{renderFieldValue(f)}
</div>
</div>
))}
</div>
</div>
);
}
if (block.type === 'timeline') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
>
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
{block.title || 'Лента активности'}
</h3>
<div className="space-y-2">
{auditLogs.slice(0, block.settings?.maxItems || 5).map((log) => (
<div
key={log.id}
className="p-2.5 bg-krona-workspace rounded-lg text-xs flex justify-between items-center"
>
<div>
<span className="font-semibold text-krona-primary">{log.summary}</span>
<span className="text-[10px] text-krona-secondary block">
{log.action} • {log.userName || log.userId}
</span>
</div>
<span className="text-[10px] text-krona-secondary font-mono">
{formatDateTime(log.timestamp)}
</span>
</div>
))}
{auditLogs.length === 0 && (
<div className="text-xs text-krona-secondary py-2">
Записей активности нет.
</div>
)}
</div>
</div>
);
}
if (block.type === 'tasks_notes') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
>
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
{block.title || 'Заметки и задачи'}
</h3>
<div className="p-3 bg-krona-workspace/60 rounded-lg text-xs text-krona-secondary">
Связанные задачи отсутствуют
</div>
</div>
);
}
return null;
})
) : (
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3">
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Все атрибуты записи
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
{entity.fields
.filter((f) => !f.hidden)
.map((f) => (
<div
key={f.key}
data-testid={`card-field-${f.key}`}
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
>
<span className="text-[11px] text-krona-secondary block mb-1">
{f.label}
</span>
<div className="font-semibold text-krona-primary break-words">
{renderFieldValue(f)}
</div>
</div>
))}
</div>
</div>
)}
{/* System Audit Information */}
<div className="p-3.5 bg-krona-surface border border-krona-border rounded-xl text-xs text-krona-secondary flex flex-wrap gap-4">
<div>
Создано:{' '}
<strong className="text-krona-primary">
{record.audit?.createdAt
? formatDateTime(String(record.audit.createdAt))
: record.created_at
? formatDateTime(String(record.created_at))
: '—'}
</strong>
</div>
<div>
Обновлено:{' '}
<strong className="text-krona-primary">
{formatDateTime(String(record.updatedAt || new Date().toISOString()))}
</strong>
</div>
<div>
Версия:{' '}
<strong className="text-krona-primary">v{record.audit?.version || 1}</strong>
</div>
</div>
</div>
)}
{/* Tab: Timeline / Audit Log */}
{activeTab === 'timeline' && (
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3">
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
История изменений записи
</h3>
<div className="space-y-3">
{auditLogs.map((log) => (
<div
key={log.id}
className="p-3 bg-krona-workspace rounded-lg border border-krona-border/60 flex items-start justify-between gap-3 text-xs"
>
<div className="flex items-start gap-2.5">
<div className="w-2 h-2 rounded-full bg-krona-green mt-1.5 shrink-0" />
<div>
<p className="font-semibold text-krona-primary">{log.summary}</p>
<p className="text-[11px] text-krona-secondary">
Действие: <code className="font-mono">{log.action}</code> • Пользователь: {log.userName || log.userId}
</p>
</div>
</div>
<span className="text-[11px] text-krona-secondary shrink-0 font-mono">
{formatDateTime(log.timestamp)}
</span>
</div>
))}
{auditLogs.length === 0 && (
<div className="py-8 text-center text-xs text-krona-secondary">
Лента аудита пока пуста для этой записи.
</div>
)}
</div>
</div>
)}
{/* Tab: Raw JSON */}
{activeTab === 'json' && (
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Исходный документ (IndexedDB JSON)
</span>
<Button variant="outline" size="sm" onClick={handleCopyJson}>
{copiedJson ? <Check className="w-3 h-3 mr-1 text-krona-green" /> : <Copy className="w-3 h-3 mr-1" />}
{copiedJson ? 'Скопировано' : 'Копировать'}
</Button>
</div>
<pre className="p-3 bg-krona-workspace border border-krona-border rounded-lg text-xs font-mono text-krona-primary overflow-x-auto max-h-96">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
</div>
);
};
export default DynamicCardRenderer;