Files
erp-mvp/frontend/src/pages/CatalogPage.tsx
T

315 lines
10 KiB
TypeScript

import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import type { CatalogListParams } from '../api/catalogApi';
import { getApiErrorMessage } from '../api/errors';
import type { PageResponse } from '../api/types';
import { useAuth } from '../auth/AuthContext';
import { DataTable, type TableColumn } from '../components/DataTable';
import { Layout } from '../components/Layout';
import { Badge } from '../components/ui/Badge';
import { Button } from '../components/ui/Button';
import { Card } from '../components/ui/Card';
import { ConfirmDialog } from '../components/ui/ConfirmDialog';
import { ErrorState } from '../components/ui/ErrorState';
import { FormField } from '../components/ui/FormField';
import { Input } from '../components/ui/Input';
import { LoadingState } from '../components/ui/LoadingState';
import { PageHeader } from '../components/ui/PageHeader';
import { Pagination } from '../components/ui/Pagination';
import { Select } from '../components/ui/Select';
import { Textarea } from '../components/ui/Textarea';
type CatalogItem = {
id: string;
active: boolean;
};
export type CatalogFormState = Record<string, string>;
export type CatalogField = {
name: string;
label: string;
required?: boolean;
type?: 'text' | 'email' | 'textarea';
disabledOnEdit?: boolean;
};
type CatalogPageProps<TItem extends CatalogItem> = {
title: string;
description: string;
columns: TableColumn<TItem>[];
fields: CatalogField[];
emptyForm: CatalogFormState;
list: (params: CatalogListParams) => Promise<PageResponse<TItem>>;
create: (form: CatalogFormState) => Promise<unknown>;
update: (id: string, form: CatalogFormState, item: TItem) => Promise<unknown>;
remove: (id: string) => Promise<unknown>;
};
const PAGE_SIZE = 20;
export function CatalogPage<TItem extends CatalogItem>({
title,
description,
columns,
fields,
emptyForm,
list,
create,
update,
remove,
}: CatalogPageProps<TItem>) {
const { user } = useAuth();
const canWrite = user?.role === 'ADMIN' || user?.role === 'MANAGER';
const [pageData, setPageData] = useState<PageResponse<TItem> | null>(null);
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [activeFilter, setActiveFilter] = useState<'true' | 'false'>('true');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<CatalogFormState>(emptyForm);
const [editingItem, setEditingItem] = useState<TItem | null>(null);
const [deactivateItem, setDeactivateItem] = useState<TItem | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const loadData = useCallback(async () => {
setIsLoading(true);
setError('');
try {
const data = await list({
page,
size: PAGE_SIZE,
search,
active: activeFilter === 'true',
});
setPageData(data);
} catch (caughtError) {
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить записи справочника'));
} finally {
setIsLoading(false);
}
}, [activeFilter, list, page, search]);
useEffect(() => {
void loadData();
}, [loadData]);
const tableColumns = useMemo<TableColumn<TItem>[]>(
() => [
...columns,
{
key: 'active',
label: 'Статус',
render: (item) => <Badge variant={item.active ? 'success' : 'neutral'}>{item.active ? 'Активен' : 'Неактивен'}</Badge>,
},
],
[columns],
);
const openCreateForm = () => {
setEditingItem(null);
setForm(emptyForm);
setIsFormOpen(true);
setError('');
};
const openEditForm = (item: TItem) => {
const nextForm = fields.reduce<CatalogFormState>((acc, field) => {
const value = item[field.name as keyof TItem];
acc[field.name] = value == null ? '' : String(value);
return acc;
}, {});
setEditingItem(item);
setForm(nextForm);
setIsFormOpen(true);
setError('');
};
const closeForm = () => {
setEditingItem(null);
setForm(emptyForm);
setIsFormOpen(false);
};
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPage(0);
setSearch(searchInput.trim());
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
setError('');
try {
if (editingItem) {
await update(editingItem.id, form, editingItem);
} else {
await create(form);
setPage(0);
}
closeForm();
await loadData();
} catch (caughtError) {
setError(getApiErrorMessage(caughtError, 'Не удалось сохранить запись'));
} finally {
setIsSaving(false);
}
};
const handleDeactivate = async () => {
if (!deactivateItem) {
return;
}
setError('');
try {
await remove(deactivateItem.id);
setDeactivateItem(null);
await loadData();
} catch (caughtError) {
setError(getApiErrorMessage(caughtError, 'Не удалось деактивировать запись'));
}
};
return (
<Layout title={title}>
<div className="space-y-6">
<PageHeader
title={title}
description={description}
actions={
canWrite ? (
<Button type="button" onClick={openCreateForm}>
Создать
</Button>
) : null
}
/>
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 rounded-md border border-slate-200 bg-white p-4 shadow-sm sm:flex-row">
<Input
type="search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Поиск"
className="min-w-0 flex-1"
/>
<Select
value={activeFilter}
onChange={(event) => {
setPage(0);
setActiveFilter(event.target.value as 'true' | 'false');
}}
>
<option value="true">Только активные</option>
<option value="false">Только неактивные</option>
</Select>
<Button type="submit" variant="secondary">
Найти
</Button>
</form>
{isFormOpen && (
<Card>
<form onSubmit={handleSubmit} className="space-y-4 p-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-base font-semibold">{editingItem ? 'Редактировать запись' : 'Создать запись'}</h2>
<Button type="button" variant="ghost" size="sm" onClick={closeForm}>
Отмена
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => {
const disabled = Boolean(editingItem && field.disabledOnEdit);
return (
<FormField key={field.name} label={field.label} required={field.required}>
{field.type === 'textarea' ? (
<Textarea
value={form[field.name] ?? ''}
onChange={(event) => setForm((current) => ({ ...current, [field.name]: event.target.value }))}
required={field.required}
disabled={disabled}
/>
) : (
<Input
type={field.type ?? 'text'}
value={form[field.name] ?? ''}
onChange={(event) => setForm((current) => ({ ...current, [field.name]: event.target.value }))}
required={field.required}
disabled={disabled}
/>
)}
</FormField>
);
})}
</div>
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
</form>
</Card>
)}
{error && <ErrorState message={error} />}
{isLoading ? (
<LoadingState />
) : (
<DataTable
items={pageData?.items ?? []}
columns={tableColumns}
getRowKey={(item) => item.id}
emptyTitle="Записи не найдены"
actions={
canWrite
? (item) => (
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => openEditForm(item)}>
Изменить
</Button>
<Button type="button" variant="danger" size="sm" onClick={() => setDeactivateItem(item)}>
Деактивировать
</Button>
</div>
)
: undefined
}
/>
)}
{pageData && (
<Pagination
page={pageData.page}
totalPages={pageData.totalPages}
totalElements={pageData.totalElements}
hasPrevious={pageData.hasPrevious}
hasNext={pageData.hasNext}
onPrevious={() => setPage((current) => Math.max(current - 1, 0))}
onNext={() => setPage((current) => current + 1)}
/>
)}
<ConfirmDialog
open={Boolean(deactivateItem)}
title="Деактивировать запись"
message="Запись будет помечена как неактивная. История и связанные данные останутся доступными."
confirmLabel="Деактивировать"
isConfirming={isSaving}
onCancel={() => setDeactivateItem(null)}
onConfirm={handleDeactivate}
/>
</div>
</Layout>
);
}