sync: migrate erp-mvp to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user