...

Иерархия элементов приложения

Тема в разделе "Примеры решений и дополнительных модулей", создана пользователем Valentin Lysenko, 15 июл 2025.

?

Пользовался виджетом?

  1. Да

    Голосов: 0
    0%
  2. Нет

    Голосов: 0
    0%
  3. Ещё нет, но выглядит полезно

    Голосов: 5
    100%
  4. Не думаю, что понадобится

    Голосов: 0
    0%
Можно выбрать сразу несколько вариантов.
  1. Valentin Lysenko

    Valentin Lysenko Активный участник

    Предыстория
    В Elma365 есть виджет, позволяющий отобразить иерархическую структуру.
    Однако там необходимо задать корневой элемент и второе приложение.
    Виджет, описанный в данной статье позволяет отобразить иерархию внутри одного приложения, если у элемента указан родитель.

    Описание функционала

    Модуль представляет собой виджет структуры приложения, позволяющий:
    • Просматривать иерархию элементов в виде дерева
    • Осуществлять поиск по названию
    • Интерактивно раскрывать/сворачивать ветки структуры
    • Обновлять данные по требованию
    Основные функции
    1. Отображение структуры
    • Рекурсивное построение дерева подразделений на основе parent-child связей
    • Группировка по родительским подразделениям
    • Автоматическая сортировка (сначала с дочерними элементами, затем по алфавиту)
    2. Поиск и навигация
    • Поиск по частичному совпадению названия подразделения
    • Подсветка найденных элементов
    • Автоматическое раскрытие родительских веток до найденного элемента
    • Плавная прокрутка к результатам поиска
    3. Интерактивность
    • Раскрытие/сворачивание веток:
      • Клик по всей строке подразделения
      • Клик по иконке-стрелке
    • Анимации:
      • Плавное раскрытие/закрытие
      • Параболическое движение соседних элементов
      • Каскадное появление дочерних элементов
    • Визуальные эффекты:
      • Подсветка при наведении
      • Индикация активных элементов
    4. Управление данными
    • Кнопка обновления данных
    • Кнопка очистки результатов поиска

    Вложения:

    Последнее редактирование: 27 авг 2025
  2. Valentin Lysenko

    Valentin Lysenko Активный участник

    Как использовать:
    1. После импорта модуля, необходимо вынести виджет на страницу.
    2. Ввести код раздела и приложения, на которое собираемся ссылаться.
    3. Ввести код атрибута, где указан родитель.
    4. обавить опциональную зависимость в настройках виджета.
    Спойлер: Добавление виджета настраницу
    [​IMG]
    Спойлер: Добавление зависимости
    [​IMG]
    Спойлер: Пример
    [​IMG]
    Последнее редактирование: 15 июл 2025
  3. Valentin Lysenko

    Valentin Lysenko Активный участник

    Сделал несколько доработок. В топике версия V2.
    • Добавил спинер для отображения загрузки
    • Сделал запуск кода по нажатию на кнопку вместо onInit().
    • Добавил возможность передавать в виджет данные со страницы на случай, если применяются дополнительные фильтры.
    • Добавил переменную "elements_any" и навесил на неё функцию "getElements()".
    • Добавил переменную "show_loader" для отображения спинера.
    • Добавил проверки на наличие настроек.

    Спойлер: Обновлённый виджет код
    Код:
    
    <div class="department-browser">
        <
    div class="search-controls">
            <
    div class="search-container">
                <
    input type="text" id="department-search" class="search-input" placeholder="Поиск отдела..." />
                <
    button class="dept-search-btn" onclick="<%= Scripts %>.searchElement()">
                    <
    svg class="search-icon" viewBox="0 0 24 24" width="16" height="16">
                        <
    path d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.505 6.505 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
                    </
    svg>
                    
    Найти
                
    </button>
                <
    button class="dept-clear-btn" onclick="<%= Scripts %>.clearSearch()">
                    <
    svg class="clear-icon" viewBox="0 0 24 24" width="16" height="16">
                        <
    path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
                    </
    svg>
                    
    Очистить
                
    </button>
                    <% if (!
    Context.data.structure_data && !Context.data.elements_any ) { %>
                    <
    button class="dept-get-btn" onclick="<%= Scripts %>.getElements()">
                        
    Получить данные
                    
    </button>
                <% }  %>
                <% if (
    Context.data.structure_data && !Context.data.elements_any ) { %>
                    <
    button class="dept-refresh-btn" onclick="<%= Scripts %>.getElements()">
                        <
    svg class="refresh-icon" viewBox="0 0 24 24" width="16" height="16">
                            <
    path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
                        </
    svg>
                        
    Обновить
                    
    </button>
                <% }  %>
            </
    div>
        </
    div>

        <% if (
    Context.data.show_loader) { %>
            <
    div class="status-container">
                <
    div class="loading-state">
                    <
    div class="loading-indicator">
                        <
    svg width="80" height="80" viewBox="0 0 100 100">
                            <
    circle cx="50" cy="50" r="40" stroke="#f0f2f5" stroke-width="8" fill="none"/>
                            <
    path class="loading-path" d="M50 10 A40 40 0 0 1 90 50" stroke="#4e73df" stroke-width="8"
                                
    fill="none" stroke-linecap="round">
                                <
    animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50"
                                                
    dur="1.5s" repeatCount="indefinite"/>
                            </
    path>
                            <
    circle cx="50" cy="50" r="6" fill="#4e73df"/>
                            <
    circle cx="50" cy="10" r="3" fill="#4e73df">
                                <
    animate attributeName="opacity" values="0.3;1;0.3" dur="1.5s" repeatCount="indefinite"/>
                            </
    circle>
                        </
    svg>
                    </
    div>
                    <
    class="loading-text">Загрузка данных...</p>
                </
    div>
            </
    div>
        <% } else if (!
    Context.data.show_loader && Context.data.structure_data) {
            const 
    rootItems Context.data.structure_data.filter(item => item.subgroup.length 0);
            const 
    orphanItems Context.data.structure_data.filter(item => item.subgroup.length == 0);
        %>
            <
    ul id="departments-list" class="tree-view">
                <% for (const 
    division of rootItems) { %>
                    <
    li class="division" id="division-<%= division.id %>">
                        <
    div class="division-content clickable-area" onclick="<%= Scripts %>.handleDivisionClick(event, '<%= division.id %>')">
                            <
    span class="division-name"><%= division.name %></span>
                            <% if (
    division.subgroup && division.subgroup.length 0) { %>
                            <
    span class="toggle-btn">
                                <
    svg class="toggle-icon" viewBox="0 0 24 24" width="16" height="16">
                                    <
    path d="M7 10l5 5 5-5z"/>
                                </
    svg>
                            </
    span>
                            <% } %>
                        </
    div>
                        <
    div class="subgroup-container"></div>
                    </
    li>
                <% } %>
           
                <% if (
    orphanItems.length 0) { %>
                    <
    li class="division" id="orphan-items">
                        <
    div class="division-content clickable-area" onclick="<%= Scripts %>.toggleOrphanItems(event)">
                            <
    span class="division-name">Незакрепленные элементы</span>
                            <
    span class="toggle-btn">
                                <
    svg class="toggle-icon" viewBox="0 0 24 24" width="16" height="16">
                                    <
    path d="M7 10l5 5 5-5z"/>
                                </
    svg>
                            </
    span>
                        </
    div>
                        <
    div class="orphan-container">
                            <
    div class="orphan-scroll-wrapper">
                                <
    ul class="orphan-items-list">
                                    <% 
    orphanItems.forEach(item => { %>
                                        <
    li class="group-app" id="group-<%= item.id %>">
                                            <
    div class="group-content clickable-area">
                                                <
    span class="group-name"><%= item.name %></span>
                                            </
    div>
                                        </
    li>
                                    <% }); %>
                                </
    ul>
                            </
    div>
                        </
    div>
                    </
    li>
                <% } %>
            </
    ul>
        <% } %>
    </
    div>

    <
    style>
        .
    toggle-btn {
            
    margin-leftauto;
            
    backgroundnone;
            
    bordernone;
            
    padding4px;
        }

        .
    toggle-btn:hover {
            
    background-color#e0e0e0;
        
    }

        .
    toggle-icon {
            
    fill#666;
            
    transitiontransform 0.2s ease;
            
    transform-origincenter;
        }

        .
    toggle-btn.active .toggle-icon {
            
    transformrotate(90deg);
            
    fill#4a90e2;
        
    }

        .
    dept-search-btn,
        .
    dept-clear-btn,
        .
    dept-refresh-btn,
        .
    dept-get-btn {
            
    displayflex;
            
    align-itemscenter;
            
    gap6px;
            
    padding8px 12px;
            
    bordernone;
            
    border-radius4px;
            
    font-size14px;
            
    cursorpointer;
            
    transitionall 0.2s;
        }

        .
    dept-search-btn {
            
    background-color#4a90e2;
            
    colorwhite;
        }

        .
    dept-search-btn:hover {
            
    background-color#3a7bc8;
        
    }

        .
    dept-clear-btn {
            
    background-color#f5f5f5;
            
    color#333;
        
    }

        .
    dept-clear-btn:hover {
            
    background-color#e5e5e5;
        
    }

        .
    dept-refresh-btn {
            
    background-color#f5f5f5;
            
    color#333;
        
    }

        .
    dept-refresh-btn:hover {
            
    background-color#e5e5e5;
        
    }

        .
    dept-get-btn {
            
    background-color#f5f5f5;
            
    color#333;
        
    }

        .
    dept-get-btn:hover {
            
    background-color#e5e5e5;
        
    }

        .
    department-browser {
            
    font-family'Segoe UI'TahomaGenevaVerdanasans-serif;
            
    max-width800px;
            
    margin0 auto;
            
    padding20px;
            
    background#fff;
            
    border-radius8px;
            
    box-shadow0 2px 10px rgba(0,0,0,0.1);
        }

        .
    search-controls {
            
    margin-bottom20px;
        }

        .
    search-container {
            
    displayflex;
            
    gap8px;
            
    align-itemscenter;
        }

        .
    search-input {
            
    flex1;
            
    padding10px 15px;
            
    border1px solid #ddd;
            
    border-radius4px;
            
    font-size14px;
            
    transitionborder-color 0.3s;
        }

        .
    search-input:focus {
            
    outlinenone;
            
    border-color#4a90e2;
            
    box-shadow0 0 0 2px rgba(74,144,226,0.2);
        }

        .
    tree-view {
            
    positionrelative;
        }

        .
    division {
            
    margin8px 0;
            list-
    stylenone;
            
    border-left2px solid #e0e0e0;
            
    padding-left15px;
        }

        .
    division-content {
            
    displayflex;
            
    align-itemscenter;
            
    padding6px 0;
        }

        .
    division-name {
            
    flex1;
            
    font-weight500;
            
    color#333;
        
    }

        .
    subgroup-container {
            
    margin-left15px;
            
    overflowhidden;
            
    max-height0;
            
    opacity0;
            
    transformtranslateY(-10px);
            
    transition:
                
    max-height 0.3s ease-out,
                
    opacity 0.2s ease-out,
                
    transform 0.3s ease-out;
        }

        .
    subgroup-container.expanded {
            
    max-height100vh;
            
    opacity1;
            
    transformtranslateY(0);
            
    transition:
                
    max-height 0.5s ease-in,
                
    opacity 0.3s ease-in 0.1s,
                
    transform 0.4s ease-in;
        }

        .
    subgroup-container.finished {
            
    max-heightnone;
        }

        .
    group-app {
            
    margin6px 0;
            
    padding-left15px;
            
    border-left1px dashed #e0e0e0;
            
    list-stylenone;
        }

        .
    group-name {
            
    color#555;
        
    }

        .
    highlight_class {
            
    background-colorrgba(255235590.3);
            
    font-weight600;
            
    padding2px 4px;
            
    border-radius3px;
            
    box-shadow0 0 0 1px rgba(255235590.5);
        }

        
    svg {
            
    fillcurrentColor;
        }

        .
    clickable-area {
            
    cursorpointer;
            
    padding6px 8px;
            
    border-radius4px;
            
    transitionbackground-color 0.2s ease;
        }

        .
    clickable-area:hover {
            
    background-color#f5f5f5;
        
    }

        .
    division-content {
            
    displayflex;
            
    align-itemscenter;
            
    padding0;
        }

        .
    orphan-container {
            
    margin-left20px;
            
    max-height0;
            
    overflowhidden;
            
    transitionmax-height 0.3s ease-out;
        }

        .
    orphan-container.expanded {
            
    max-height300px;
        }

        .
    orphan-scroll-wrapper {
            
    max-height280px;
            
    overflow-yauto;
            
    padding-right5px;
        }

        .
    orphan-scroll-wrapper::-webkit-scrollbar {
            
    width8px;
        }

        .
    orphan-scroll-wrapper::-webkit-scrollbar-track {
            
    background#f1f1f1;
            
    border-radius4px;
        }

        .
    orphan-scroll-wrapper::-webkit-scrollbar-thumb {
            
    background#c1c1c1;
            
    border-radius4px;
        }

        .
    orphan-scroll-wrapper::-webkit-scrollbar-thumb:hover {
            
    background#a8a8a8;
        
    }

        .
    orphan-items-list {
            list-
    stylenone;
            
    padding-left15px;
            
    margin5px 0;
        }

        .
    orphan-items-list .group-app {
            
    padding6px 0;
            
    border-left1px dashed #e0e0e0;
        
    }
    </
    style>


    Спойлер: Новая функция инициации
    Код:
    
    async function getElements(): Promise<void> {
        
    Context.data.structure_data undefined
        
    //Если данные получаем по
        
    if (Context.data.elements_any){
            
    Context.data.show_loader true
            await buildObject_tree
    (Context.data.elements_any);
            
    await addEventListeners();
            
    Context.data.show_loader false
        
    }
        else if (!
    Context.data.ns_code || !Context.data.app_code){
            
    window.alert('Добавьте название раздела и/или приложения')
        }
        
    //@ts-ignore
        
    else if(!Imports || !Imports[Context.data.ns_code] || !Imports[Context.data.ns_code].app[Context.data.app_code]){
            
    window.alert('Добавьте раздел в Imports')
        }
        
    //@ts-ignore
        
    else if (Imports && Imports[Context.data.ns_code] && Imports[Context.data.ns_code].app[Context.data.app_code]) {
            
    Context.data.show_loader true
            
    //@ts-ignore
            
    let application Imports![Context.data.ns_code]!.app[Context.data.app_code]

            
    let divisions await application.search()
                .
    where((divisionanyany) =>
                    
    g.and(
                        
    division.__deletedAt.eq(null)
                    )
                )
                .
    size(10000)
                .
    all();

            
    await buildObject_tree(divisions);
            
    await addEventListeners();
            
    Context.data.show_loader false
        
    }
    }

    В контексте странице, на которой будет отображаться виджет, необходимо создать
    -переменную типа any (выходное)
    -переменную для отображения спинера (входное + выходное).

    Для красоты, сделал кнопку получения данных в таком же стиле.

    Спойлер: Кнопка получения данных
    Код:
    
    <div class="button-block">
        <
    button class="dept-get-btn" onclick="<%= Scripts %>.formElements()">
    Получить данные
        
    </button>
    </
    div>
    <
    style>
        .
    button-block {
            
    displayflex;
            
    align-itemscenter;
            
    font-family'Segoe UI'TahomaGenevaVerdanasans-serif;
            
    max-width800px;
            
    padding20px;
            
    background#fff;
            
    border-radius8px;
            
    box-shadow0 2px 10px rgba(0,0,0,0.1);
            
    justify-contentcenter;
        }
        .
    dept-get-btn {
            
    displayflex;
            
    align-itemscenter;
            
    gap6px;
            
    padding8px 12px;
            
    bordernone;
            
    border-radius4px;
            
    font-size14px;
            
    cursorpointer;
            
    transitionall 0.2s;
        }
            .
    dept-get-btn {
            
    background-color#f5f5f5;
            
    color#333;
        
    }

        .
    dept-get-btn:hover {
            
    background-color#e5e5e5;
        
    }
    </
    style>

    Если взять виджет "Строка", навесить на него класс "get_data" и положить туда кнопку и сам виджет, то потребуются дополнительные стили.

    Спойлер: Итоговый вариант на странице
    Код:
    
    <style>
        .
    get_data {
            
    displayflex;
            
    flex-directioncolumn;
            
    align-contentspace-around;
            
    row-gap10px;
        }
    </
    style>
    [​IMG]