374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { useMetadata } from '../../services/hooks/useMetadata';
|
||
import { BaseMockRepository } from '../../services/mock/baseRepository';
|
||
import { RecordEntity } from '../../services/storage/indexedDb';
|
||
import { ViewDefinition } from '../../services/contracts/metadata';
|
||
import { DynamicTable } from './DynamicTable';
|
||
import { DynamicFormRenderer } from './DynamicFormRenderer';
|
||
import { DynamicCardRenderer } from './DynamicCardRenderer';
|
||
import { Button } from '../../shared/ui/Button';
|
||
import { Badge } from '../../shared/ui/Badge';
|
||
import { Modal } from '../../shared/ui/Modal';
|
||
import { Drawer } from '../../shared/ui/Drawer';
|
||
import { useToast } from '../../shared/ui/useToast';
|
||
import {
|
||
Layers,
|
||
Plus,
|
||
Table,
|
||
CreditCard,
|
||
Sliders,
|
||
Layout,
|
||
Sparkles,
|
||
Lock,
|
||
} from 'lucide-react';
|
||
|
||
import { BaseEntity } from '../../entities/types';
|
||
|
||
export const CustomEntityPage: React.FC = () => {
|
||
const { entityCode, recordId } = useParams<{ entityCode: string; recordId?: string }>();
|
||
const navigate = useNavigate();
|
||
const { currentEntity, views, layouts, loading } = useMetadata(entityCode);
|
||
const { showToast } = useToast();
|
||
|
||
const [records, setRecords] = useState<RecordEntity[]>([]);
|
||
const [isLoadingRecords, setIsLoadingRecords] = useState(true);
|
||
|
||
// Active View
|
||
const [activeViewId, setActiveViewId] = useState<string>('');
|
||
|
||
// Modals & Drawers
|
||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||
const [editingRecord, setEditingRecord] = useState<RecordEntity | null>(null);
|
||
const [viewingRecord, setViewingRecord] = useState<RecordEntity | null>(null);
|
||
const [recordToDelete, setRecordToDelete] = useState<RecordEntity | null>(null);
|
||
|
||
// Create repository instance for this entity
|
||
const repository = useMemo(() => {
|
||
if (!entityCode) return null;
|
||
return new BaseMockRepository<RecordEntity & BaseEntity>(entityCode);
|
||
}, [entityCode]);
|
||
|
||
// Fetch records
|
||
const loadRecords = useCallback(async () => {
|
||
if (!repository) return;
|
||
try {
|
||
setIsLoadingRecords(true);
|
||
const res = await repository.list({ pageSize: 100 });
|
||
setRecords(res.items);
|
||
} catch (err) {
|
||
showToast({
|
||
type: 'error',
|
||
title: 'Ошибка загрузки записей',
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
} finally {
|
||
setIsLoadingRecords(false);
|
||
}
|
||
}, [repository, showToast]);
|
||
|
||
useEffect(() => {
|
||
void Promise.resolve().then(() => {
|
||
loadRecords();
|
||
});
|
||
}, [loadRecords]);
|
||
|
||
// Set default view
|
||
useEffect(() => {
|
||
void Promise.resolve().then(() => {
|
||
if (views && views.length > 0 && !activeViewId) {
|
||
const def = views.find((v) => v.isDefault) || views[0];
|
||
setActiveViewId(def.id);
|
||
}
|
||
});
|
||
}, [views, activeViewId]);
|
||
|
||
// Handle direct link to recordId
|
||
useEffect(() => {
|
||
if (recordId && repository) {
|
||
repository.getById(recordId).then((rec) => {
|
||
if (rec) setViewingRecord(rec);
|
||
});
|
||
}
|
||
}, [recordId, repository]);
|
||
|
||
const activeView = useMemo<ViewDefinition | null>(() => {
|
||
return views.find((v) => v.id === activeViewId) || null;
|
||
}, [views, activeViewId]);
|
||
|
||
// Form layout (if exists)
|
||
const formLayout = useMemo(() => {
|
||
return layouts.find((l) => l.type === 'form' || l.isDefault) || null;
|
||
}, [layouts]);
|
||
|
||
// Card layout (if exists)
|
||
const cardLayout = useMemo(() => {
|
||
return layouts.find((l) => l.type === 'card' || l.cardBlocks) || null;
|
||
}, [layouts]);
|
||
|
||
// Save new record
|
||
const handleCreateRecord = async (values: Record<string, unknown>) => {
|
||
if (!repository || !currentEntity) return;
|
||
|
||
// Handle autonumbering if enabled
|
||
let recordValues = { ...values };
|
||
if (currentEntity.autonumberingEnabled && !recordValues.number) {
|
||
const year = new Date().getFullYear();
|
||
const count = records.length + 1;
|
||
const formattedNumber = `DOC-${year}-${String(count).padStart(4, '0')}`;
|
||
recordValues = { ...recordValues, number: formattedNumber };
|
||
}
|
||
|
||
await repository.create(recordValues as Omit<RecordEntity, 'id'>);
|
||
showToast({ type: 'success', title: 'Запись успешно создана' });
|
||
setIsCreateModalOpen(false);
|
||
await loadRecords();
|
||
};
|
||
|
||
// Update record
|
||
const handleUpdateRecord = async (values: Record<string, unknown>) => {
|
||
if (!repository || !editingRecord) return;
|
||
await repository.update(editingRecord.id, values);
|
||
showToast({ type: 'success', title: 'Запись обновлена' });
|
||
setEditingRecord(null);
|
||
if (viewingRecord?.id === editingRecord.id) {
|
||
const updated = await repository.getById(editingRecord.id);
|
||
setViewingRecord(updated);
|
||
}
|
||
await loadRecords();
|
||
};
|
||
|
||
// Delete record
|
||
const handleDeleteRecord = async () => {
|
||
if (!repository || !recordToDelete) return;
|
||
try {
|
||
await repository.delete(recordToDelete.id);
|
||
showToast({ type: 'success', title: 'Запись удалена' });
|
||
setRecordToDelete(null);
|
||
if (viewingRecord?.id === recordToDelete.id) {
|
||
setViewingRecord(null);
|
||
}
|
||
await loadRecords();
|
||
} catch (err) {
|
||
showToast({
|
||
type: 'error',
|
||
title: 'Ошибка удаления',
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
};
|
||
|
||
if (!currentEntity && !loading) {
|
||
return (
|
||
<div className="p-8 text-center bg-krona-surface border border-krona-border rounded-xl">
|
||
<Layers className="w-10 h-10 text-krona-secondary mx-auto mb-2 opacity-40" />
|
||
<h2 className="text-base font-bold text-krona-primary mb-1">
|
||
Сущность «{entityCode}» не найдена
|
||
</h2>
|
||
<p className="text-xs text-krona-secondary mb-4">
|
||
Возможно, она была удалена или переименована в каталоге сущностей.
|
||
</p>
|
||
<Button variant="primary" size="sm" onClick={() => navigate('/settings/entities')}>
|
||
Перейти в каталог сущностей
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4 animate-fade-in text-left">
|
||
{/* Top Banner */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-krona-workspace flex items-center justify-center text-krona-green border border-krona-border/60">
|
||
<Layers className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h1 className="text-base font-bold text-krona-primary">
|
||
{currentEntity?.pluralName || currentEntity?.name || entityCode}
|
||
</h1>
|
||
{currentEntity?.isSystem ? (
|
||
<Badge variant="neutral" size="sm">
|
||
<Lock className="w-2.5 h-2.5 mr-1" />
|
||
System
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="gold" size="sm">
|
||
<Sparkles className="w-2.5 h-2.5 mr-1" />
|
||
Custom
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-krona-secondary">
|
||
{currentEntity?.description || `Управление записями сущности ${entityCode}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{/* Settings Shortcuts */}
|
||
<div className="flex items-center bg-krona-workspace rounded-lg p-0.5 border border-krona-border/80 text-xs">
|
||
<button
|
||
onClick={() => navigate(`/settings/entities/${entityCode}/fields`)}
|
||
className="px-2 py-1 text-krona-secondary hover:text-krona-primary rounded"
|
||
title="Настройка полей"
|
||
>
|
||
<Sliders className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => navigate(`/settings/entities/${entityCode}/forms`)}
|
||
className="px-2 py-1 text-krona-secondary hover:text-krona-primary rounded"
|
||
title="Настройка форм"
|
||
>
|
||
<Layout className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => navigate(`/settings/entities/${entityCode}/card`)}
|
||
className="px-2 py-1 text-krona-secondary hover:text-krona-primary rounded"
|
||
title="Настройка карточки"
|
||
>
|
||
<CreditCard className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => navigate(`/settings/entities/${entityCode}/views`)}
|
||
className="px-2 py-1 text-krona-secondary hover:text-krona-primary rounded"
|
||
title="Настройка представлений"
|
||
>
|
||
<Table className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<Button variant="primary" size="sm" onClick={() => setIsCreateModalOpen(true)}>
|
||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||
Создать {currentEntity?.name || 'запись'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Views Tabs (if more than 1 view) */}
|
||
{views.length > 0 && (
|
||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1">
|
||
{views.map((v) => (
|
||
<button
|
||
key={v.id}
|
||
onClick={() => setActiveViewId(v.id)}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${
|
||
activeViewId === v.id
|
||
? 'bg-krona-green text-white shadow-sm'
|
||
: 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border/60'
|
||
}`}
|
||
>
|
||
{v.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Dynamic Records Table */}
|
||
{currentEntity && (
|
||
<DynamicTable
|
||
entity={currentEntity}
|
||
records={records}
|
||
view={activeView}
|
||
isLoading={isLoadingRecords}
|
||
onRowClick={(rec) => setViewingRecord(rec)}
|
||
onEdit={(rec) => setEditingRecord(rec)}
|
||
onDelete={(rec) => setRecordToDelete(rec)}
|
||
onCreateNew={() => setIsCreateModalOpen(true)}
|
||
/>
|
||
)}
|
||
|
||
{/* Create Record Modal */}
|
||
{currentEntity && (
|
||
<Modal
|
||
isOpen={isCreateModalOpen}
|
||
onClose={() => setIsCreateModalOpen(false)}
|
||
title={`Создать ${currentEntity.name}`}
|
||
subtitle={`Заполните реквизиты сущности «${currentEntity.name}»`}
|
||
maxWidth="lg"
|
||
>
|
||
<DynamicFormRenderer
|
||
entity={currentEntity}
|
||
layout={formLayout}
|
||
mode="create"
|
||
onSave={handleCreateRecord}
|
||
onCancel={() => setIsCreateModalOpen(false)}
|
||
/>
|
||
</Modal>
|
||
)}
|
||
|
||
{/* Edit Record Modal */}
|
||
{currentEntity && editingRecord && (
|
||
<Modal
|
||
isOpen={!!editingRecord}
|
||
onClose={() => setEditingRecord(null)}
|
||
title={`Редактирование: ${editingRecord.name || editingRecord.id}`}
|
||
subtitle={`Сущность «${currentEntity.name}»`}
|
||
maxWidth="lg"
|
||
>
|
||
<DynamicFormRenderer
|
||
entity={currentEntity}
|
||
record={editingRecord}
|
||
layout={formLayout}
|
||
mode="edit"
|
||
onSave={handleUpdateRecord}
|
||
onCancel={() => setEditingRecord(null)}
|
||
/>
|
||
</Modal>
|
||
)}
|
||
|
||
{/* View Record Card Drawer */}
|
||
{currentEntity && viewingRecord && (
|
||
<Drawer
|
||
isOpen={!!viewingRecord}
|
||
onClose={() => setViewingRecord(null)}
|
||
title={String(viewingRecord.name || viewingRecord.id)}
|
||
subtitle={`Карточка записи: ${currentEntity.name}`}
|
||
width="xl"
|
||
>
|
||
<DynamicCardRenderer
|
||
entity={currentEntity}
|
||
record={viewingRecord}
|
||
layout={cardLayout}
|
||
onEdit={() => {
|
||
setEditingRecord(viewingRecord);
|
||
setViewingRecord(null);
|
||
}}
|
||
onDelete={() => {
|
||
setRecordToDelete(viewingRecord);
|
||
}}
|
||
onClose={() => setViewingRecord(null)}
|
||
/>
|
||
</Drawer>
|
||
)}
|
||
|
||
{/* Delete Record Confirmation Modal */}
|
||
<Modal
|
||
isOpen={!!recordToDelete}
|
||
onClose={() => setRecordToDelete(null)}
|
||
title="Подтверждение удаления"
|
||
subtitle={`Вы действительно хотите удалить запись «${recordToDelete?.name || recordToDelete?.id}»?`}
|
||
maxWidth="sm"
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-krona-secondary">
|
||
Это действие нельзя будет отменить. Запись будет удалена из базы данных, а в журнал аудита
|
||
будет внесена соответствующая запись.
|
||
</p>
|
||
|
||
<div className="flex justify-end gap-2 pt-2 border-t border-krona-border">
|
||
<Button variant="outline" size="sm" onClick={() => setRecordToDelete(null)}>
|
||
Отмена
|
||
</Button>
|
||
<Button variant="danger" size="sm" onClick={handleDeleteRecord}>
|
||
Удалить запись
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CustomEntityPage;
|