
Писать с нуля и поддерживать веб-приложения, тем более для бизнеса – сложная и муторная задача. При этом готовые решения часто бывает сложно подстроить под свои нужды. Возьмём таск-менеджеры: джира медленная, дорогая и часто слишком сложная, Trello слишком простой, а персональные таск-менеджеры не дают нужного взаимодействия с командой.
Мы написали свой low-code конструктор, на котором наши партнеры собирают CRM и ERP-решения для бизнеса, и на котором создавать и дорабатывать решения могут даже студенты. Давайте попробуем создать на нём простой, но почти полноценный и при этом расширяемый продукт, выходящий за рамки таск-менеджера из учебника по очередному фреймворку.
TL;DR — вот что у нас получится к концу статьи (и 3 часа работы)

Что хотим от таск-трекера
На первом этапе от нашей разработки много не нужно:
Он должен быть многопользовательским — мы хотим вести таски всей команды.
У задач может быть только один ответственный. Почему один? Для простоты, а если понадобится сделать несколько, то это реализуется дополнительной таблицей-связью и небольшими изменениями в собранных пользовательских представлениях.
Задачи можно представить в виде канбан-доски, обычной таблицы или дерева — смотря как кому будет удобнее.
Для каждой задачи есть простой лог изменений статуса и возможность оставлять комментарии. Без системы версий, предыдущих историй и тому подобного.
Дашборд с парой графиков на всякий случай. Кажется, что это тоже нужно.
Общая мысль — для начала будем собирать что-то максимально простое. Все следующие хотелки если и появятся, то уже в процессе использования системы, а докрутить их — обычно дело на 15 минут.
Создаем интерфейс
В нашем low-code конструкторе пользовательские представления создаются c помощью SQL-подобного языка FunQL. По сути в нём к обычным SQL-запросам добавляются атрибуты для настройки пользовательского интерфейса и немного синтаксического сахара — например, стрелка для обращения к связанным записям без JOIN. Внутри конструктора такой запрос оптимизируется и транспилируется в SQL, и к нему автоматически применяются права доступа конкретного пользователя – почти как ROW LEVEL SECURITY в традиционных БД, но с некоторыми расширениями. Сами представления похожи на функции — принимают аргументы и возвращают результат запроса.
Что будем создавать в первой версии системы:
Канбан-доска (с задачами)
Формы (для задачи и для человека-сотрудника)
Таблицы (с задачами и с людьми)
Дерево (с задачами)
Меню (страница со ссылками на все остальные представления)
Дашборды (графики по данным в базе)
Настраиваем канбан-доску
Наш выбор — это таск-менеджер с доской с карточками сгруппированными по статусам. Это самый популярный способ оценить объем работ и их состояние. Поэтому начнем с канбана.

Вот тут можно посмотреть страницу с канбан-доской в демке.
А тут посмотреть на FunQL запрос для создания этой страницы.
Теперь чуть подробнее по шагам, как мы к этому пришли.
С помощью FunQL опишем пользовательское представление для отображения данных о всех задачах в виде доски. Каждая запись из результата FunQL-запроса отображается как отдельная карточка. Для канбан-представления достаточно указать только поле, по которому карточки будут разбиваться на столбцы (в нашем случае задачи группируются по статусу, но настроить это можно как угодно).
По умолчанию все поля, указанные в блоке SELECT запроса, будут отображаться на карточке, а те, которые не нужны, можно скрыть атрибутом поля visible = false. Для каждого поля можно выбрать подпись, иконку и выделить значение цветом по определенной логике, например, как просроченные задачи на скриншоте выше.
Простой запрос для создания представления типа "канбан-доска"
SELECT /* User view type */ @type = 'board', /* User view title */ @title = 'Задачи', subject @{ /* Material design icon */ icon = 'subject' }, status @{ /* Group entries by this field */ board_group = true, /* Do not display status field on the card */ visible = false }, start_date @{ icon = 'date_range' }, due_date @{ icon = 'flag', cell_variant = CASE WHEN due_date < $$transaction_time THEN 'warning' END }, priority @{ icon = 'priority_high' }, responsible_contact @{ icon = 'person' }, "order" @{ /* Use the value from the "order" field as a sorting number for all entries */ board_order = true, visible = false } FROM pm.tasks
В таком канбане уже можно перемещать карточки, а система сама будет менять значения полей, которые отвечают за колонку карточки и порядок в ней. Канбан, как и другие представления, работает и в браузерах на мобильных устройствах.
После этого добавим несколько базовых фильтров, чтобы можно было делать выборку задач, не открывая код представления. Фильтры мы реализуем через аргументы в представлении, которые по умолчанию они будут равны NULL. Чаще всего задачи требуется фильтровать по ответственным, приоритету, статусу или сроку. Остальные фильтры можно добавить потом, если понадобится.
Добавляем к запросу аргументы представления и правильно обрабатываем их в блоке WHERE
{ /* Arguments of the user view */ $responsible array(reference(base.people)) null @{ caption = 'Ответственный', /* Available values in popup will be restricted with the results of the specified user view */ options_view = &base.ref_base_people_view }, $status array(enum('backlog', 'new', 'in_progress', 'done')) null @{ caption = 'Статус', text = array mapping WHEN 'backlog' THEN 'Идеи' WHEN 'new' THEN 'Новое' WHEN 'in_progress' THEN 'В работе' WHEN 'done' THEN 'Завершено' END, }, $priority array(enum('low', 'medium', 'high', 'urgent')) null @{ caption = 'Приоритет', text = array mapping WHEN 'low' THEN 'Низкий' WHEN 'medium' THEN 'Средний' WHEN 'high' THEN 'Высокий' WHEN 'urgent' THEN 'Критичный' END }, $is_archived bool null @{ caption = 'Архив' }, /* другие аргументы */ }: SELECT /* ... */ FROM pm.tasks WHERE ($responsible IS NULL OR responsible_contact = ANY($responsible)) AND ($status IS NULL OR status = ANY($status)) AND ($priority IS NULL OR priority = ANY($priority)) AND ($is_archived IS NULL OR is_archived = $is_archived)
Добавим сверху несколько кнопок для быстрого применения фильтров к представлению — “Мои” для отображения задач текущего пользователя, “Просрочено” — для отображения задач, срок которых уже наступил, “Не завершено” — для всех незакрытых задач. Фактически эти кнопки ведут на тот же самый юзервью, но передают в него разные аргументы.
Добавляем к запросу атрибут @buttons и задаем в нём настройки для кнопок
@buttons = [ { /* Caption of the button */ caption: 'Статус', /* Material design icon code */ icon: 'filter_alt', /* Show button at the top right corner on PCs */ display: 'desktop', buttons: [ { caption: 'Просрочено', icon: 'flag', /* Reference to the user view */ ref: &pm.tasks_board, /* New values for arguments */ args: { status: ['backlog', 'new', 'in_progress'], due_date_to: $$transaction_time, is_archived: false, responsible: $responsible, parent_task: $parent_task }, target: 'top' }, { caption: 'Не завершено', icon: 'notifications', ref: &pm.tasks_board, args: { status: ['new', 'in_progress'], is_archived: false, responsible: $responsible, parent_task: $parent_task }, target: 'top' }, { caption: 'Все', icon: 'filter_list_alt', ref: &pm.tasks_board, args: { is_archived: false, responsible: $responsible, parent_task: $parent_task }, target: 'top' } ] } ]
В финале добавим ссылки на создание новой записи в отдельном окне прямо из канбан-колонки и укажем какую форму открыть при клике на карточку.
Добавляем ссылки на другие представления
/* Reference to the user view opened by clicking the "Open entry in modal" button */ @row_link = &pm.task_form, /* Set the user view used for creating new entries */ @card_create_view = { /* Reference to the user view */ ref: &pm.task_form, /* Default values for the entry being created */ default_values: { responsible_contact: $responsible, parent_task: $parent_task } }
На этом с канбаном вроде всё.
Итоговый запрос для нашего канбана получился таким
{ /* Arguments of the user view */ $responsible array(reference(base.people)) null @{ caption = 'Ответственный', /* Available values in popup will be restricted with the results of the specified user view */ options_view = &base.ref_base_people_view }, $status array(enum('backlog', 'new', 'in_progress', 'done')) null @{ caption = 'Статус', text = array mapping WHEN 'backlog' THEN 'Идеи' WHEN 'new' THEN 'Новое' WHEN 'in_progress' THEN 'В работе' WHEN 'done' THEN 'Завершено' END }, $priority array(enum('low', 'medium', 'high', 'urgent')) null @{ caption = 'Приоритет', text = array mapping WHEN 'low' THEN 'Низкий' WHEN 'medium' THEN 'Средний' WHEN 'high' THEN 'Высокий' WHEN 'urgent' THEN 'Критичный' END }, $parent_task reference(pm.tasks) null @{ caption = 'Надзадача' }, $start_date_from datetime null @{ caption = 'Дата начала с' }, $start_date_to datetime null @{ caption = 'Дата начала до' }, $due_date_from datetime null @{ caption = 'Срок с' }, $due_date_to datetime null @{ caption = 'Срок до' }, $is_archived bool null @{ caption = 'Архив' } }: SELECT /* User view type. See also: https://wiki.ozma.io/ru/docs/funapp/board */ @type = 'board', /* User view title */ @title = 'Задачи', /* "Filters" panel is hidden by default */ @show_argument_editor = false, /* "Filters" button will be showed on top of the board */ @show_argument_button = true, /* Reference to the user view opened by clicking the "Open entry in modal" button */ @row_link = &pm.task_form, /* Set the user view used for creating new entries */ @card_create_view = { /* Reference to the user view */ ref: &pm.task_form, /* Default values for the entry being created */ default_values: { responsible_contact: $responsible, parent_task: $parent_task } }, /* Specifications of the interface buttons */ @buttons = [ /* ... */ ], subject @{ /* Material design icon */ icon = 'subject' }, status @{ /* Group entries by this field */ board_group = true, /* Do not display status field on the card */ visible = false, }, start_date @{ icon = 'date_range', }, due_date @{ icon = 'flag' }, priority as priority_raw @{ /* Display the icon specified in the default attributes for the "priority" field */ icon = priority.@icon, }, responsible_contact @{ icon = 'person' }, parent_task @{ icon = 'account_tree' }, "order" @{ /* Use the value from the "order" field as a sorting number for all entries */ board_order = true, visible = false, }, FROM pm.tasks WHERE ($is_archived IS NULL OR is_archived = $is_archived) AND ($responsible IS NULL OR responsible_contact = ANY($responsible)) AND (($parent_task IS NULL AND parent_task IS NULL) OR parent_task = $parent_task) AND ($status IS NULL OR status = ANY($status)) AND ($priority IS NULL OR priority = ANY($priority)) AND ($start_date_to IS NULL OR start_date <= $start_date_to) AND ($start_date_from IS NULL OR start_date >= $start_date_from) AND ($due_date_to IS NULL OR due_date <= $due_date_to) AND ($due_date_from IS NULL OR due_date <= $due_date_to) ORDER BY "order", is_archived, completed_datetime DESC NULLS FIRST, due_date NULLS LAST, id
Всё написанное после @ — это атрибуты для настройки отображения. У нас есть два типа атрибутов — атрибуты представления (юзервью), например, @title, @row_link и прочие, и атрибуты колонок — @{ visible = false }.
Атрибуты для колонок могут задаваться не только в запросе, но и в настройках по умолчанию для каждой колонки (таблица public.default_attributes). Так мы можем не указывать в каждой таблице названия полей, цветовые выделения и прочее. Ещё есть атрибуты аргументов – через них мы задаем названия у фильтров { $is_archived bool null @{ caption = 'Архив' }}
Пилим формы

Так выглядит форма задачи в демо-инстансе. А это её FunQL запрос.
Делаем всё по очереди:
Создадим карточку (форму) задачи. FunQL-запрос для формы почти такой же, как запрос для канбана или таблицы за одним исключением — он возвращает одну запись по
id, который передается форме в качестве аргумента.Разобьем форму на блоки, расположим поля и другие контролы по блокам так, чтобы и на мобилке, и на ПК было удобно работать с задачей.
Добавим на форму таймлайн с комментариями — если всё правильно настроить (написать триггеры), то при изменении конкретной задачи в таймлайне будут отображаться ивенты об изменениях.
Ниже встроим на форму доску с подзадачами этой задачи.
Кнопка “Архивировать”/”Разархивировать” - сейчас меняет статус значения поля "Архив" на
trueилиfalse, но позже можно будет прикрутить на них более сложную логику.
В итоге получится какой-то такой запрос для формы
{ $id reference(pm.tasks) }: SELECT /* User view type */ @type = 'form', /* User view title */ @title = $id=>__main, /* Form block sizes. This form has three blocks - two first row blocks with the ratio of 7 to 5, and the third block occupying the second row */ @block_sizes = array[ 7, 5, 12 ], @buttons = [ { caption: 'Архивировать запись', /* Show button at the top right corner on PCs */ display: 'desktop', /* Show button only if the record is not archived */ visible: NOT $id=>is_archived, /* Set material design icon */ icon: 'archive', /* Color variant for the button */ variant: 'outline-danger', action: { schema: 'user', name: 'archive_record' }, args: { entity: { schema: 'pm', name: 'tasks' }, is_archived: true, id: $id } }, { caption: 'Разархивировать запись', display: 'desktop', visible: $id=>is_archived, icon: 'archive', variant: 'outline-success', action: { schema: 'user', name: 'archive_record' }, args: { entity: { schema: 'pm', name: 'tasks' }, is_archived: false, id: $id } } ], subject @{ /* Display input control in the first block */ form_block = 0 }, description @{ form_block = 0, /* Use multiline WYSIWYG editor */ text_type = 'wysiwyg', /* Set control height to 400 px */ control_height = 400 }, parent_task @{ form_block = 0, }, status @{ form_block = 1 }, responsible_contact @{ form_block = 1 }, priority @{ form_block = 1 }, start_date @{ form_block = 1, default_value = $$transaction_time }, due_date @{ form_block = 1 }, /* Arrange nested user views on the form */ { /* Reference to the nested user view */ ref: &pm.tasks_board, /* Arguments that will be passed to the nested user view */ args: { parent_task: $id } } as subtasks @{ /* Display user view control on the form */ control = 'user_view', /* Display user View control in the first block */ form_block = 4, /* Caption for the nested User View. User view @title will be replaced with this caption */ caption = 'Подзадачи' }, { ref: &pm.notes_for_task_timeline, args: { id: $id } } as notes @{ control = 'user_view', form_block = 1, caption = 'Комментарии' } FROM pm.tasks WHERE id = $id FOR INSERT INTO pm.tasks
В готовой форме можно создавать новые и редактировать существующие записи. Система связывает выбираемые данные с исходными записями, так что одного SELECT вполне достаточно для отображения, а для создания мы явно указываем желаемую сущность в конструкции FOR INSERT INTO.
Отображение задач в виде таблицы (куда же без нее)

Ссылка на таблицу с задачами.
Ссылка на код запроса, который эту таблицу создал.
Делаем всё так же, как и с канбаном — напишем FunQL-запрос для отображения данных, добавим несколько фильтров, добавим несколько кнопок с “сохраненными” фильтрами в верхнюю панель. В таблице еще будет не лишним настроить пагинацию для отображения по 25 записей на странице.
В финале еще добавим ссылки на создание новой записи прямо из таблицы или в отдельном окне, и ссылки на другие представления, где это необходимо.
FunQL-запрос для создания таблицы задач
{ /* Arguments of the user view */ $is_archived bool null @{ caption = 'Архив' }, $responsible array(reference(base.people)) null @{ caption = 'Ответственный', /* Available values in popup will be restricted with the results of the specified user view */ options_view = &base.ref_base_people_view }, $parent_task reference(pm.tasks) null @{ caption = 'Надзадача' }, $status array(enum('backlog', 'new', 'in_progress', 'done')) null @{ caption = 'Статус', text = array mapping WHEN 'backlog' THEN 'Идеи' WHEN 'new' THEN 'Новое' WHEN 'in_progress' THEN 'В работе' WHEN 'done' THEN 'Завершено' END, }, $priority array(enum('low', 'medium', 'high', 'urgent')) null @{ caption = 'Приоритет', text = array mapping WHEN 'low' THEN 'Низкий' WHEN 'medium' THEN 'Средний' WHEN 'high' THEN 'Высокий' WHEN 'urgent' THEN 'Критичный' END }, $start_date_from datetime null @{ caption = 'Дата начала с' }, $start_date_to datetime null @{ caption = 'Дата начала до' }, $due_date_from datetime null @{ caption = 'Срок с' }, $due_date_to datetime null @{ caption = 'Срок до' } }: SELECT /* User view type. See also: https://wiki.ozma.io/ru/docs/funapp/table */ /* This table can be displayed as a tree is case of all filters are null. More info about tree view: https://wiki.ozma.io/ru/docs/funapp/tree */ @type = 'table', /* User view title */ @title = 'Задачи', /* "Filters" panel is hidden by default */ @show_argument_editor = false, /* "Filters" button will be showed on the top of the board */ @show_argument_button = true, /* Set the user view used for creating new entries */ @create_link = &pm.task_form, /* Reference to the user view opened by clicking the "Open entry in modal" button */ @row_link = &pm.task_form, /* Disable ability to create new child record if parent task is not null and it was archived */ @soft_disabled = $parent_task=>is_archived, /* Set pagination options */ @lazy_load = { pagination: { /* Show 25 rows per page */ per_page: 25 } }, /* Specifications of the interface buttons */ @buttons = [ { /* Caption of the button */ caption: 'Ответственный', /* Material design icon code */ icon: 'people', /* Show button at the top right corner on PCs */ display: 'desktop', buttons: [ { caption: 'Я', ref: &pm.tasks_table, icon: 'person', /* Apply a "light gray" color variant to the button if there is no person associated with the current user */ /* $$user_id returns ID of the current user */ variant: CASE WHEN (SELECT COUNT(1) FROM base.people WHERE user = $$user_id) = 0 THEN 'light' END, args: { responsible: ( SELECT array_agg(id) FROM base.people WHERE user = $$user_id ), status: $status, start_date_to: $start_date_to, start_date_from: $start_date_from, due_date_to: $due_date_to, due_date_from: $due_date_from, is_archived: $is_archived, parent_task: $parent_task, priority: $priority }, /* Open link full screen */ target: 'top' }, { caption: 'Все', ref: &pm.tasks_table, icon: 'people', args: { responsible: null, status: $status, start_date_to: $start_date_to, start_date_from: $start_date_from, due_date_to: $due_date_to, due_date_from: $due_date_from, is_archived: $is_archived, parent_task: $parent_task, priority: $priority }, target: 'top' } ] }, { caption: 'Статус', icon: 'filter_alt', display: 'desktop', buttons: [ { caption: 'Просрочено', icon: 'flag', ref: &pm.tasks_table, args: { status: ['backlog', 'new', 'in_progress'], due_date_to: $$transaction_time, is_archived: false, responsible: $responsible, parent_task: $parent_task }, target: 'top' }, { caption: 'Не завершено', icon: 'notifications', ref: &pm.tasks_table, args: { status: ['new', 'in_progress'], is_archived: false, responsible: $responsible, parent_task: $parent_task }, target: 'top' }, { caption: 'Завершено', icon: 'done_all', ref: &pm.tasks_table, args: { status: ['done'], is_archived: false, responsible: $responsible, parent_task: $parent_task }, target: 'top' }, { caption: 'Все', icon: 'filter_list_alt', ref: &pm.tasks_table, args: { is_archived: false, responsible: $responsible, parent_task: $parent_task }, target: 'top' }, { caption: 'Архив', icon: 'delete', ref: &pm.tasks_table, args: { is_archived: true, responsible: $responsible, parent_task: $parent_task }, target: 'top' } ] }, { /* Open board with tasks instead of tasks table */ caption: 'Доска', tooltip: 'Отображать задачи в виде канбан-доски', icon: 'sticky_note_2', variant: 'dark', display: 'desktop', ref: &pm.tasks_board, /* Use filters from tasks table for tasks board */ args: { is_archived: $is_archived, parent_task: $parent_task, status: $status, priority: $priority, start_date_from: $start_date_from, start_date_to: $start_date_to, due_date_from: $due_date_from, due_date_to: $due_date_to, responsible: $responsible }, target: 'top' }, ], subject @{ /* Set column width to 350 px */ column_width = 350, }, status @{ column_width = 100 }, priority @{ column_width = 100 }, start_date @{ column_width = 100, default_value = $$transaction_time /* $$transaction_time returns current timestamp */ }, due_date @{ column_width = 100 }, description @{ column_width = 300 }, responsible_contact @{ column_width = 150 }, parent_task @{ /* Set $parent_task argument value as a default value for "parent_task" field */ default_value = $parent_task, /* Display column "parent_task" only if $parent_task argument value is null */ visible = $parent_task IS NULL }, completed_datetime @{ column_width = 125, visible = ($status = array['done']) }, completed_person @{ column_width = 125, visible = ($status = array['done']) } FROM pm.tasks WHERE ($is_archived IS NULL OR is_archived = $is_archived) AND ($responsible IS NULL OR responsible_contact = ANY($responsible)) AND ($parent_task IS NULL OR parent_task = $parent_task) AND ($status IS NULL OR status = ANY($status)) AND ($priority IS NULL OR priority = ANY($priority)) AND ($start_date_to IS NULL OR start_date <= $start_date_to) AND ($start_date_from IS NULL OR start_date >= $start_date_from) AND ($due_date_to IS NULL OR due_date <= $due_date_to) AND ($due_date_from IS NULL OR due_date <= $due_date_to) ORDER BY is_archived, status.@order_number, /* Return an attribute "order_numer" value for the "status" column field */ completed_datetime DESC NULLS FIRST, start_date NULLS LAST, id /* Set the main entity */ FOR INSERT INTO pm.tasks
Одного SELECT по-прежнему достаточно для редактирования, удаления и добавления записей. Работать будет даже с JOIN и UNION в запросах.
Деревья - простая вложенная структура тасков

Отображение задач в виде дерева можно посмотреть тут.
FunQL запрос у дерева и таблицы один и тот же. Он тут.
Древовидная таблица — это обычная таблица, которая отображает родительские и дочерние задачи в виде списка с любым уровнем вложенности. Проще говоря, это иерархия, подзадачи и подподзадачи (и сколько угодно ещё уровней вложенности).
Сделать из таблицы задач таблицу с древовидной структурой задач и подзадач можно с помощью одного дополнительного атрибута для поля parent_task @{ tree_parent_ids = true }. Тогда фронтенд по умолчанию будет отрисовывать все задачи в виде дерева, ориентируясь по записям, связанным через колонку.
По умолчанию дерево строится от корневых записей, где parent_task = null. Чтобы построить дерево от произвольной записи, нужно изменить запрос, добавив в него рекурсию.
Просто добавим этот атрибут в код таблицы - зачем пилить два разных представления, если можно обойтись одним. Только оставим условие, чтобы дерево отключалось при применении фильтров.
Меню - точка входа и быстрый доступ ко всем функциям
Фактически меню — это просто набор ссылок, внешних или на другие представления.
После логина пользователь попадает на страницу /user/main , поэтому мы сделали на этой странице главное меню — ссылки на представления для быстрого доступа.

Посмотреть, как выглядит главное меню можно в демо-инстансе.
FunQL запрос для создания меню тоже есть.
Чтобы сделать такое меню, создадим ссылки на задачи всех пользователей и на задачи текущего пользователями в виде доски и таблицы. После этого добавим ссылку на меню администратора и ссылку на небольшой дашборд со списком сотрудников, чтобы можно было быстро его отредактировать.
Код представления меню — это тоже FunQL-запрос:
SELECT /* User view type.See also: https://wiki.ozma.io/ru/docs/funapp/menu */ @type = 'menu', /* Menu title */ @title = 'Главное меню', @menu_centered = true, menu.blocks FROM ( VALUES ([ { /* Menu block name */ name: 'Все задачи', /* Block width size = 3/12 */ size: 3, /* Block content */ content: [{ /* Caption for the link */ name: 'Отчеты', /* Reference to the linked user view */ ref: &pm.dashboard, /* Icon for the link */ icon: 'monitor_heart' }, { name: 'Доска: Все задачи', ref: &pm.tasks_board, /* Arguments passed to the user view */ args: { is_archived: false, }, icon: 'sticky_note_2', }, { name: 'Таблица: Все задачи', ref: &pm.tasks_table, args: { is_archived: false, }, icon: 'table_chart', }] }, { name: 'Мои задачи', size: 3, content: [{ name: 'Доска: Мои задачи', ref: &pm.tasks_board, args: { is_archived: false, responsible: (SELECT array_agg(id) FROM base.people WHERE user = $$user_id) /* $$user_id returns ID of the current user */ }, icon: 'sticky_note_2', }, { name: 'Таблица: Мои задачи', ref: &pm.tasks_table, args: { is_archived: false, responsible: (SELECT array_agg(id) FROM base.people WHERE user = $$user_id) }, icon: 'table_chart', badge: { value: (SELECT COUNT(id) FROM pm.tasks WHERE responsible_contact = ANY((SELECT id FROM base.people WHERE user = $$user_id))), variant: 'danger' } }] }, { name: 'Другое', size: 3, content: [{ name: 'Люди', ref: &base.people_table, icon: 'person', badge: { value: (SELECT COUNT(1) FROM base.people WHERE NOT is_archived), variant: 'info' } }, { name: 'Настройки', ref: &admin.main, icon: 'settings', }] }, ]) ) AS menu(blocks)
Дашборд - красивые графики, которые любят все

Для первой версии добавим 4 графика, которые могут пригодиться на старте:
донат-график с количеством открытых задач, чтобы видеть, сколько всего нужно сделать;
столбики с открытыми задачами по ответственным, чтобы смотреть кто это должен делать;
график активности закрытых задач, чтобы мониторить, что задачи всё-таки закрываются;
донат-график с завершенными задачами по приоритетам, чтобы оценить, насколько быстро их нужно было закрыть.
Остальные графики будем добавлять в процессе работы, как только они понадобятся. Но в самом начале нам достаточно этих четырёх.
Графики встраиваются через iframe – мы пишем страницу на HTML и передаем ей данные из базы. Это значит, что мы можем выбрать любую библиотеку для визуализации данных и подключить ее во вложенной странице, а веб-приложение будет передавать в iframe значение выбранного поля. Так можно делать виджеты для редактирования данных самостоятельно, например, с интеграцией в системы поиска адресов.
Все обрабатывается на фронте, поэтому важно получить из базы уже посчитанные данные, чтобы потом на js их надо было только отрисовать. Для простоты выбрали https://nvd3.org - набор компонентов, использующих https://d3js.org:
Создаем четыре формы с единственным контролом на каждой из них типа iframe. Немного дорабатываем код для графиков, собираем контролы с графиками на одну форму, чтобы получился простенький дашборд
Дашборд с демо-данными смотреть тут. А FunQL-запрос, который объединил четыре графика в один, тут.
FunQL-запрос, который собирает данные из базы и передает их в iframe.
Код iframe-markup-а для одного из графиков со скриншота выше.
Все остальные графики.
Допиливаем мелочи
Неупомянутыми остались еще несколько пользовательских представлений (как обычно, все запросы на языке FunQL):
Комментарии и лог изменений задачи (тот самый встроенный на форму задачи таймлайн) + запрос
Настраиваем автоматику
Триггеры и функции в ozma.io пишутся на JavaScript. Внутри доступен полный API для работы с базой, при этом вся операция происходит в одной транзакции. Триггеры работают так же, как и в других реляционных базах данных, и позволяют отменить операцию или изменить её аргументы.
Функции можно вызывать по кнопкам из интерфейса, или из внешнего сервиса по API — они также могут возвращать произвольные данные. Используя триггеры, функции и их интеграцию в интерфейс, можно создавать полноценные веб-приложения. К триггерам и действиям также применяются права доступа, но внутри можно выполнять и привилегированные запросы — так можно реализовывать служебные автоматические поля, если не хватает другого функционала.
Кнопки
Чтобы автоматизировать любой процесс, в том числе создание, изменение или удаление записей в правильном порядке, мы используем “экшены”. На языке нашего проекта, это функции на JS, которые можно дёрнуть из любого представления, передав ей произвольные данные.
В самом простом варианте таск-трекера нам не нужно автоматизировать сложные процессы (их у нас просто нет), но для примера можно рассмотреть экшн “архивации” записи. Он простой, и всего лишь меняет значение поля is_archived с true на false и обратно для всех записей, айдишники которых в него передали.

Пишем код экшна
export default async function archiveRecord(args) { const ids = args.ids ?? [args.id]; if (!('is_archived' in args)) throw 'Неправильно задано действие'; for (const id of ids) { try { // FunDB API function call await FunDB.updateEntity( // updateEntity: ( args.entity, // ref: IEntityRef, id, // id: number, { is_archived: args.is_archived } // args: Record<string, unknown> ); // ) => Promise<void>; } catch(e) { throw 'Неправильно задано действие'; } } return { ok: true } }
Теперь надо как-то вызывать экшн из интерфейса
Дописываем кнопки с вызовом экшна на форму задачи
/* pm.task_form */ @buttons = [ { caption: 'Архивировать запись', display: 'desktop', visible: NOT $id=>is_archived, icon: 'archive', variant: 'outline-danger', action: { schema: 'user', name: 'archive_record' }, args: { entity: { schema: 'pm', name: 'tasks' }, is_archived: true, id: $id } }, { caption: 'Разархивировать запись', display: 'desktop', visible: $id=>is_archived, icon: 'archive', variant: 'outline-success', action: { schema: 'user', name: 'archive_record' }, args: { entity: { schema: 'pm', name: 'tasks' }, is_archived: false, id: $id } } ]
А потом то же самое - из таблицы задач
/* pm.tasks_table */ @buttons = [ { /* Set is_archived = true for all selected records */ caption: 'Архивировать выделенные записи', /* Display button when records are selected with a check mark */ display: 'selectionPanel', /* Display button only if $is_archived argument value is false */ visible: NOT $is_archived, icon: 'archive', variant: 'danger', action: { schema: 'user', name: 'archive_record' }, args: { entity: { schema: 'pm', name: 'tasks' }, is_archived: true }, }, { caption: 'Разархивировать выделенные записи', display: 'selectionPanel', visible: $is_archived, icon: 'unarchive', variant: 'success', action: { schema: 'user', name: 'archive_record' }, args: { entity: { schema: 'pm', name: 'tasks' }, is_archived: false } } ]
Ссылка на код экшна user.archive_record.
Вызов этого экшна на форме задачи по нажатию на кнопку в верхней панели.
Вызов экшна из таблицы задач при выделении одной или нескольких записей галочками.
Триггеры (продолжение автоматизации)
Напишем триггер, который при изменении задачи будет добавлять в таблицу “notes_for_tasks” информацию об этом в удобном для понимания виде:
Триггер срабатывает на обновление статуса, приоритета или значения поля "Архив"
import { getPersonName } from 'admin/user_info.mjs'; export default async function insertNote(event, args) { const taskId = event.source.id; const now = new Date(); const author = await getPersonName() ?? args.author; /* append status change event */ if ('status' in args) { const msg = args.status ? `изменил(а) статус задачи на \"${args.status}\"` : `убрал(а) статус задачи`; await addNote(taskId, now, author, msg); } /* append priority change event */ if ('priority' in args) { const msg = args.priority ? `изменил(а) приоритет задачи на \"${args.priority}\"` : `убрал(а) приоритет задачи`; await addNote(taskId, now, author, msg); } /* append archive change event */ if ('is_archived' in args) { const msg = args.is_archived ? `переместил(а) задачу в Архив` : `вернул(а) задачу из Архива`; await addNote(taskId, now, author, msg); } return true; } /* add entity into "pm"."notes_for_tasks" table */ async function addNote(taskId, date, author, message) { await FunDB.insertEntity({ schema: 'pm', name: 'notes_for_tasks' }, { note_datetime: date, author: author, message: message, type: 'event', task: taskId } ); }
Теперь при изменении, например, статуса задачи с id = 101 с “Новое” на “В работе” сработает триггер, который вставит в таблицу notes_for_tasks новую запись:
{ note_datetime:`2022-01-01 10:00:00`, author: `Петя`, message:`изменил(а) статус задачи на "В работе"`, type: 'event', task: 101 }
Эта информация и так фиксируется в логах, но чтобы всегда иметь эти данные вместе с записью и избежать медленных селектов по многомиллионным таблицам с логами — храним их структурированно для задач.
Аналогичные триггеры есть для “Дата создания”, “Кто создал” и “Дата изменения”, “Кто изменил”.
Ссылка на код триггера для вставки заметок/комментариев.
Другой пример - триггер, заполняющий поля "Дата завершения" и "Кто завершил" для задачи.
Список всех триггеров в демо-решении.
Про схему базы данных
Глядя на наш код может возникнуть вопрос — а куда мы вообще SELECT-ы пишем? Где база данных? А как создавать еще таблицы или изменять колонки? Вернемся немного в начало и пройдемся по структуре базы для нашего таск-трекера и тому, как её создать и настроить.
Внутри ozma.io — реляционная база данных на основе PostgreSQL, поэтому для хранения данных, естественно, нужно заранее придумать структуру базы. Разрабатываем её с учетом возможного расширения и рисуем в Миро

Чтобы впоследствии добавление новых модулей было менее болезненным, сразу делим сущности на несколько схем. Схемы, как и в других базах данных, это наборы таблиц и других сущностей, например:
в схеме
pm— все сущности, связанные с задачами: Задачи (tasks) и Комментарии (notes_for_tasks)в схеме
baseбудут Контакты (contacts) и унаследованные от них Люди (people) и Организации (organizations). Наследование позволяет ссылаться на контакт из других сущностей – внутри системы люди и организации находятся в одной таблице.
На самом деле “Организации” на первый взгляд кажутся бесполезными, мы их нигде не используем. Но когда-нибудь наступит момент, когда рядом с задачей нужно будет хранить ссылку на заказчика или провайдера или конкурента, а еще их телефоны и адреса. Лучше подстраховаться и заложить правильную архитектуру с самого начала, сэкономив своё драгоценное время впоследствии.схема
publicсистемная, и её менять нельзя, поэтому ей не уделяем много внимания. В ней нам интересна сущность Пользователи (users), т.к. тут хранится и настраивается список пользователей, у которых есть доступ к решению.
Параллельно думаем над необходимой автоматикой:
Нужны будут несколько триггеров для заполнения полей, например “Дата создания”, “Кто создал”, “Дата изменения”, “Кто изменил” для таблиц задач (
pm.tasks) и контактов (base.contacts)И ещё нужны будут пара триггеров, чтобы вставлять кастомные ивенты в таблицу
pm.notes_for_tasks
Готовим таблицы, поля и связи в БД
Заводим в админке конструктора схемы, сущности и поля, как нарисовали до этого в схеме. Система автоматически создаст схемы, таблицы и поля в базе данных.

Вся информация о самой базе данных (о схемах, таблицах, полях, триггерах и остальном) лежит в схеме public.
Еще в самом начале развития конструктора мы создали набор пользовательских представлений для взаимодействия с этими сущностями и объединили их в схеме
admin. Это не системная схема, в чистом новом решении её нет – весь интерфейс администрирования написан внутри самого конструктора.Актуальная версия лежит в открытом доступе, поэтому чаще админы либо пользуются ей, либо (гораздо реже) пишут свой интерфейс для редактирования сущностей, полей, триггеров и всего остального.
Можно посмотреть на список полей сущности tasks в демке.
Список всех схем решения можно посмотреть тут, а список сущностей тут.
Что не вошло в статью
Чтобы не растягивать статью до бесконечности, мы не стали описывать разграничение доступов, пользовательские роли и уведомления о задачах на почту и в телеграм. Если такое будет интересно — напишите в комментариях, расскажем, как это сделать самому.
Можно еще кстати выгружать локально себе весь код решения, править его в виде кода и разворачивать обратно в озму.
Как решение может расти дальше?
Добавим “Клиентов” и историю взаимодействия с ними;
Создадим сущность “Сделки” и воронки продаж;
Настроим представления для менеджеров и руководителей, а потом настроим их роли;
Напишем больше CRM-дашбордов.
Ещё соберём финансовый модуль. Но может и не соберём, если в работе будем справляться без него.
Что под капотом?
Платформа | Linux (NixOS/nixops), PostgreSQL |
Бэкенд | F# (.NET Core), свой диалект SQL с компилятором в pgSQL |
Фронтэнд | TypeScript/Vue.js |
О нас
Озма придумана двумя студентами из бауманки Кириллом Маркиным и Николаем Амиантовым. Большие куски статьи и демо-пример написала Ирина Горохова. Спасибо Константину, Ренату Дарыбаеву, Даниилу и Любови за большой вклад в проект, Михаилу Полянину за помощь с текстом и Роману Белякову за иллюстрации.
Расскажите, чего вам не хватает в своем таск-трекере. Как думаете, будет удобно собирать на платформе такие решения под свои хотелки? Может ли быть полезна такая штука?
