Разработка системы управления проектами (Project Management)
Системы управления проектами делятся на два полюса: универсальные SaaS-продукты вроде Jira или Asana и самописные решения под конкретный процесс. Первые подходят большинству, но когда процесс нестандартный — отраслевая специфика, жёсткие требования к данным, интеграция с внутренними системами — разработка собственной системы становится экономически обоснованной. Задача этой страницы — показать, что именно строится и в каком порядке.
Архитектурные решения
Система управления проектами — это прежде всего сложная доменная модель. Центральные сущности: Project → Milestone → Task → Subtask, плюс поперечные: User, Team, Comment, Attachment, TimeLog, Activity. Связи между ними нетривиальны: задача может принадлежать нескольким проектам через эпики, пользователь может иметь разные роли в разных проектах, зависимости между задачами образуют граф (а не дерево).
Два принципиальных архитектурных выбора на старте:
Монолит vs микросервисы. Для проектов до 50 000 активных пользователей монолит с чёткими доменными границами — правильный выбор. Микросервисы оправданы, когда отдельные компоненты масштабируются независимо: например, движок уведомлений под нагрузкой отличается от аналитики. На практике 90% систем PM строятся как модульный монолит и остаются им навсегда.
Event-driven внутри монолита. Переходы состояний задач, назначение исполнителей, изменение дедлайнов — всё это события, которые должны тригерить побочные эффекты (уведомления, обновление дашбордов, запись в лог активности) без прямой связи между модулями. Используем внутренний event bus — в Laravel это Event::dispatch(), в Node.js — EventEmitter или библиотека eventemitter2.
Модель данных
Схема должна поддерживать иерархию задач произвольной глубины. Неочевидное решение — adjacency list с материализованным путём вместо чистого Nested Sets: проще в записи, достаточно быстро в чтении при индексации по path.
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id),
parent_id BIGINT REFERENCES tasks(id),
path LTREE NOT NULL, -- PostgreSQL ltree: '1.5.23'
title VARCHAR(500) NOT NULL,
status task_status NOT NULL DEFAULT 'todo',
priority SMALLINT NOT NULL DEFAULT 2,
assignee_id BIGINT REFERENCES users(id),
due_date DATE,
estimate INTEGER, -- в минутах
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX tasks_path_gist ON tasks USING GIST (path);
CREATE INDEX tasks_project_status ON tasks (project_id, status);
Расширение ltree позволяет делать запросы вида «все подзадачи задачи 5» одним запросом: WHERE path <@ '1.5'.
Зависимости между задачами — отдельная таблица:
CREATE TABLE task_dependencies (
task_id BIGINT REFERENCES tasks(id),
depends_on_id BIGINT REFERENCES tasks(id),
type dep_type NOT NULL, -- 'blocks', 'required_by', 'related'
PRIMARY KEY (task_id, depends_on_id)
);
Перед сохранением зависимости проверяем цикличность — обход графа в глубину или запрос через WITH RECURSIVE.
Статусные машины и воркфлоу
Воркфлоу — главная причина, по которой компании разрабатывают собственную PM-систему. Jira позволяет настраивать воркфлоу, но это медленно и дорого в сопровождении.
Подход: настраиваемые воркфлоу на уровне типа задачи. Каждый тип (Task, Bug, Epic, Story, CR) имеет собственную state machine с разными наборами переходов и guards.
// Laravel + winzou/state-machine
'bug' => [
'graph' => 'bug_workflow',
'property_path' => 'status',
'states' => ['new', 'triaged', 'in_progress', 'in_review', 'resolved', 'closed', 'reopened'],
'transitions' => [
'triage' => ['from' => ['new'], 'to' => 'triaged'],
'start' => ['from' => ['triaged'], 'to' => 'in_progress'],
'review' => ['from' => ['in_progress'], 'to' => 'in_review'],
'resolve' => ['from' => ['in_review'], 'to' => 'resolved'],
'close' => ['from' => ['resolved'], 'to' => 'closed'],
'reopen' => ['from' => ['resolved', 'closed'], 'to' => 'reopened'],
],
'callbacks' => [
'after' => [
'notify_assignee' => ['on' => ['start', 'review'], 'do' => 'NotifyAssigneeCallback'],
],
],
],
Конфигурация воркфлоу хранится в базе данных (JSON-поле), редактируется через визуальный конструктор — drag-and-drop редактор состояний, аналогичный тому, что есть в Jira, но проще.
Реалтайм-обновления
PM-система без реалтайма — это система для работы в одиночку. Когда кто-то меняет статус задачи или добавляет комментарий, остальные участники должны видеть изменения без перезагрузки страницы.
Стек реалтайма:
- WebSocket-сервер: Laravel Reverb (собственное решение Laravel), Soketi (self-hosted совместимый с Pusher) или Ably для managed-решения
-
Каналы: приватные каналы на уровне проекта (
private-project.{id}) и на уровне задачи (private-task.{id}) - Presence-каналы: показывают, кто сейчас просматривает задачу — важно для совместного редактирования
// Frontend: Laravel Echo + React
const channel = window.Echo.private(`project.${projectId}`);
channel
.listen('.task.updated', (e) => {
queryClient.invalidateQueries(['tasks', e.task.id]);
})
.listen('.comment.created', (e) => {
setComments(prev => [...prev, e.comment]);
});
Оптимистичные обновления на фронте: пользователь меняет статус задачи — UI обновляется мгновенно, запрос уходит в фоне. При ошибке — откат. Реализуется через React Query useMutation с onMutate / onError / onSettled.
Просмотры задач: Kanban, список, Gantt, календарь
Разные команды работают по-разному. Минимальный набор представлений:
Kanban-доска: колонки = статусы воркфлоу. Drag-and-drop через @dnd-kit/core — более производительный аналог react-beautiful-dnd. При перетаскивании между колонками вызывается transition API. Важно: виртуализация списков задач в колонках при количестве > 50 карточек (react-virtual или TanStack Virtual).
Список с вложенностью: tree-table с раскрывающимися подзадачами. Сортировка и фильтрация на сервере — не тащить всё дерево на клиент.
Диаграмма Ганта: самый сложный компонент. Строим на базе frappe-gantt или @dhtmlx/gantt (платная, но мощная). Ключевая сложность — отображение зависимостей между задачами стрелками и автоматический пересчёт дат при изменении длительности.
Календарный вид: FullCalendar с кастомным рендером событий. Задачи с дедлайном — точка, задачи с диапазоном дат — полоса.
Тайм-трекинг
Встроенный тайм-трекинг нужен не всем, но когда нужен — реализуется как отдельный модуль:
CREATE TABLE time_logs (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id),
user_id BIGINT NOT NULL REFERENCES users(id),
started_at TIMESTAMPTZ NOT NULL,
stopped_at TIMESTAMPTZ,
duration INTEGER GENERATED ALWAYS AS (
EXTRACT(EPOCH FROM (stopped_at - started_at))::INTEGER
) STORED,
description TEXT
);
Таймер — глобальный элемент UI, не привязанный к конкретной странице. Хранит состояние {taskId, startedAt} в localStorage + синхронизирует с сервером. При закрытии вкладки — beforeunload сохраняет текущий интервал.
Права доступа и роли
Гранулярные права — сложная часть. Модель: RBAC с project-scope ролями.
System roles: admin, member
Project roles: owner, manager, developer, viewer, external
Каждая проектная роль имеет набор permissions: task:create, task:assign, task:delete, project:settings, member:invite и т.д. Используем spatie/laravel-permission с кастомными scoped ролями (привязка роли к модели Project, а не глобально).
Гостевой доступ: внешние пользователи (клиенты, подрядчики) видят только выбранные задачи или только читают. Реализуется через отдельный тип токена с ограниченным набором permissions.
Уведомления
Многоканальные уведомления с настройкой подписок:
| Событие | Push | In-app | Slack | |
|---|---|---|---|---|
| Задача назначена мне | ✓ | ✓ | ✓ | опционально |
| Дедлайн через 24ч | ✓ | ✓ | ✓ | — |
| Комментарий с упоминанием | ✓ | ✓ | ✓ | опционально |
| Смена статуса | — | — | ✓ | опционально |
| Изменение дедлайна | ✓ | — | ✓ | — |
Пользователь управляет подписками в настройках профиля. Технически: очередь уведомлений через Laravel Queues / BullMQ, батчинг email-уведомлений (не отправлять 50 писем, если за 5 минут произошло 50 событий — группируем в одно дайджест-письмо).
Поиск
Глобальный поиск по задачам, проектам, комментариям, файлам. Требования: мгновенный отклик, морфология, поиск по содержимому комментариев.
PostgreSQL FTS через tsvector достаточен до ~200 000 задач:
ALTER TABLE tasks ADD COLUMN search_vector TSVECTOR;
UPDATE tasks SET search_vector =
to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(description, ''));
CREATE INDEX tasks_search ON tasks USING GIN(search_vector);
При большем объёме или необходимости поиска по вложениям (PDF, DOCX) — OpenSearch с Apache Tika для извлечения текста из файлов.
Аналитика и отчёты
Velocity: средняя скорость закрытия задач по спринтам. График в виде burndown chart.
Bottleneck analysis: задачи, которые дольше всего висят в конкретном статусе. SQL-запрос через оконные функции или предварительно агрегированные данные в отдельной таблице task_status_durations.
Отчёт по времени: кто сколько залогировал, по каким проектам. Экспорт в CSV/XLSX через maatwebsite/excel или xlsxwriter.
Кастомные дашборды: виджеты с drag-and-drop расстановкой, сохранение конфигурации в профиле пользователя. Каждый виджет — независимый запрос с кешированием (Redis, TTL 5 минут).
Интеграции
Стандартный набор интеграций для корпоративного PM:
-
Git-репозитории (GitHub, GitLab, Bitbucket): автосмена статуса задачи при упоминании в commit message (
closes #123), linkback из PR в задачу - CI/CD (GitHub Actions, GitLab CI): статус деплоя в карточке задачи
- Slack / Teams: уведомления, создание задач из сообщений через slash-команды
- Confluence / Notion: двусторонняя ссылка задача ↔ документация
- Calendars: синхронизация дедлайнов с Google Calendar / Outlook через CalDAV
Интеграции строятся через OAuth 2.0 (авторизация) + webhooks (входящие события) + REST/GraphQL API (исходящие запросы). Храним токены зашифрованными (encrypt() в Laravel, AES-256-CBC).
Производительность
Узкие места, которые появляются при росте:
N+1 на списке задач: у каждой задачи — assignee, метки, последний комментарий. Решение: eager loading с with() в Eloquent, DataLoader-паттерн в GraphQL.
Пересчёт прогресса проекта: если считать COUNT(completed) / COUNT(*) при каждом запросе — медленно. Денормализуем: поле completed_tasks_count в таблице projects, обновляется через DB-триггер или application-level событие.
Большие списки задач: пагинация cursor-based вместо offset-based. Cursor по (created_at, id) даёт стабильное поведение при одновременном добавлении новых задач.
Сроки
| Этап | Содержание | Длительность |
|---|---|---|
| Проектирование | Воркфлоу, роли, интеграции, wireframes | 3–4 нед. |
| Ядро системы | Проекты, задачи, воркфлоу, права | 6–8 нед. |
| UI: список + Kanban | Базовые представления | 4–5 нед. |
| Реалтайм + уведомления | WebSocket, email, push | 2–3 нед. |
| Gantt + календарь | Сложные представления | 3–4 нед. |
| Тайм-трекинг | Таймер, логи, отчёты | 2 нед. |
| Интеграции (2–3 шт.) | Git + Slack + Calendar | 3–4 нед. |
| Тестирование, запуск | E2E, нагрузочное | 2–3 нед. |
Полный проект с базовым набором функций: 22–32 недели. Итерационный запуск реален уже через 10–12 недель — core-функционал без Gantt и интеграций. Системы, которые строятся одним большим релизом, как правило, не доходят до продакшена.







