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

396 lines
16 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, useMemo } from 'react';
import { EntityMetadata, FieldDefinition } from '../../entities/types';
import { ViewDefinition } from '../../services/contracts/metadata';
import { RecordEntity } from '../../services/storage/indexedDb';
import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input';
import { Badge } from '../../shared/ui/Badge';
import { formatFieldValue } from '../../shared/lib/fieldEngine';
import {
Search,
ArrowUpDown,
ArrowUp,
ArrowDown,
Eye,
Edit2,
Trash2,
Plus,
Inbox,
} from 'lucide-react';
export interface DynamicTableProps {
entity: EntityMetadata;
records: RecordEntity[];
view?: ViewDefinition | null;
isLoading?: boolean;
onRowClick?: (record: RecordEntity) => void;
onEdit?: (record: RecordEntity) => void;
onDelete?: (record: RecordEntity) => void;
onCreateNew?: () => void;
}
export const DynamicTable: React.FC<DynamicTableProps> = ({
entity,
records,
view,
isLoading = false,
onRowClick,
onEdit,
onDelete,
onCreateNew,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<string>(view?.sortBy || 'updatedAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(view?.sortOrder || 'desc');
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 15;
const fieldKeyMap = useMemo(() => {
const map = new Map<string, FieldDefinition>();
entity.fields.forEach((f) => map.set(f.key, f));
return map;
}, [entity]);
// Determine which columns to display
const columns = useMemo<FieldDefinition[]>(() => {
if (view?.visibleFieldKeys && view.visibleFieldKeys.length > 0) {
return view.visibleFieldKeys
.map((k) => fieldKeyMap.get(k))
.filter((f): f is FieldDefinition => Boolean(f));
}
// Fallback: first 6 non-hidden fields
return entity.fields.filter((f) => !f.hidden).slice(0, 6);
}, [view, fieldKeyMap, entity]);
// Handle sort click
const handleHeaderClick = (key: string) => {
if (sortField === key) {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(key);
setSortOrder('asc');
}
};
// Filter & sort
const filteredRecords = useMemo(() => {
let result = [...records];
// Search filter
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
result = result.filter((row) => {
return Object.values(row).some((val) => {
if (typeof val === 'string') return val.toLowerCase().includes(q);
if (typeof val === 'number') return String(val).includes(q);
return false;
});
});
}
// Sort
if (sortField) {
result.sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
if (aVal === bVal) return 0;
if (aVal === undefined || aVal === null) return 1;
if (bVal === undefined || bVal === null) return -1;
const comp = aVal > bVal ? 1 : -1;
return sortOrder === 'desc' ? -comp : comp;
});
}
return result;
}, [records, searchQuery, sortField, sortOrder]);
// Paginated records
const totalPages = Math.ceil(filteredRecords.length / pageSize) || 1;
const paginatedRecords = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return filteredRecords.slice(start, start + pageSize);
}, [filteredRecords, currentPage, pageSize]);
return (
<div className="space-y-3">
{/* Table Top Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-krona-surface border border-krona-border rounded-xl">
<div className="relative w-full sm:w-80">
<Input
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
placeholder="Поиск по записям..."
icon={<Search className="w-3.5 h-3.5" />}
className="text-xs py-1.5"
/>
</div>
<div className="flex items-center gap-3 text-xs text-krona-secondary justify-between sm:justify-end">
<span>
Найдено записей: <strong className="text-krona-primary">{filteredRecords.length}</strong>
</span>
{onCreateNew && (
<Button variant="primary" size="sm" onClick={onCreateNew}>
<Plus className="w-3.5 h-3.5 mr-1" />
Создать
</Button>
)}
</div>
</div>
{/* Mobile Card List View (< 768px) */}
<div className="block md:hidden space-y-2.5" data-testid="mobile-table-cards">
{paginatedRecords.map((row) => {
const primaryCol = columns.find((c) => c.key === entity.primaryFieldKey) || columns[0];
const primaryVal = primaryCol ? formatFieldValue(primaryCol.type, row[primaryCol.key], primaryCol) : row.id;
const statusCol = columns.find((c) => c.key === 'status' || c.type === 'status');
const statusVal = statusCol ? formatFieldValue(statusCol.type, row[statusCol.key], statusCol) : null;
const otherCols = columns.filter((c) => c.key !== primaryCol?.key && c.key !== statusCol?.key).slice(0, 4);
return (
<div
key={row.id}
onClick={() => onRowClick && onRowClick(row)}
className="p-3.5 bg-krona-surface border border-krona-border rounded-xl shadow-xs space-y-2.5 active:bg-krona-workspace/60 transition-colors cursor-pointer"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-bold text-xs text-krona-primary truncate">
{primaryVal}
</div>
<div className="text-[10px] text-krona-secondary font-mono truncate">
ID: {row.id.slice(0, 10)}...
</div>
</div>
{statusVal && (
<Badge variant="neutral" size="sm">
{statusVal}
</Badge>
)}
</div>
{otherCols.length > 0 && (
<div className="grid grid-cols-2 gap-2 text-xs pt-2 border-t border-krona-border/60">
{otherCols.map((c) => (
<div key={c.key} className="space-y-0.5">
<div className="text-[10px] text-krona-secondary uppercase truncate">
{c.label}
</div>
<div className="text-xs text-krona-primary font-medium truncate">
{formatFieldValue(c.type, row[c.key], c)}
</div>
</div>
))}
</div>
)}
{/* Mobile Card Actions */}
<div
className="flex items-center justify-end gap-2 pt-2 border-t border-krona-border/60"
onClick={(e) => e.stopPropagation()}
>
{onRowClick && (
<Button variant="secondary" size="sm" onClick={() => onRowClick(row)} className="text-xs py-1 px-2.5 min-h-[36px]">
<Eye className="w-3.5 h-3.5 mr-1" />
<span>Открыть</span>
</Button>
)}
{onEdit && (
<Button variant="outline" size="sm" onClick={() => onEdit(row)} className="text-xs py-1 px-2.5 min-h-[36px]">
<Edit2 className="w-3.5 h-3.5 mr-1" />
<span>Изменить</span>
</Button>
)}
{onDelete && (
<Button variant="danger" size="sm" onClick={() => onDelete(row)} className="text-xs py-1 px-2.5 min-h-[36px]">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
);
})}
{paginatedRecords.length === 0 && !isLoading && (
<div className="p-8 text-center text-krona-secondary bg-krona-surface border border-krona-border rounded-xl">
<Inbox className="w-8 h-8 text-krona-secondary mx-auto mb-2 opacity-50" />
<p className="font-semibold text-krona-primary text-xs">Записей пока нет</p>
{onCreateNew && (
<Button variant="primary" size="sm" onClick={onCreateNew} className="mt-2 text-xs">
<Plus className="w-3.5 h-3.5 mr-1" />
Создать запись
</Button>
)}
</div>
)}
</div>
{/* Desktop/Tablet Table Element (>= 768px) */}
<div className="hidden md:block bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace border-b border-krona-border select-none">
<tr>
{columns.map((col) => {
const isSorted = sortField === col.key;
return (
<th
key={col.key}
onClick={() => handleHeaderClick(col.key)}
className="p-3 font-semibold text-krona-primary hover:bg-krona-surface-muted/60 cursor-pointer transition-colors whitespace-nowrap"
>
<div className="flex items-center gap-1.5">
<span>{col.label}</span>
{isSorted ? (
sortOrder === 'asc' ? (
<ArrowUp className="w-3 h-3 text-krona-green" />
) : (
<ArrowDown className="w-3 h-3 text-krona-green" />
)
) : (
<ArrowUpDown className="w-3 h-3 text-krona-secondary opacity-40 hover:opacity-100" />
)}
</div>
</th>
);
})}
<th className="p-3 text-right font-semibold text-krona-secondary w-28">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/60 bg-krona-surface">
{paginatedRecords.map((row) => (
<tr
key={row.id}
onClick={() => onRowClick && onRowClick(row)}
className="hover:bg-krona-workspace/60 transition-colors cursor-pointer group"
>
{columns.map((col) => {
const val = row[col.key];
const display = formatFieldValue(col.type, val, col);
return (
<td key={col.key} className="p-3 text-krona-secondary truncate max-w-xs">
{col.type === 'color' && val ? (
<div className="flex items-center gap-1.5">
<span
className="w-3 h-3 rounded-full border border-krona-border/60 shrink-0"
style={{ backgroundColor: String(val) }}
/>
<span className="font-mono text-[11px]">{display}</span>
</div>
) : col.type === 'boolean' && val !== undefined && val !== null ? (
<Badge variant={val ? 'green' : 'neutral'} size="sm">
{val ? 'Да' : 'Нет'}
</Badge>
) : col.key === 'status' || col.type === 'status' ? (
<Badge variant="neutral" size="sm">
{display}
</Badge>
) : col.key === entity.primaryFieldKey || col.key === 'name' ? (
<span className="font-semibold text-krona-primary">{display}</span>
) : (
<span>{display}</span>
)}
</td>
);
})}
{/* Actions Column */}
<td
className="p-3 text-right"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-end gap-1 opacity-70 group-hover:opacity-100">
{onRowClick && (
<button
type="button"
onClick={() => onRowClick(row)}
className="p-1 hover:bg-krona-workspace rounded text-krona-secondary hover:text-krona-primary"
title="Просмотреть карточку"
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
{onEdit && (
<button
type="button"
onClick={() => onEdit(row)}
className="p-1 hover:bg-krona-workspace rounded text-krona-secondary hover:text-krona-primary"
title="Редактировать запись"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => onDelete(row)}
className="p-1 hover:bg-krona-danger/10 rounded text-krona-danger"
title="Удалить запись"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
{paginatedRecords.length === 0 && !isLoading && (
<tr>
<td colSpan={columns.length + 1} className="p-12 text-center text-krona-secondary">
<Inbox className="w-8 h-8 text-krona-secondary mx-auto mb-2 opacity-50" />
<p className="font-semibold text-krona-primary">Записей пока нет</p>
<p className="text-xs text-krona-secondary mb-3">
Создайте первую запись в сущности «{entity.name}»
</p>
{onCreateNew && (
<Button variant="primary" size="sm" onClick={onCreateNew}>
<Plus className="w-3.5 h-3.5 mr-1" />
Создать запись
</Button>
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination Footer */}
{totalPages > 1 && (
<div className="flex items-center justify-between p-3 border-t border-krona-border text-xs text-krona-secondary">
<span>
Страница {currentPage} из {totalPages}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
Назад
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
>
Вперёд
</Button>
</div>
</div>
)}
</div>
</div>
);
};
export default DynamicTable;