396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
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;
|