Розробка 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 важливий — користувач бачить карточку на новій позиції миттєво, без очікування відповіді сервера.
Графік активностей
Хронологія взаємодій — одна з ключових частин 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 тижні.
Основна частина часу уходит не на код, а на проробку бізнес-логіки: які поля обов'язкові, які переходи між стадіями дозволені, хто бачить чиї дані, які сповіщення критичні.







