Как сделать таск-трекер под себя на low-code конструкторе

от автора

Писать с нуля и поддерживать веб-приложения, тем более для бизнеса – сложная и муторная задача. При этом готовые решения часто бывает сложно подстроить под свои нужды. Возьмём таск-менеджеры: джира медленная, дорогая и часто слишком сложная, 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 запрос.

Делаем всё по очереди:

  1. Создадим карточку (форму) задачи. FunQL-запрос для формы почти такой же, как запрос для канбана или таблицы за одним исключением — он возвращает одну запись по id, который передается форме в качестве аргумента.

  2. Разобьем форму на блоки, расположим поля и другие контролы по блокам так, чтобы и на мобилке, и на ПК было удобно работать с задачей.

  3. Добавим на форму таймлайн с комментариями — если всё правильно настроить (написать триггеры), то при изменении конкретной задачи в таймлайне будут отображаться ивенты об изменениях.

  4. Ниже встроим на форму доску с подзадачами этой задачи.

  5. Кнопка “Архивировать”/”Разархивировать” — сейчас меняет статус значения поля «Архив» на  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

Готовим таблицы, поля и связи в БД

Заводим в админке конструктора схемы, сущности и поля, как нарисовали до этого  в схеме. Система автоматически создаст схемы, таблицы и поля в базе данных.

Форма сущности pm.tasks в админке решения
Форма сущности pm.tasks в админке решения

Вся информация о самой базе данных (о схемах, таблицах, полях, триггерах и остальном) лежит в схеме public.

Еще в самом начале развития конструктора мы создали набор пользовательских представлений для взаимодействия с этими сущностями и объединили их в схеме admin. Это не системная схема, в чистом новом решении её нет – весь интерфейс администрирования написан внутри самого конструктора. 

Актуальная версия лежит в открытом доступе, поэтому чаще админы либо пользуются ей, либо (гораздо реже) пишут свой интерфейс для редактирования сущностей, полей, триггеров и всего остального.

Можно посмотреть на список полей сущности tasks в демке.
Список всех схем решения можно посмотреть тут, а список сущностей тут.

Что не вошло в статью

Чтобы не растягивать статью до бесконечности, мы не стали описывать разграничение доступов, пользовательские роли и уведомления о задачах на почту и в телеграм. Если такое будет интересно — напишите в комментариях, расскажем, как это сделать самому.

Можно еще кстати выгружать локально себе весь код решения, править его в виде кода и разворачивать обратно в озму.

Как решение может расти дальше?

  • Добавим “Клиентов” и историю взаимодействия с ними;

  • Создадим сущность “Сделки” и воронки продаж;

  • Настроим представления для менеджеров и руководителей, а потом настроим их роли;

  • Напишем больше CRM-дашбордов.

Ещё соберём финансовый модуль. Но может и не соберём, если в работе будем справляться без него.

Что под капотом?

Платформа

Linux (NixOS/nixops), PostgreSQL

Бэкенд

F# (.NET Core), свой диалект SQL с компилятором в pgSQL

Фронтэнд

TypeScript/Vue.js

О нас

Озма придумана двумя студентами из бауманки Кириллом Маркиным и Николаем Амиантовым. Большие куски статьи и демо-пример написала Ирина Горохова. Спасибо Константину, Ренату Дарыбаеву, Даниилу и Любови за большой вклад в проект, Михаилу Полянину за помощь с текстом и Роману Белякову за иллюстрации.

Расскажите, чего вам не хватает в своем таск-трекере. Как думаете, будет удобно собирать на платформе такие решения под свои хотелки? Может ли быть полезна такая штука?


ссылка на оригинал статьи https://habr.com/ru/post/673874/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *