Писать с нуля и поддерживать веб-приложения, тем более для бизнеса – сложная и муторная задача. При этом готовые решения часто бывает сложно подстроить под свои нужды. Возьмём таск-менеджеры: джира медленная, дорогая и часто слишком сложная, 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 |
О нас
Озма придумана двумя студентами из бауманки Кириллом Маркиным и Николаем Амиантовым. Большие куски статьи и демо-пример написала Ирина Горохова. Спасибо Константину, Ренату Дарыбаеву, Даниилу и Любови за большой вклад в проект, Михаилу Полянину за помощь с текстом и Роману Белякову за иллюстрации.
Расскажите, чего вам не хватает в своем таск-трекере. Как думаете, будет удобно собирать на платформе такие решения под свои хотелки? Может ли быть полезна такая штука?