Розробка 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 модулями — від півтора до двох років при тій же команді.
Спроба зробити все одразу без ітеративного підходу — гарантований провал. Правильна стратегія: запуск з мінімальним робочим набором модулів, постійний зворотний зв'язок від реальних користувачів, ітеративне розширення.







