Разработка CRM-системы (веб-интерфейс)
CRM-система в большинстве случаев — это не про «управление отношениями с клиентами» в маркетинговом смысле, а про конкретный инструмент: очередь задач, карточка клиента, воронка сделок, история коммуникаций. Задача разработки веб-интерфейса — реализовать именно тот набор функций, который нужен конкретному бизнесу, без лишних модулей, нагромождения настроек и лицензионных ограничений.
Что входит в типовой интерфейс CRM
Минимальный набор сущностей для большинства B2B-компаний:
- Контакты — физические лица с историей взаимодействий
- Компании — юридические лица, к которым привязаны контакты
- Сделки — потенциальные и активные продажи со статусами
- Задачи — поручения с дедлайнами, привязанные к сделкам/контактам
- Активности — звонки, письма, встречи (лог коммуникаций)
- Воронка — визуальный Kanban или Pipeline с колонками-статусами
Дополнительно в зависимости от специфики: прайс-листы и коммерческие предложения, интеграция с телефонией, модуль рассылок, отчёты по менеджерам.
Технологический стек
Для веб-интерфейса CRM оптимально работает стек SPA или SSR-приложения с реактивным UI:
Backend:
- Laravel / Node.js (NestJS) в роли API
- PostgreSQL — основная база данных
- Redis — кеш и очереди событий (звонки, уведомления)
- WebSocket (Laravel Echo + Pusher / Socket.io) — realtime-обновления
Frontend:
- React + TypeScript
- React Query для серверного состояния
- Zustand или Redux Toolkit для глобального UI-состояния
- React Hook Form + Zod для форм
- TanStack Table для таблиц с фильтрацией и сортировкой
- @dnd-kit для drag-and-drop воронки
Структура базы данных
CREATE TABLE contacts (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT REFERENCES companies(id),
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
source VARCHAR(64), -- откуда пришёл
responsible_id BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE deals (
id BIGSERIAL PRIMARY KEY,
contact_id BIGINT REFERENCES contacts(id),
company_id BIGINT REFERENCES companies(id),
title VARCHAR(255) NOT NULL,
amount DECIMAL(14,2),
currency CHAR(3) DEFAULT 'RUB',
stage_id BIGINT REFERENCES pipeline_stages(id),
responsible_id BIGINT REFERENCES users(id),
closed_at DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE pipeline_stages (
id BIGSERIAL PRIMARY KEY,
pipeline_id BIGINT REFERENCES pipelines(id),
name VARCHAR(128) NOT NULL,
sort_order INT DEFAULT 0,
is_won BOOLEAN DEFAULT FALSE,
is_lost BOOLEAN DEFAULT FALSE
);
CREATE TABLE activities (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(32) NOT NULL, -- 'contact', 'deal', 'company'
entity_id BIGINT NOT NULL,
type VARCHAR(32) NOT NULL, -- 'call', 'email', 'meeting', 'note'
body TEXT,
user_id BIGINT REFERENCES users(id),
happened_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON activities(entity_type, entity_id);
API-дизайн
RESTful JSON API с ресурсо-ориентированной структурой:
GET /api/deals?stage_id=2&responsible_id=5&page=1&per_page=50
POST /api/deals
PATCH /api/deals/{id}
DELETE /api/deals/{id}
POST /api/deals/{id}/move # смена стадии
POST /api/activities # добавить активность к любой сущности
GET /api/contacts/{id}/timeline # хронология взаимодействий
Пример PATCH-ответа при смене стадии:
{
"id": 1042,
"stage_id": 4,
"stage": { "id": 4, "name": "Переговоры" },
"updated_at": "2025-03-15T14:22:00Z",
"activity": {
"id": 3891,
"type": "stage_change",
"body": "Стадия изменена: Квалификация → Переговоры",
"user_id": 12
}
}
Воронка: drag-and-drop Kanban
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
const Pipeline: React.FC<{ stages: Stage[]; deals: Deal[] }> = ({ stages, deals }) => {
const moveDeal = useMutation({
mutationFn: ({ dealId, stageId }: { dealId: number; stageId: number }) =>
api.patch(`/deals/${dealId}/move`, { stage_id: stageId }),
onMutate: async ({ dealId, stageId }) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: ['deals'] });
const prev = queryClient.getQueryData(['deals']);
queryClient.setQueryData(['deals'], (old: Deal[]) =>
old.map(d => d.id === dealId ? { ...d, stage_id: stageId } : d)
);
return { prev };
},
onError: (_, __, context) => {
queryClient.setQueryData(['deals'], context?.prev);
},
});
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
moveDeal.mutate({ dealId: Number(active.id), stageId: Number(over.id) });
};
return (
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<div className="flex gap-4 overflow-x-auto p-4">
{stages.map(stage => (
<KanbanColumn
key={stage.id}
stage={stage}
deals={deals.filter(d => d.stage_id === stage.id)}
/>
))}
</div>
</DndContext>
);
};
Optimistic update важен — пользователь видит карточку на новой позиции мгновенно, без ожидания ответа сервера.
Timeline активностей
Хронология взаимодействий — одна из ключевых частей CRM. Реализуется как полиморфная лента:
type ActivityItem =
| { type: 'call'; duration: number; result: string }
| { type: 'email'; subject: string; direction: 'in' | 'out' }
| { type: 'note'; body: string }
| { type: 'stage_change'; from: string; to: string };
const ActivityFeed: React.FC<{ entityType: string; entityId: number }> = (props) => {
const { data } = useQuery({
queryKey: ['timeline', props.entityType, props.entityId],
queryFn: () => api.get(`/${props.entityType}s/${props.entityId}/timeline`),
});
return (
<div className="space-y-3">
<AddActivityForm entityType={props.entityType} entityId={props.entityId} />
{data?.items.map(item => (
<ActivityCard key={item.id} item={item} />
))}
</div>
);
};
Права доступа
CRM требует гранулярных прав: менеджер видит только своих клиентов, руководитель — всех в своём отделе. Реализуется через Policy-классы:
// Laravel Policy
class DealPolicy {
public function viewAny(User $user): bool {
return $user->hasPermission('deals.view');
}
public function view(User $user, Deal $deal): bool {
if ($user->hasRole('admin')) return true;
if ($user->hasRole('team_lead')) {
return $deal->responsible->team_id === $user->team_id;
}
return $deal->responsible_id === $user->id;
}
public function update(User $user, Deal $deal): bool {
return $user->hasRole('admin') || $deal->responsible_id === $user->id;
}
}
Scope на уровне Eloquent:
class Deal extends Model {
public function scopeVisibleTo(Builder $query, User $user): Builder {
if ($user->hasRole('admin')) return $query;
if ($user->hasRole('team_lead')) {
return $query->whereHas('responsible', fn($q) =>
$q->where('team_id', $user->team_id)
);
}
return $query->where('responsible_id', $user->id);
}
}
Realtime-уведомления
Когда сделка назначается другому менеджеру или по задаче наступает дедлайн — уведомление приходит без обновления страницы:
// Frontend: подключение к каналу пользователя
import Echo from 'laravel-echo';
const echo = new Echo({ broadcaster: 'pusher', ... });
echo.private(`user.${currentUser.id}`).listen('DealAssigned', (event) => {
toast.info(`Вам назначена сделка: ${event.deal.title}`);
queryClient.invalidateQueries({ queryKey: ['deals'] });
});
// Backend: событие
class DealAssigned implements ShouldBroadcast {
public function broadcastOn(): PrivateChannel {
return new PrivateChannel('user.' . $this->deal->responsible_id);
}
}
Сроки реализации
MVP с воронкой, карточками контактов/сделок, задачами и базовой аналитикой: 4–6 недель. Добавление интеграций (телефония, email, мессенджеры), расширенных отчётов, ролей и мультиязычности: плюс 3–4 недели. Мобильная версия (PWA или React Native): плюс 3–4 недели.
Основная часть времени уходит не на код, а на проработку бизнес-логики: какие поля обязательны, какие переходы между стадиями разрешены, кто видит чьи данные, какие уведомления критичны.







