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

374 lines
14 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, 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;