Разработка ERP-системы (веб-интерфейс)
ERP-система для среднего бизнеса — это десятки взаимосвязанных модулей: склад, закупки, продажи, производство, финансы, HR. Веб-интерфейс — лишь часть, но критическая: именно здесь сотрудники проводят восемь часов в день, и неудачный UX напрямую стоит денег в виде ошибок и медленной работы.
Архитектурный выбор: SPA vs SSR vs гибрид
Для ERP выбор однозначный — SPA (Single Page Application). Причины:
Интенсивное взаимодействие: формы с десятками полей, модальные окна, drag-and-drop таблицы, inline-редактирование. Серверный рендеринг каждого изменения — не вариант.
Персонализация: каждый пользователь видит свой набор модулей, своё рабочее пространство.
Оффлайн-режим: склад на производстве может иметь нестабильный интернет — PWA с IndexedDB позволяет работать и синхронизироваться позже.
Стек для серьёзного ERP-интерфейса
Frontend:
- React 18+ (Concurrent Features для тяжёлых таблиц)
- TypeScript (строгий, без any в бизнес-логике)
- TanStack Table v8 (виртуализация, 100k+ строк)
- TanStack Query (серверное состояние, кэш, оптимистичные обновления)
- React Hook Form + Zod (сложные формы с вложенными объектами)
- Zustand (глобальное UI-состояние: открытые панели, фильтры)
Backend (для веб-клиента):
- REST API или tRPC
- GraphQL оправдан, если модули независимо разрабатываются разными командами
Компонентная библиотека:
- Radix UI + Tailwind (кастомизируемость без CSS-конфликтов)
или Ant Design / Mantine (быстрый старт, богатые компоненты)
Ключевые технические задачи
1. Работа с большими таблицами
Таблица на 50 000 строк — типичная задача для складского учёта или отчётности. Без виртуализации браузер зависает.
// VirtualizedTable.tsx
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
interface VirtualizedTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
rowHeight?: number;
}
export function VirtualizedTable<T>({
data,
columns,
rowHeight = 40,
}: VirtualizedTableProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const { rows } = table.getRowModel();
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 20,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div ref={parentRef} className="overflow-auto h-full">
<table className="w-full border-collapse">
<thead className="sticky top-0 bg-white z-10 shadow-sm">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
style={{ width: header.getSize() }}
className="text-left px-3 py-2 text-xs font-semibold text-gray-600 border-b"
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{/* Пустое пространство сверху */}
{virtualItems.length > 0 && (
<tr style={{ height: virtualItems[0].start }}>
<td colSpan={columns.length} />
</tr>
)}
{virtualItems.map(virtualRow => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
className="hover:bg-gray-50 border-b border-gray-100"
style={{ height: rowHeight }}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-3 py-2 text-sm">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
{/* Пустое пространство снизу */}
{virtualItems.length > 0 && (
<tr style={{ height: totalSize - virtualItems[virtualItems.length - 1].end }}>
<td colSpan={columns.length} />
</tr>
)}
</tbody>
</table>
</div>
);
}
2. Сложные формы с зависимыми полями
ERP-форма создания заказа может включать: выбор контрагента → загрузка его договоров → выбор договора → автозаполнение условий оплаты → добавление позиций → пересчёт сумм.
// OrderForm.tsx (фрагмент)
import { useForm, useFieldArray, useWatch } from 'react-hook-form';
import { useQuery } from '@tanstack/react-query';
function OrderForm() {
const { control, register, setValue, watch } = useForm<OrderFormData>({
resolver: zodResolver(orderSchema),
defaultValues: {
lines: [{ productId: '', qty: 1, price: 0, discount: 0 }],
},
});
const { fields, append, remove } = useFieldArray({ control, name: 'lines' });
const contractorId = watch('contractorId');
// При смене контрагента загружаем его договоры
const { data: contracts } = useQuery({
queryKey: ['contracts', contractorId],
queryFn: () => fetchContracts(contractorId),
enabled: !!contractorId,
});
// Автозаполнение условий из договора
function handleContractSelect(contractId: string) {
const contract = contracts?.find(c => c.id === contractId);
if (contract) {
setValue('paymentTermsDays', contract.paymentTermsDays);
setValue('currencyCode', contract.currencyCode);
setValue('vatRate', contract.vatRate);
}
}
// Пересчёт итогов при изменении любой строки
const lines = useWatch({ control, name: 'lines' });
const totals = useMemo(() => {
return lines.reduce((acc, line) => {
const subtotal = line.qty * line.price * (1 - (line.discount ?? 0) / 100);
return {
subtotal: acc.subtotal + subtotal,
vat: acc.vat + subtotal * (line.vatRate ?? 0.2),
};
}, { subtotal: 0, vat: 0 });
}, [lines]);
// ... JSX
}
3. Оптимистичные обновления для скорости отклика
Пользователь меняет статус заказа — интерфейс должен реагировать немедленно, не ждать ответа сервера:
const queryClient = useQueryClient();
const updateStatus = useMutation({
mutationFn: (data: { orderId: string; status: OrderStatus }) =>
api.patch(`/orders/${data.orderId}/status`, { status: data.status }),
onMutate: async ({ orderId, status }) => {
// Отменяем текущие запросы для этого заказа
await queryClient.cancelQueries({ queryKey: ['orders', orderId] });
// Сохраняем текущее состояние для отката
const prev = queryClient.getQueryData(['orders', orderId]);
// Оптимистично обновляем
queryClient.setQueryData(['orders', orderId], (old: Order) => ({
...old, status,
}));
return { prev };
},
onError: (_err, { orderId }, context) => {
// Откат при ошибке
queryClient.setQueryData(['orders', orderId], context?.prev);
toast.error('Не удалось изменить статус');
},
onSettled: (_, __, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
},
});
4. Разграничение доступа к модулям
// PermissionGuard.tsx
import { useAuth } from '@/stores/auth';
interface PermissionGuardProps {
permission: string; // 'orders:create', 'inventory:write'
fallback?: ReactNode;
children: ReactNode;
}
export function PermissionGuard({ permission, fallback, children }: PermissionGuardProps) {
const { user } = useAuth();
const hasPermission = user?.permissions.includes(permission)
|| user?.roles.some(role => ROLE_PERMISSIONS[role]?.includes(permission));
if (!hasPermission) {
return fallback ? <>{fallback}</> : null;
}
return <>{children}</>;
}
// Использование
<PermissionGuard permission="orders:create" fallback={<ReadOnlyBadge />}>
<CreateOrderButton />
</PermissionGuard>
Производительность ERP-интерфейса
Несколько обязательных оптимизаций:
Code splitting по модулям — пользователь склада не загружает модуль HR:
const routes = [
{
path: '/warehouse/*',
element: React.lazy(() => import('@/modules/warehouse')),
permission: 'warehouse:view',
},
{
path: '/hr/*',
element: React.lazy(() => import('@/modules/hr')),
permission: 'hr:view',
},
];
Дебаунс для поиска и фильтров — не отправляем запрос после каждого нажатия.
Мемоизация тяжёлых вычислений — отчёты с агрегацией в браузере (не всегда можно сделать на сервере) через useMemo.
Сроки
ERP-интерфейс не разрабатывается «с нуля за три месяца». Реалистичные рамки:
MVP с четырьмя-пятью ключевыми модулями (заказы, склад, справочники, отчётность, пользователи) — шесть-восемь месяцев для команды из трёх-четырёх разработчиков.
Полноценная система с 15–20 модулями — от полутора до двух лет при той же команде.
Попытка сделать всё сразу без итеративного подхода — гарантированный провал. Правильная стратегия: запуск с минимальным работающим набором модулей, постоянная обратная связь от реальных пользователей, итеративное расширение.







