705 lines
25 KiB
TypeScript
705 lines
25 KiB
TypeScript
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;
|