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

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

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка ERP-системы (веб-интерфейс)
Сложная
от 2 недель до 3 месяцев
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

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

Попытка сделать всё сразу без итеративного подхода — гарантированный провал. Правильная стратегия: запуск с минимальным работающим набором модулей, постоянная обратная связь от реальных пользователей, итеративное расширение.