Розробка ERP-системи (веб-інтерфейс)

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка ERP-системи (веб-інтерфейс)
Складна
від 2 тижнів до 3 місяців
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка 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 модулями — від півтора до двох років при тій же команді.

Спроба зробити все одразу без ітеративного підходу — гарантований провал. Правильна стратегія: запуск з мінімальним робочим набором модулів, постійний зворотний зв'язок від реальних користувачів, ітеративне розширення.