Pull to refresh

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

Reading time22 min
Views12K

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

О нас

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

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

Tags:
Hubs:
Total votes 26: ↑25 and ↓1+32
Comments24

Articles