/
diloytd
/
shield
Обзор
Документация
Войти
/
diloytd
/
shield
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/ui/scripts/table.js
1 132 строки
42 KB
diloytd
Перепроектировать shields.json и поддержать ТУ, сертификаты и новые условия.
22 июл 2026, 17:14
22 июл 2026, 17:14
fb8b887
Код
Авторство
О чём код?
/** * Колонки журнала в формате «Журнал Паспортов.xlsx». * @type {Array<{ key: string, title: string, align: 'center' | 'left', width: string, merge?: boolean }>} */ const JOURNAL_COLUMNS = [ { key: 'entryNumber', title: '№', align: 'center', width: '48px', merge: true }, { key: 'productionDate', title: 'Дата', align: 'center', width: '88px', merge: true }, { key: 'orderNumber', title: 'ТЗ', align: 'center', width: '72px', merge: true }, { key: 'shieldNumber', title: 'Шильда', align: 'center', width: '72px' }, { key: 'itemNumber', title: 'п.номер', align: 'center', width: '72px' }, { key: 'instanceNumber', title: 'изд в номере', align: 'center', width: '96px' }, { key: 'marking', title: 'обозначение', align: 'left', width: '160px' }, ]; /** Ключи колонок, объединяемых по записи журнала (как в Excel). */ const JOURNAL_MERGED_COLUMN_KEYS = JOURNAL_COLUMNS .filter((column) => column.merge) .map((column) => column.key); /** Текст при отсутствии строк журнала. */ const JOURNAL_EMPTY_MESSAGE = 'Журнал пуст.'; /** * Форматирует дату YYYY-MM-DD в формат Excel DD.MM.YY. * @param {string} isoDate - Дата в формате ISO. * @returns {string} Дата для отображения или исходная строка. */ const formatProductionDateForDisplay = (isoDate) => { if (!isoDate || typeof isoDate !== 'string') { return ''; } const parts = isoDate.split('-'); if (parts.length !== 3) { return isoDate; } const [year, month, day] = parts; const shortYear = year.slice(-2); return `${day}.${month}.${shortYear}`; }; /** * Создаёт элемент colgroup с ширинами колонок журнала. * @returns {HTMLTableColElement} Группа колонок таблицы. */ const createJournalColgroup = () => { const colgroup = document.createElement('colgroup'); JOURNAL_COLUMNS.forEach(({ width }) => { const col = document.createElement('col'); col.className = 'journal-table__col'; col.style.width = width; colgroup.appendChild(col); }); return colgroup; }; /** * Создаёт заголовок таблицы журнала. * @returns {HTMLTableSectionElement} Секция thead. */ const createJournalTableHead = () => { const thead = document.createElement('thead'); thead.className = 'journal-table__head'; const headerRow = document.createElement('tr'); headerRow.className = 'journal-table__row journal-table__row--header'; JOURNAL_COLUMNS.forEach(({ title, align }) => { const th = document.createElement('th'); th.className = 'journal-table__header-cell'; th.classList.add(`journal-table__header-cell--${align}`); th.scope = 'col'; th.textContent = title; headerRow.appendChild(th); }); thead.appendChild(headerRow); return thead; }; /** * Создаёт ячейку данных журнала с учётом выравнивания. * @param {string} value - Значение ячейки. * @param {'center' | 'left'} align - Горизонтальное выравнивание. * @param {number} [rowSpan=1] - Количество объединяемых строк. * @returns {HTMLTableCellElement} Ячейка td. */ const createJournalDataCell = (value, align, rowSpan = 1) => { const td = document.createElement('td'); td.className = 'journal-table__cell'; td.classList.add(`journal-table__cell--${align}`); if (rowSpan > 1) { td.classList.add('journal-table__cell--merged'); td.rowSpan = rowSpan; } td.textContent = value; return td; }; /** * Преобразует строку журнала из сервиса в значения для отображения. * @param {Object} row - Строка журнала из journalService. * @returns {Record<string, string>} Значения колонок для UI. */ const mapJournalRowToDisplayValues = (row) => ({ entryNumber: String(row.entryNumber ?? ''), productionDate: formatProductionDateForDisplay(String(row.productionDate ?? '')), orderNumber: String(row.orderNumber ?? ''), shieldNumber: String(row.shieldNumber ?? ''), itemNumber: String(row.itemNumber ?? ''), instanceNumber: String(row.instanceNumber ?? ''), marking: String(row.marking ?? ''), }); /** * Вычисляет размеры групп строк с одинаковым номером записи журнала. * @param {Array<Object>} rows - Строки журнала. * @returns {number[]} Размер группы для каждой строки (0 — продолжение объединённого блока). */ const buildJournalGroupSizes = (rows) => { const groupSizes = new Array(rows.length).fill(0); let groupStartIndex = 0; while (groupStartIndex < rows.length) { const entryNumber = rows[groupStartIndex].entryNumber; let groupEndIndex = groupStartIndex + 1; while ( groupEndIndex < rows.length && rows[groupEndIndex].entryNumber === entryNumber ) { groupEndIndex += 1; } const groupSize = groupEndIndex - groupStartIndex; groupSizes[groupStartIndex] = groupSize; groupStartIndex = groupEndIndex; } return groupSizes; }; /** * Создаёт DOM-элемент таблицы журнала с данными или сообщением о пустом журнале. * @param {Array<Object>} [rows=[]] - Строки журнала из journalService. * @returns {HTMLElement} Обёртка с таблицей журнала. */ const createJournalTable = (rows = []) => { const wrapper = document.createElement('div'); wrapper.className = 'workspace__table-wrapper'; const table = document.createElement('table'); table.className = 'journal-table'; table.setAttribute('aria-label', 'Журнал шильдов'); table.appendChild(createJournalColgroup()); table.appendChild(createJournalTableHead()); const tbody = document.createElement('tbody'); tbody.className = 'journal-table__body'; if (!rows || rows.length === 0) { const emptyRow = document.createElement('tr'); emptyRow.className = 'journal-table__row journal-table__row--empty'; const emptyCell = document.createElement('td'); emptyCell.className = 'journal-table__cell journal-table__cell--empty'; emptyCell.colSpan = JOURNAL_COLUMNS.length; emptyCell.textContent = JOURNAL_EMPTY_MESSAGE; emptyRow.appendChild(emptyCell); tbody.appendChild(emptyRow); } else { const groupSizes = buildJournalGroupSizes(rows); rows.forEach((row, rowIndex) => { const dataRow = document.createElement('tr'); dataRow.className = 'journal-table__row'; const displayValues = mapJournalRowToDisplayValues(row); const rowSpan = groupSizes[rowIndex]; JOURNAL_COLUMNS.forEach(({ key, align, merge }) => { if (merge && rowSpan === 0) { return; } const cellRowSpan = merge ? rowSpan : 1; dataRow.appendChild(createJournalDataCell(displayValues[key], align, cellRowSpan)); }); tbody.appendChild(dataRow); }); } table.appendChild(tbody); wrapper.appendChild(table); return wrapper; }; /** @deprecated Используйте createJournalTable([]). */ const createEmptyJournalTable = () => createJournalTable([]); /** Группы колонок рабочей таблицы экземпляров. */ const INSTANCES_GROUP_ORDER = 'order'; const INSTANCES_GROUP_SHIELD = 'shield'; const INSTANCES_GROUP_CERTIFICATES = 'certificates'; /** Заголовки групп колонок рабочей таблицы. */ const INSTANCES_GROUP_TITLES = { [INSTANCES_GROUP_ORDER]: 'Заказ', [INSTANCES_GROUP_SHIELD]: 'Шильд', [INSTANCES_GROUP_CERTIFICATES]: 'Сертификаты', }; /** * Колонки рабочей таблицы экземпляров изделий. * @type {Array<{ key: string, title: string, align: 'center' | 'left', group: string, width: string, isGroupEnd?: boolean, isGroupStart?: boolean }>} */ const INSTANCES_COLUMNS = [ { key: 'rowNumber', title: '№', align: 'center', group: INSTANCES_GROUP_ORDER, width: '44px' }, { key: 'orderNumber', title: '№ заказа', align: 'center', group: INSTANCES_GROUP_ORDER, width: '88px' }, { key: 'positionNumber', title: '№ позиции\nзаказа', align: 'center', group: INSTANCES_GROUP_ORDER, width: '96px', }, { key: 'productType', title: 'Составляющая\nпозиции', align: 'left', group: INSTANCES_GROUP_ORDER, width: '132px', }, { key: 'instanceNumber', title: '№ экземпляра\nпозиции', align: 'center', group: INSTANCES_GROUP_ORDER, width: '108px', isGroupEnd: true, }, { key: 'shieldNumber', title: '№ шильда', align: 'center', group: INSTANCES_GROUP_SHIELD, width: '72px' }, { key: 'marking', title: 'Маркировка', align: 'left', group: INSTANCES_GROUP_SHIELD, width: '200px' }, { key: 'shieldType', title: 'Тип', align: 'center', group: INSTANCES_GROUP_SHIELD, width: '72px' }, { key: 'shieldFireResistance', title: 'Класс\nогнестойкости', align: 'center', group: INSTANCES_GROUP_SHIELD, width: '108px', }, { key: 'tu', title: 'ТУ', align: 'left', group: INSTANCES_GROUP_SHIELD, width: '120px' }, { key: 'productionDate', title: 'Дата\nвыпуска', align: 'center', group: INSTANCES_GROUP_SHIELD, width: '88px', isGroupEnd: true, }, { key: 'certificates', title: 'Сертификаты', align: 'left', group: INSTANCES_GROUP_CERTIFICATES, width: '220px', isGroupStart: true, }, ]; /** Ширина колонки действий (удаление строки). */ const INSTANCES_ACTIONS_COLUMN_WIDTH = '40px'; /** Заголовок колонки удаления строки. */ const INSTANCES_DELETE_COLUMN_TITLE = ''; /** Символ кнопки удаления строки. */ const INSTANCES_DELETE_BUTTON_LABEL = '×'; /** Текст при отсутствии экземпляров изделий. */ const INSTANCES_EMPTY_MESSAGE = 'Экземпляры изделий отсутствуют'; /** Ключи колонок, доступных для пользовательской сортировки. */ const INSTANCES_SORTABLE_COLUMN_KEYS = ['orderNumber', 'positionNumber', 'instanceNumber']; /** Направление сортировки по возрастанию. */ const INSTANCES_SORT_DIRECTION_ASC = 'asc'; /** Направление сортировки по убыванию. */ const INSTANCES_SORT_DIRECTION_DESC = 'desc'; /** * Возвращает состояние сортировки по умолчанию (порядок из базы данных). * @returns {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} Состояние сортировки. */ const getDefaultInstanceSortState = () => ({ sortKey: null, sortDirection: INSTANCES_SORT_DIRECTION_ASC, }); /** * Сравнивает значения одного поля строки экземпляра для сортировки. * @param {string} key - Ключ поля (orderNumber, positionNumber, instanceNumber). * @param {Object} rowA - Первая строка экземпляра. * @param {Object} rowB - Вторая строка экземпляра. * @returns {number} Отрицательное, нулевое или положительное число для Array.sort. */ const compareInstanceSortField = (key, rowA, rowB) => { if (key === 'orderNumber') { const valueA = String(rowA.orderNumber ?? '').trim(); const valueB = String(rowB.orderNumber ?? '').trim(); const numberA = Number(valueA); const numberB = Number(valueB); const isNumericA = valueA !== '' && Number.isFinite(numberA); const isNumericB = valueB !== '' && Number.isFinite(numberB); if (isNumericA && isNumericB && numberA !== numberB) { return numberA - numberB; } return valueA.localeCompare(valueB, 'ru', { numeric: true, sensitivity: 'base' }); } const numberA = Number(rowA[key]); const numberB = Number(rowB[key]); if (Number.isFinite(numberA) && Number.isFinite(numberB)) { return numberA - numberB; } return String(rowA[key] ?? '').localeCompare(String(rowB[key] ?? ''), 'ru', { numeric: true }); }; /** * Сравнивает две строки экземпляров с учётом активной сортировки и стабильных tie-breaker. * @param {Object} rowA - Первая строка экземпляра. * @param {Object} rowB - Вторая строка экземпляра. * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} sortState - Состояние сортировки. * @returns {number} Результат сравнения для Array.sort. */ const compareInstanceRows = (rowA, rowB, sortState) => { if (sortState.sortKey) { const primaryCompare = compareInstanceSortField(sortState.sortKey, rowA, rowB); if (primaryCompare !== 0) { return sortState.sortDirection === INSTANCES_SORT_DIRECTION_ASC ? primaryCompare : -primaryCompare; } } const defaultKeys = ['orderNumber', 'positionNumber', 'instanceNumber']; for (const key of defaultKeys) { const compareResult = compareInstanceSortField(key, rowA, rowB); if (compareResult !== 0) { return compareResult; } } return Number(rowA.instanceId) - Number(rowB.instanceId); }; /** * Сортирует строки экземпляров по выбранному пользователем столбцу. * @param {Array<Object>} rows - Строки экземпляров из instanceService. * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} sortState - Состояние сортировки. * @returns {Array<Object>} Отсортированная копия массива строк. */ const sortInstanceRows = (rows, sortState) => { if (!rows || rows.length === 0 || !sortState?.sortKey) { return rows ?? []; } return [...rows].sort((rowA, rowB) => compareInstanceRows(rowA, rowB, sortState)); }; /** * Формирует следующее состояние сортировки при клике по заголовку колонки. * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} currentState - Текущее состояние. * @param {string} columnKey - Ключ нажатой колонки. * @returns {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} Новое состояние сортировки. */ const getNextInstanceSortState = (currentState, columnKey) => { if (currentState.sortKey !== columnKey) { return { sortKey: columnKey, sortDirection: INSTANCES_SORT_DIRECTION_ASC, }; } if (currentState.sortDirection === INSTANCES_SORT_DIRECTION_ASC) { return { sortKey: columnKey, sortDirection: INSTANCES_SORT_DIRECTION_DESC, }; } return getDefaultInstanceSortState(); }; /** * Форматирует дату YYYY-MM-DD для отображения в таблице экземпляров. * @param {string} isoDate - Дата в формате ISO. * @returns {string} Дата DD.MM.YY или пустая строка. */ const formatInstanceProductionDate = (isoDate) => { if (!isoDate || typeof isoDate !== 'string') { return ''; } const parts = isoDate.split('-'); if (parts.length !== 3) { return isoDate; } const [year, month, day] = parts; const shortYear = year.slice(-2); return `${day}.${month}.${shortYear}`; }; /** * Преобразует строку экземпляра в значения для отображения в рабочей таблице. * @param {Object} row - Строка экземпляра из instanceService. * @param {number} rowNumber - Порядковый номер строки в таблице. * @returns {Record<string, string>} Значения колонок для UI. */ const mapInstanceRowToDisplayValues = (row, rowNumber) => ({ rowNumber: String(rowNumber), orderNumber: String(row.orderNumber ?? ''), positionNumber: String(row.positionNumber ?? ''), productType: String(row.productType ?? ''), instanceNumber: String(row.instanceNumber ?? ''), shieldNumber: row.hasShield ? String(row.shieldNumber ?? '') : '', marking: row.hasShield ? String(row.marking ?? '') : '', shieldType: row.hasShield ? String(row.shieldType ?? '') : '', shieldFireResistance: row.hasShield ? String(row.shieldFireResistance ?? '') : '', tu: row.hasShield ? String(row.tu ?? '') : '', productionDate: row.hasShield ? formatInstanceProductionDate(String(row.productionDate ?? '')) : '', certificates: row.hasShield ? String(row.certificates ?? '') : '', }); /** * Создаёт элемент colgroup с ширинами колонок рабочей таблицы. * @returns {HTMLTableColElement} Группа колонок таблицы. */ const createInstancesColgroup = () => { const colgroup = document.createElement('colgroup'); INSTANCES_COLUMNS.forEach(({ width }) => { const col = document.createElement('col'); col.className = 'data-grid__col'; col.style.width = width; colgroup.appendChild(col); }); const actionsCol = document.createElement('col'); actionsCol.className = 'data-grid__col data-grid__col--actions'; actionsCol.style.width = INSTANCES_ACTIONS_COLUMN_WIDTH; colgroup.appendChild(actionsCol); return colgroup; }; /** * Создаёт блок индикаторов сортировки (▲▼) для заголовка колонки. * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} sortState - Состояние сортировки. * @param {string} columnKey - Ключ колонки. * @returns {HTMLSpanElement} Контейнер с двумя треугольниками. */ const createInstanceSortIndicator = (sortState, columnKey) => { const isActiveSort = sortState.sortKey === columnKey; const isAsc = isActiveSort && sortState.sortDirection === INSTANCES_SORT_DIRECTION_ASC; const isDesc = isActiveSort && sortState.sortDirection === INSTANCES_SORT_DIRECTION_DESC; const indicator = document.createElement('span'); indicator.className = 'data-grid__sort-indicator'; indicator.setAttribute('aria-hidden', 'true'); const upArrow = document.createElement('span'); upArrow.className = 'data-grid__sort-indicator-arrow data-grid__sort-indicator-arrow--up'; upArrow.classList.toggle('data-grid__sort-indicator-arrow--active', isAsc); upArrow.textContent = '▲'; const downArrow = document.createElement('span'); downArrow.className = 'data-grid__sort-indicator-arrow data-grid__sort-indicator-arrow--down'; downArrow.classList.toggle('data-grid__sort-indicator-arrow--active', isDesc); downArrow.textContent = '▼'; indicator.appendChild(upArrow); indicator.appendChild(downArrow); return indicator; }; /** * Обновляет состояние индикаторов сортировки в заголовке колонки. * @param {HTMLSpanElement} indicator - Контейнер индикаторов ▲▼. * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} sortState - Состояние сортировки. * @param {string} columnKey - Ключ колонки. * @returns {void} */ const updateInstanceSortIndicator = (indicator, sortState, columnKey) => { const isActiveSort = sortState.sortKey === columnKey; const isAsc = isActiveSort && sortState.sortDirection === INSTANCES_SORT_DIRECTION_ASC; const isDesc = isActiveSort && sortState.sortDirection === INSTANCES_SORT_DIRECTION_DESC; const upArrow = indicator.querySelector('.data-grid__sort-indicator-arrow--up'); const downArrow = indicator.querySelector('.data-grid__sort-indicator-arrow--down'); if (!(upArrow instanceof HTMLElement) || !(downArrow instanceof HTMLElement)) { return; } upArrow.classList.toggle('data-grid__sort-indicator-arrow--active', isAsc); downArrow.classList.toggle('data-grid__sort-indicator-arrow--active', isDesc); }; /** * Создаёт двухуровневый заголовок рабочей таблицы с группами «Заказ», «Шильд» и «Сертификаты». * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} sortState - Активная сортировка. * @param {(columnKey: string) => void} onSortColumn - Обработчик клика по сортируемой колонке. * @returns {HTMLTableSectionElement} Секция thead. */ const createInstancesTableHead = (sortState, onSortColumn) => { const thead = document.createElement('thead'); thead.className = 'data-grid__head'; const groupRow = document.createElement('tr'); groupRow.className = 'data-grid__row data-grid__row--group'; const orderColumnsCount = INSTANCES_COLUMNS.filter( (column) => column.group === INSTANCES_GROUP_ORDER, ).length; const shieldColumnsCount = INSTANCES_COLUMNS.filter( (column) => column.group === INSTANCES_GROUP_SHIELD, ).length; const certificatesColumnsCount = INSTANCES_COLUMNS.filter( (column) => column.group === INSTANCES_GROUP_CERTIFICATES, ).length; const orderGroupCell = document.createElement('th'); orderGroupCell.className = 'data-grid__group-cell data-grid__group-cell--order'; orderGroupCell.colSpan = orderColumnsCount; orderGroupCell.scope = 'colgroup'; orderGroupCell.textContent = INSTANCES_GROUP_TITLES[INSTANCES_GROUP_ORDER]; groupRow.appendChild(orderGroupCell); const shieldGroupCell = document.createElement('th'); shieldGroupCell.className = 'data-grid__group-cell data-grid__group-cell--shield'; shieldGroupCell.colSpan = shieldColumnsCount; shieldGroupCell.scope = 'colgroup'; shieldGroupCell.textContent = INSTANCES_GROUP_TITLES[INSTANCES_GROUP_SHIELD]; groupRow.appendChild(shieldGroupCell); const certificatesGroupCell = document.createElement('th'); certificatesGroupCell.className = 'data-grid__group-cell data-grid__group-cell--certificates'; certificatesGroupCell.colSpan = certificatesColumnsCount; certificatesGroupCell.scope = 'colgroup'; certificatesGroupCell.textContent = INSTANCES_GROUP_TITLES[INSTANCES_GROUP_CERTIFICATES]; groupRow.appendChild(certificatesGroupCell); const actionsGroupCell = document.createElement('th'); actionsGroupCell.className = 'data-grid__group-cell data-grid__group-cell--actions'; actionsGroupCell.scope = 'col'; actionsGroupCell.setAttribute('aria-hidden', 'true'); groupRow.appendChild(actionsGroupCell); const columnRow = document.createElement('tr'); columnRow.className = 'data-grid__row data-grid__row--header'; INSTANCES_COLUMNS.forEach(({ key, title, isGroupEnd, isGroupStart }) => { const th = document.createElement('th'); th.className = 'data-grid__header-cell data-grid__header-cell--center'; if (title.includes('\n')) { th.classList.add('data-grid__header-cell--multiline'); } if (isGroupEnd) { th.classList.add('data-grid__header-cell--group-end'); } if (isGroupStart) { th.classList.add('data-grid__header-cell--group-start'); } th.scope = 'col'; const isSortable = INSTANCES_SORTABLE_COLUMN_KEYS.includes(key); if (!isSortable) { th.textContent = title; columnRow.appendChild(th); return; } th.classList.add('data-grid__header-cell--sortable'); const isActiveSort = sortState.sortKey === key; const ariaSortValue = isActiveSort ? (sortState.sortDirection === INSTANCES_SORT_DIRECTION_ASC ? 'ascending' : 'descending') : 'none'; th.setAttribute('aria-sort', ariaSortValue); if (isActiveSort) { th.classList.add('data-grid__header-cell--sorted'); } const sortButton = document.createElement('button'); sortButton.type = 'button'; sortButton.className = 'data-grid__sort-button'; sortButton.dataset.sortKey = key; const titleSpan = document.createElement('span'); titleSpan.className = 'data-grid__sort-button-text'; titleSpan.textContent = title; sortButton.appendChild(titleSpan); sortButton.appendChild(createInstanceSortIndicator(sortState, key)); const sortTitle = isActiveSort ? (sortState.sortDirection === INSTANCES_SORT_DIRECTION_ASC ? 'Сортировка по возрастанию. Нажмите для убывания.' : 'Сортировка по убыванию. Нажмите для сброса.') : 'Сортировать по этому столбцу'; sortButton.title = sortTitle; sortButton.setAttribute('aria-label', `${title.replace('\n', ' ')}. ${sortTitle}`); const handleSortClick = (event) => { event.stopPropagation(); onSortColumn(key); }; sortButton.addEventListener('click', handleSortClick); sortButton.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); event.stopPropagation(); handleSortClick(event); }); th.appendChild(sortButton); columnRow.appendChild(th); }); const actionsHeaderCell = document.createElement('th'); actionsHeaderCell.className = 'data-grid__header-cell data-grid__header-cell--actions'; actionsHeaderCell.scope = 'col'; actionsHeaderCell.textContent = INSTANCES_DELETE_COLUMN_TITLE; actionsHeaderCell.setAttribute('aria-label', 'Удаление'); columnRow.appendChild(actionsHeaderCell); thead.appendChild(groupRow); thead.appendChild(columnRow); return thead; }; /** * Создаёт ячейку данных рабочей таблицы экземпляров. * @param {string} value - Значение ячейки. * @param {'center' | 'left'} align - Горизонтальное выравнивание. * @param {boolean} [isGroupEnd=false] - Является ли ячейка последней в группе. * @param {boolean} [isGroupStart=false] - Является ли ячейка первой в новой группе. * @returns {HTMLTableCellElement} Ячейка td. */ const createInstanceDataCell = (value, align, isGroupEnd = false, isGroupStart = false) => { const td = document.createElement('td'); td.className = 'data-grid__cell'; td.classList.add(`data-grid__cell--${align}`); if (isGroupEnd) { td.classList.add('data-grid__cell--group-end'); } if (isGroupStart) { td.classList.add('data-grid__cell--group-start'); } td.textContent = value; return td; }; /** * Создаёт ячейку с кнопкой удаления строки экземпляра. * @param {number} instanceId - Идентификатор экземпляра изделия. * @param {(instanceId: number) => void} onDelete - Обработчик удаления экземпляра. * @returns {HTMLTableCellElement} Ячейка td с кнопкой удаления. */ const createInstanceDeleteCell = (instanceId, onDelete) => { const td = document.createElement('td'); td.className = 'data-grid__cell data-grid__cell--actions'; const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'data-grid__delete-button'; deleteButton.textContent = INSTANCES_DELETE_BUTTON_LABEL; deleteButton.setAttribute('aria-label', 'Удалить строку'); deleteButton.title = 'Удалить'; const handleDelete = (event) => { event.stopPropagation(); if (!Number.isInteger(instanceId) || instanceId <= 0) { return; } onDelete(instanceId); }; deleteButton.addEventListener('click', handleDelete); deleteButton.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); event.stopPropagation(); handleDelete(event); }); td.appendChild(deleteButton); return td; }; /** * Снимает выделение со всех строк рабочей таблицы экземпляров. * @param {HTMLTableSectionElement} tbody - Тело таблицы экземпляров. * @returns {void} */ const clearInstanceRowSelection = (tbody) => { tbody.querySelectorAll('.data-grid__row--selected').forEach((rowElement) => { rowElement.classList.remove('data-grid__row--selected'); rowElement.setAttribute('aria-selected', 'false'); }); }; /** * Обрабатывает выбор строки экземпляра по клику или клавише Enter/Space. * @param {HTMLTableRowElement} rowElement - Строка таблицы. * @param {HTMLTableSectionElement} tbody - Тело таблицы экземпляров. * @param {(instanceId: number|null) => void} onSelect - Обработчик выбора экземпляра. * @returns {void} */ const handleInstanceRowSelect = (rowElement, tbody, onSelect) => { clearInstanceRowSelection(tbody); rowElement.classList.add('data-grid__row--selected'); rowElement.setAttribute('aria-selected', 'true'); const instanceId = Number(rowElement.dataset.instanceId); onSelect(Number.isInteger(instanceId) ? instanceId : null); }; /** * Обновляет данные одной строки рабочей таблицы без перезагрузки всей таблицы. * @param {HTMLElement} tableWrapper - Обёртка таблицы экземпляров. * @param {Object} rowData - Обновлённые данные экземпляра из instanceService. * @returns {boolean} true, если строка найдена и обновлена. */ const updateInstanceRowInTable = (tableWrapper, rowData) => { if (!tableWrapper || !rowData?.instanceId) { return false; } const rowElement = tableWrapper.querySelector( `[data-instance-id="${rowData.instanceId}"]`, ); if (!(rowElement instanceof HTMLTableRowElement)) { return false; } const rowNumber = rowElement.dataset.rowNumber ?? ''; const displayValues = mapInstanceRowToDisplayValues(rowData, Number(rowNumber)); rowElement.dataset.hasShield = rowData.hasShield ? 'true' : 'false'; INSTANCES_COLUMNS.forEach(({ key, align, isGroupEnd, isGroupStart }, columnIndex) => { const cellElement = rowElement.children[columnIndex]; if (!(cellElement instanceof HTMLTableCellElement)) { return; } cellElement.textContent = displayValues[key]; cellElement.className = 'data-grid__cell'; cellElement.classList.add(`data-grid__cell--${align}`); if (isGroupEnd) { cellElement.classList.add('data-grid__cell--group-end'); } if (isGroupStart) { cellElement.classList.add('data-grid__cell--group-start'); } }); rowElement.classList.remove('data-grid__row--selected'); rowElement.setAttribute('aria-selected', 'false'); if (Array.isArray(tableWrapper.__instanceSourceRows)) { const sourceRowIndex = tableWrapper.__instanceSourceRows.findIndex( (row) => row.instanceId === rowData.instanceId, ); if (sourceRowIndex >= 0) { tableWrapper.__instanceSourceRows[sourceRowIndex] = rowData; } } return true; }; /** * Обновляет индикаторы сортировки в заголовке таблицы экземпляров. * @param {HTMLTableSectionElement} thead - Секция thead таблицы экземпляров. * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} sortState - Состояние сортировки. * @returns {void} */ const updateInstancesTableSortHeaders = (thead, sortState) => { thead.querySelectorAll('.data-grid__header-cell--sortable').forEach((headerCell) => { const sortButton = headerCell.querySelector('.data-grid__sort-button'); if (!(sortButton instanceof HTMLButtonElement)) { return; } const columnKey = sortButton.dataset.sortKey; const isActiveSort = sortState.sortKey === columnKey; headerCell.classList.toggle('data-grid__header-cell--sorted', isActiveSort); headerCell.setAttribute( 'aria-sort', isActiveSort ? (sortState.sortDirection === INSTANCES_SORT_DIRECTION_ASC ? 'ascending' : 'descending') : 'none', ); headerCell.querySelectorAll('.data-grid__sort-indicator').forEach((indicator) => { if (indicator instanceof HTMLElement) { updateInstanceSortIndicator(indicator, sortState, columnKey); } }); const sortTitle = isActiveSort ? (sortState.sortDirection === INSTANCES_SORT_DIRECTION_ASC ? 'Сортировка по возрастанию. Нажмите для убывания.' : 'Сортировка по убыванию. Нажмите для сброса.') : 'Сортировать по этому столбцу'; sortButton.title = sortTitle; sortButton.setAttribute( 'aria-label', `${sortButton.querySelector('.data-grid__sort-button-text')?.textContent?.replace('\n', ' ') ?? columnKey}. ${sortTitle}`, ); }); }; /** * Заполняет тело таблицы экземпляров строками данных. * @param {HTMLTableSectionElement} tbody - Секция tbody таблицы экземпляров. * @param {Array<Object>} rows - Строки экземпляров для отображения. * @param {(instanceId: number|null) => void} onSelect - Обработчик выбора экземпляра. * @param {(instanceId: number) => void} onDelete - Обработчик удаления экземпляра. * @returns {void} */ const fillInstancesTableBody = (tbody, rows, onSelect, onDelete) => { tbody.replaceChildren(); if (!rows || rows.length === 0) { const emptyRow = document.createElement('tr'); emptyRow.className = 'data-grid__row data-grid__row--empty'; const emptyCell = document.createElement('td'); emptyCell.className = 'data-grid__cell data-grid__cell--empty'; emptyCell.colSpan = INSTANCES_COLUMNS.length + 1; emptyCell.textContent = INSTANCES_EMPTY_MESSAGE; emptyRow.appendChild(emptyCell); tbody.appendChild(emptyRow); return; } rows.forEach((row, rowIndex) => { const dataRow = document.createElement('tr'); dataRow.className = 'data-grid__row data-grid__row--selectable'; dataRow.dataset.instanceId = String(row.instanceId); dataRow.dataset.hasShield = row.hasShield ? 'true' : 'false'; dataRow.dataset.rowNumber = String(rowIndex + 1); dataRow.tabIndex = 0; dataRow.setAttribute('role', 'row'); dataRow.setAttribute('aria-selected', 'false'); dataRow.setAttribute( 'aria-label', `Строка ${rowIndex + 1}, заказ ${row.orderNumber}, позиция ${row.positionNumber}, ${row.productType}`, ); const displayValues = mapInstanceRowToDisplayValues(row, rowIndex + 1); INSTANCES_COLUMNS.forEach(({ key, align, isGroupEnd, isGroupStart }) => { dataRow.appendChild(createInstanceDataCell( displayValues[key], align, isGroupEnd, isGroupStart, )); }); dataRow.appendChild(createInstanceDeleteCell(row.instanceId, onDelete)); const handleSelect = () => { handleInstanceRowSelect(dataRow, tbody, onSelect); }; dataRow.addEventListener('click', handleSelect); dataRow.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); handleSelect(); }); tbody.appendChild(dataRow); }); }; /** * Перерисовывает таблицу экземпляров с учётом нового состояния сортировки. * @param {HTMLElement} wrapper - Обёртка таблицы экземпляров. * @param {{ sortKey: string|null, sortDirection: 'asc'|'desc' }} sortState - Состояние сортировки. * @returns {void} */ const applyInstancesTableSort = (wrapper, sortState) => { const sourceRows = wrapper.__instanceSourceRows; const tbody = wrapper.querySelector('.data-grid__body'); const thead = wrapper.querySelector('.data-grid__head'); const onSelect = wrapper.__instanceOnSelect; const onDelete = wrapper.__instanceOnDelete; if (!Array.isArray(sourceRows) || !(tbody instanceof HTMLTableSectionElement) || !onSelect || !onDelete) { return; } wrapper.__instanceSortState = sortState; const sortedRows = sortInstanceRows(sourceRows, sortState); fillInstancesTableBody(tbody, sortedRows, onSelect, onDelete); if (thead instanceof HTMLTableSectionElement) { updateInstancesTableSortHeaders(thead, sortState); } }; /** * Создаёт DOM-элемент рабочей таблицы экземпляров изделий с возможностью выбора одной строки. * @param {Array<Object>} [rows=[]] - Строки экземпляров из instanceService. * @param {(instanceId: number|null) => void} [onSelect] - Обработчик выбора экземпляра. * @param {(instanceId: number) => void} [onDelete] - Обработчик удаления экземпляра. * @param {{ sortState?: { sortKey: string|null, sortDirection: 'asc'|'desc' }, onSortChange?: (sortState: { sortKey: string|null, sortDirection: 'asc'|'desc' }) => void }} [options={}] - Настройки сортировки. * @returns {HTMLElement} Обёртка с таблицей экземпляров. */ const createInstancesTable = ( rows = [], onSelect = () => {}, onDelete = () => {}, options = {}, ) => { const sortState = options.sortState ?? getDefaultInstanceSortState(); const onSortChange = options.onSortChange ?? (() => {}); const sortedRows = sortInstanceRows(rows, sortState); const wrapper = document.createElement('div'); wrapper.className = 'workspace__table-wrapper'; const handleSortColumn = (columnKey) => { const nextSortState = getNextInstanceSortState(wrapper.__instanceSortState, columnKey); applyInstancesTableSort(wrapper, nextSortState); onSortChange(nextSortState); }; const table = document.createElement('table'); table.className = 'data-grid'; table.setAttribute('aria-label', 'Рабочая таблица экземпляров изделий'); table.appendChild(createInstancesColgroup()); const tbody = document.createElement('tbody'); tbody.className = 'data-grid__body'; table.appendChild(createInstancesTableHead(sortState, handleSortColumn)); fillInstancesTableBody(tbody, sortedRows, onSelect, onDelete); table.appendChild(tbody); wrapper.appendChild(table); wrapper.__instanceSourceRows = rows; wrapper.__instanceSortState = sortState; wrapper.__instanceOnSelect = onSelect; wrapper.__instanceOnDelete = onDelete; return wrapper; }; /** Колонки таблицы результата импорта. */ const IMPORT_RESULT_COLUMNS = [ 'Номер заказа', 'Номер позиции', 'Тип изделия', 'Количество экземпляров', ]; /** * Создаёт DOM-элемент таблицы с результатом импорта. * @param {Array<Object>} rows - Строки данных из SQLite. * @param {string} rows[].orderNumber - Номер заказа. * @param {number} rows[].positionNumber - Номер позиции. * @param {string} rows[].productType - Тип изделия. * @param {number} rows[].instanceCount - Количество экземпляров. * @returns {HTMLElement} Обёртка с таблицей результата импорта. */ const createImportResultTable = (rows) => { const wrapper = document.createElement('div'); wrapper.className = 'workspace__table-wrapper'; const table = document.createElement('table'); table.className = 'journal-table'; table.setAttribute('aria-label', 'Результат импорта'); const thead = document.createElement('thead'); thead.className = 'journal-table__head'; const headerRow = document.createElement('tr'); headerRow.className = 'journal-table__row'; IMPORT_RESULT_COLUMNS.forEach((columnTitle) => { const th = document.createElement('th'); th.className = 'journal-table__header-cell'; th.scope = 'col'; th.textContent = columnTitle; headerRow.appendChild(th); }); thead.appendChild(headerRow); const tbody = document.createElement('tbody'); tbody.className = 'journal-table__body'; if (!rows || rows.length === 0) { const emptyRow = document.createElement('tr'); emptyRow.className = 'journal-table__empty-row'; const emptyCell = document.createElement('td'); emptyCell.className = 'journal-table__cell'; emptyCell.colSpan = IMPORT_RESULT_COLUMNS.length; emptyCell.textContent = 'Данные импорта отсутствуют'; emptyRow.appendChild(emptyCell); tbody.appendChild(emptyRow); } else { rows.forEach((row) => { const dataRow = document.createElement('tr'); dataRow.className = 'journal-table__row'; const values = [ row.orderNumber, row.positionNumber, row.productType, row.instanceCount, ]; values.forEach((value) => { const td = document.createElement('td'); td.className = 'journal-table__cell'; td.textContent = String(value); dataRow.appendChild(td); }); tbody.appendChild(dataRow); }); } table.appendChild(thead); table.appendChild(tbody); wrapper.appendChild(table); return wrapper; }; export { createEmptyJournalTable, createImportResultTable, createInstancesTable, createJournalTable, getDefaultInstanceSortState, IMPORT_RESULT_COLUMNS, INSTANCES_ACTIONS_COLUMN_WIDTH, INSTANCES_COLUMNS, INSTANCES_EMPTY_MESSAGE, INSTANCES_GROUP_ORDER, INSTANCES_GROUP_SHIELD, INSTANCES_GROUP_TITLES, INSTANCES_SORTABLE_COLUMN_KEYS, INSTANCES_SORT_DIRECTION_ASC, INSTANCES_SORT_DIRECTION_DESC, JOURNAL_COLUMNS, JOURNAL_EMPTY_MESSAGE, JOURNAL_MERGED_COLUMN_KEYS, updateInstanceRowInTable, };