Merge branch 'test' into feat/38-osv-virtual-table
Слияние с #39 (PR #40): scroll.x считается от фактических колонок, scroll.y — числом для virtual. Колонки сумм расширены до 160 px: итоги из 17 цифр («Итого по документу») переносились на две строки и обрезались внизу таблицы (O-3). Спека: поиск — по аналитике. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b861646a86
@ -1,7 +1,7 @@
|
|||||||
# document-review-ui Specification
|
# document-review-ui Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — деревом строк только для чтения.
|
Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — плоской таблицей строк только для чтения.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -72,9 +72,19 @@ column_id, amount}`, ячейка достроенной строки (`id = nul
|
|||||||
- **THEN** показан список нарушений из ответа и кнопка «Принять с комментарием»
|
- **THEN** показан список нарушений из ответа и кнопка «Принять с комментарием»
|
||||||
|
|
||||||
### Requirement: ОСВ-документ
|
### Requirement: ОСВ-документ
|
||||||
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) деревом по
|
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) плоской
|
||||||
`parent_entry_id` с колонками: счёт/субсчёт, контрагент, ИНН, сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец
|
таблицей строк нижнего уровня (договоры отдельными строками не идут — их сумма в строке контрагента) с колонками:
|
||||||
Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм.
|
субсчёт, подразделение (если в файле есть этот уровень), аналитика, ИНН (если есть контрагенты), сальдо на начало Дт/Кт,
|
||||||
|
обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм.
|
||||||
|
Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения не
|
||||||
|
учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name`
|
||||||
|
(«Основные средства»); смесь видов или вид без `group_name` — «Аналитика», а перечень видов — во всплывающей подсказке
|
||||||
|
заголовка. Колонка «ИНН» и подсказка поиска «Контрагент или ИНН» SHALL показываться только при наличии контрагентов
|
||||||
|
(`COUNTERPARTY`); иначе колонки ИНН нет, а поиск подписан «Поиск по аналитике».
|
||||||
|
|
||||||
|
#### Scenario: ОСВ по основным средствам
|
||||||
|
- **WHEN** листья документа — аналитика с `group_name` «Основные средства», контрагентов нет
|
||||||
|
- **THEN** колонка называется «Основные средства», колонки ИНН нет, поиск подписан «Поиск по аналитике»
|
||||||
|
|
||||||
#### Scenario: Подтверждение ОСВ
|
#### Scenario: Подтверждение ОСВ
|
||||||
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
|
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
|
||||||
@ -82,7 +92,7 @@ column_id, amount}`, ячейка достроенной строки (`id = nul
|
|||||||
|
|
||||||
### Requirement: Виртуализация таблицы ОСВ-документа
|
### Requirement: Виртуализация таблицы ОСВ-документа
|
||||||
Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не
|
Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не
|
||||||
отрисовываются. Строки по-прежнему загружаются целиком, поиск по контрагенту/ИНН и фильтр по субсчёту — клиентские;
|
отрисовываются. Строки по-прежнему загружаются целиком, поиск по аналитике (и ИНН) и фильтр по субсчёту — клиентские;
|
||||||
серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении.
|
серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении.
|
||||||
Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт»
|
Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт»
|
||||||
закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без
|
закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
|
import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { Alert, Button, Input, Select, Table, message } from 'antd';
|
import { Alert, Button, Input, Select, Table, Tooltip, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
@ -11,30 +11,51 @@ import { confirmErrorInfo } from './confirmError';
|
|||||||
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
|
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
|
||||||
import { osvBodyHeight } from './osvTableSize';
|
import { osvBodyHeight } from './osvTableSize';
|
||||||
|
|
||||||
|
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
|
||||||
|
const MONEY_WIDTH = 160;
|
||||||
|
|
||||||
const money = (key: AmountKey, title: string) => ({
|
const money = (key: AmountKey, title: string) => ({
|
||||||
key,
|
key,
|
||||||
title,
|
title,
|
||||||
dataIndex: key,
|
dataIndex: key,
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
width: 140,
|
width: MONEY_WIDTH,
|
||||||
render: (v: string | null) => formatAmount(v, { zeroDash: true }),
|
render: (v: string | null) => formatAmount(v, { zeroDash: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const group = (title: string, children: ColumnsType<OsvRow>): ColumnsType<OsvRow>[number] => ({ title, align: 'center', children });
|
const group = (title: string, children: ColumnsType<OsvRow>): ColumnsType<OsvRow>[number] => ({ title, align: 'center', children });
|
||||||
|
|
||||||
function buildColumns(hasDepartments: boolean): ColumnsType<OsvRow> {
|
interface ColumnsOptions {
|
||||||
|
hasDepartments: boolean;
|
||||||
|
hasCounterparties: boolean;
|
||||||
|
analyticsTitle: string;
|
||||||
|
analyticsHint: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ширина колонок до сумм (Субсчёт, [Подразделение], аналитика, [ИНН]) и всей таблицы с шестью суммами по MONEY_WIDTH. */
|
||||||
|
const widthBeforeAmounts = ({ hasDepartments, hasCounterparties }: ColumnsOptions) =>
|
||||||
|
100 + (hasDepartments ? 160 : 0) + 320 + (hasCounterparties ? 120 : 0);
|
||||||
|
|
||||||
|
function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
|
||||||
|
const { hasDepartments, hasCounterparties, analyticsTitle, analyticsHint } = options;
|
||||||
return [
|
return [
|
||||||
{ key: 'account', title: 'Субсчёт', dataIndex: 'account', width: 100, fixed: 'left' },
|
{ key: 'account', title: 'Субсчёт', dataIndex: 'account', width: 100, fixed: 'left' },
|
||||||
...(hasDepartments ? [{ key: 'department', title: 'Подразделение', dataIndex: 'department', width: 160 }] : []),
|
...(hasDepartments ? [{ key: 'department', title: 'Подразделение', dataIndex: 'department', width: 160 }] : []),
|
||||||
{ key: 'analytics', title: 'Контрагент / аналитика', dataIndex: 'analytics', width: 320, render: (v: string | null) => v ?? '—' },
|
{
|
||||||
{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 },
|
key: 'analytics',
|
||||||
|
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
|
||||||
|
dataIndex: 'analytics',
|
||||||
|
width: 320,
|
||||||
|
render: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
...(hasCounterparties ? [{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 }] : []),
|
||||||
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
|
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
|
||||||
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
|
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
|
||||||
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
|
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поиск по контрагенту/ИНН без учёта регистра и фильтр по субсчёту — на клиенте: строки документа уже загружены целиком. */
|
/** Поиск по аналитике (и по ИНН, если он есть) без учёта регистра и фильтр по субсчёту — на клиенте: строки документа уже загружены целиком. */
|
||||||
function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
|
function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return rows.filter(
|
return rows.filter(
|
||||||
@ -82,10 +103,19 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
|||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [account, setAccount] = useState<string | undefined>();
|
const [account, setAccount] = useState<string | undefined>();
|
||||||
const table = useMemo(() => flattenEntries(entries), [entries]);
|
const table = useMemo(() => flattenEntries(entries), [entries]);
|
||||||
const columns = useMemo(() => buildColumns(table.hasDepartments), [table.hasDepartments]);
|
const columnsOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
hasDepartments: table.hasDepartments,
|
||||||
|
hasCounterparties: table.hasCounterparties,
|
||||||
|
analyticsTitle: table.analyticsTitle,
|
||||||
|
analyticsHint: table.analyticsHint,
|
||||||
|
}),
|
||||||
|
[table.hasDepartments, table.hasCounterparties, table.analyticsTitle, table.analyticsHint],
|
||||||
|
);
|
||||||
|
const columns = useMemo(() => buildColumns(columnsOptions), [columnsOptions]);
|
||||||
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
|
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
|
||||||
// колонки до сумм: Субсчёт, [Подразделение], Контрагент, ИНН — под ними подпись «Итого по документу»
|
// колонки до сумм: Субсчёт, [Подразделение], аналитика, [ИНН] — под ними подпись «Итого по документу»
|
||||||
const labelSpan = table.hasDepartments ? 4 : 3;
|
const labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0);
|
||||||
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
|
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
@ -120,7 +150,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
|||||||
<Input
|
<Input
|
||||||
allowClear
|
allowClear
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
placeholder='Контрагент или ИНН'
|
placeholder={table.hasCounterparties ? 'Контрагент или ИНН' : 'Поиск по аналитике'}
|
||||||
style={{ width: 320 }}
|
style={{ width: 320 }}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
@ -166,7 +196,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
|||||||
dataSource={rows}
|
dataSource={rows}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
|
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
|
||||||
scroll={{ y: bodyHeight, x: table.hasDepartments ? 1540 : 1380 }}
|
scroll={{ y: bodyHeight, x: widthBeforeAmounts(columnsOptions) + AMOUNT_KEYS.length * MONEY_WIDTH }}
|
||||||
summary={() =>
|
summary={() =>
|
||||||
entries.length > 0 && (
|
entries.length > 0 && (
|
||||||
<Table.Summary fixed>
|
<Table.Summary fixed>
|
||||||
|
|||||||
@ -113,6 +113,55 @@ describe('flattenEntries', () => {
|
|||||||
total: expect.objectContaining({ opening_debit: null }),
|
total: expect.objectContaining({ opening_debit: null }),
|
||||||
accounts: [],
|
accounts: [],
|
||||||
hasDepartments: false,
|
hasDepartments: false,
|
||||||
|
analyticsTitle: 'Аналитика',
|
||||||
|
analyticsHint: null,
|
||||||
|
hasCounterparties: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('flattenEntries: вид аналитики', () => {
|
||||||
|
const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' });
|
||||||
|
const cp = (id: string) => e(id, '60', 'COUNTERPARTY', `ООО ${id}`, { opening_debit: '1' }, { group_name: 'Контрагенты' });
|
||||||
|
const os = (id: string, name: string | null = 'Основные средства') =>
|
||||||
|
e(id, '60', 'OTHER', `Объект ${id}`, { opening_debit: '1' }, { group_name: name });
|
||||||
|
|
||||||
|
it('только контрагенты — «Контрагент», ИНН и поиск по ИНН нужны', () => {
|
||||||
|
const t = flattenEntries([acc, cp('a'), cp('b')]);
|
||||||
|
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Контрагент', null, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('договор под контрагентом вид не меняет', () => {
|
||||||
|
const t = flattenEntries([acc, cp('a'), e('d', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '1' }, { group_name: 'Договоры' })]);
|
||||||
|
expect([t.analyticsTitle, t.hasCounterparties]).toEqual(['Контрагент', true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('только основные средства — название вида из файла, без ИНН', () => {
|
||||||
|
const t = flattenEntries([acc, os('a'), os('b')]);
|
||||||
|
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Основные средства', null, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('смесь видов — «Аналитика» и перечень видов в подсказке', () => {
|
||||||
|
const t = flattenEntries([acc, cp('a'), os('b')]);
|
||||||
|
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual([
|
||||||
|
'Аналитика',
|
||||||
|
'Виды аналитики: Контрагенты, Основные средства',
|
||||||
|
true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('два разных вида не-контрагентов — тоже «Аналитика»', () => {
|
||||||
|
const t = flattenEntries([acc, os('a'), os('b', 'Номенклатура')]);
|
||||||
|
expect([t.analyticsTitle, t.analyticsHint]).toEqual(['Аналитика', 'Виды аналитики: Основные средства, Номенклатура']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('вид без названия — «Аналитика»', () => {
|
||||||
|
const t = flattenEntries([acc, os('a', null)]);
|
||||||
|
expect([t.analyticsTitle, t.hasCounterparties]).toEqual(['Аналитика', false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('структурные строки вид не определяют; без листьев аналитики — «Аналитика» без ИНН', () => {
|
||||||
|
const t = flattenEntries([e('60', null, 'ACCOUNT', '60', { opening_debit: '5' }, { account_code: '60' })]);
|
||||||
|
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Аналитика', null, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -29,8 +29,41 @@ export type OsvTable = {
|
|||||||
accounts: string[];
|
accounts: string[];
|
||||||
/** В файле есть уровень «Подразделение» — показать колонку. */
|
/** В файле есть уровень «Подразделение» — показать колонку. */
|
||||||
hasDepartments: boolean;
|
hasDepartments: boolean;
|
||||||
|
/** Заголовок колонки аналитики по виду строк: «Контрагент», название вида из файла («Основные средства») или «Аналитика». */
|
||||||
|
analyticsTitle: string;
|
||||||
|
/** Перечень видов аналитики для подсказки у заголовка — только когда заголовок «Аналитика» из-за смеси видов. */
|
||||||
|
analyticsHint: string | null;
|
||||||
|
/** Среди строк есть контрагенты (COUNTERPARTY): только тогда нужны колонка ИНН и поиск по ИНН. */
|
||||||
|
hasCounterparties: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIXED_TITLE = 'Аналитика';
|
||||||
|
|
||||||
|
/** Заголовок колонки аналитики по видам строк-листьев. Вид — COUNTERPARTY или название группировки из шапки файла
|
||||||
|
* (`group_name`, у остальных типов — OTHER). Один вид: контрагенты → «Контрагент», иначе его название; смесь или
|
||||||
|
* вид без названия → «Аналитика», а при смеси перечень видов уходит в подсказку. */
|
||||||
|
export function analyticsHeading(leaves: EntryOut[]): Pick<OsvTable, 'analyticsTitle' | 'analyticsHint' | 'hasCounterparties'> {
|
||||||
|
const kinds = new Map<string, string | null>();
|
||||||
|
for (const leaf of leaves) {
|
||||||
|
const name = leaf.group_name?.trim() || null;
|
||||||
|
const key = leaf.group_type === 'COUNTERPARTY' ? 'COUNTERPARTY' : `name:${name ?? ''}`;
|
||||||
|
const label = leaf.group_type === 'COUNTERPARTY' ? (name ?? 'Контрагенты') : name;
|
||||||
|
if (!kinds.has(key) || kinds.get(key) === null) kinds.set(key, label);
|
||||||
|
}
|
||||||
|
const hasCounterparties = kinds.has('COUNTERPARTY');
|
||||||
|
if (kinds.size === 1) {
|
||||||
|
const [[key, label]] = [...kinds];
|
||||||
|
if (key === 'COUNTERPARTY') return { analyticsTitle: 'Контрагент', analyticsHint: null, hasCounterparties };
|
||||||
|
return { analyticsTitle: label ?? MIXED_TITLE, analyticsHint: null, hasCounterparties };
|
||||||
|
}
|
||||||
|
const labels = [...kinds.values()].map((label) => label ?? 'без названия');
|
||||||
|
return {
|
||||||
|
analyticsTitle: MIXED_TITLE,
|
||||||
|
analyticsHint: labels.length > 0 ? `Виды аналитики: ${labels.join(', ')}` : null,
|
||||||
|
hasCounterparties,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']);
|
const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']);
|
||||||
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
|
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
|
||||||
|
|
||||||
@ -54,10 +87,12 @@ export function flattenEntries(entries: EntryOut[]): OsvTable {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rows: OsvRow[] = [];
|
const rows: OsvRow[] = [];
|
||||||
|
const leaves: EntryOut[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue;
|
if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue;
|
||||||
const structural = STRUCTURE.has(entry.group_type ?? '');
|
const structural = STRUCTURE.has(entry.group_type ?? '');
|
||||||
if (structural && !hasAmount(entry)) continue;
|
if (structural && !hasAmount(entry)) continue;
|
||||||
|
if (!structural) leaves.push(entry);
|
||||||
const chain = [entry, ...ancestors(entry)];
|
const chain = [entry, ...ancestors(entry)];
|
||||||
const department = chain.find((item) => item.group_type === 'DEPARTMENT');
|
const department = chain.find((item) => item.group_type === 'DEPARTMENT');
|
||||||
rows.push({
|
rows.push({
|
||||||
@ -73,5 +108,5 @@ export function flattenEntries(entries: EntryOut[]): OsvTable {
|
|||||||
const roots = entries.filter((entry) => !entry.parent_entry_id || !byId.has(entry.parent_entry_id));
|
const roots = entries.filter((entry) => !entry.parent_entry_id || !byId.has(entry.parent_entry_id));
|
||||||
const total = Object.fromEntries(AMOUNT_KEYS.map((key) => [key, sumAmounts(roots.map((root) => root[key]))])) as Amounts;
|
const total = Object.fromEntries(AMOUNT_KEYS.map((key) => [key, sumAmounts(roots.map((root) => root[key]))])) as Amounts;
|
||||||
const accounts = [...new Set(rows.map((row) => row.account).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true }));
|
const accounts = [...new Set(rows.map((row) => row.account).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true }));
|
||||||
return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT') };
|
return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT'), ...analyticsHeading(leaves) };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user