Andrey Epifancev 52b813617a fix(web): синхронизация встроенного фронта с arm-web@test (de4e477) (#146)
Перенесён диапазон arm-web a3c0010..de4e477 (1 коммитов, 4 файлов набора
синхронизации) скриптом docs/scripts/sync-web.sh. После переноса web/ и
arm-web@test идентичны по набору. Коммиты arm-web:
- e202711 fix(DocumentReview): просмотр ОСВ как в 1С — строки счетов и субсчетов, без строк-показателей (#51, AURORA-1702, AURORA-1703)

Co-Authored-By: sync-web.sh <noreply@aepif.ru>
2026-10-21 00:08:27 +04:00

225 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
import { Alert, Button, Input, Select, Table, Tooltip, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { confirmDocument, getAllEntries } from '../../services/api/documents';
import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api';
import { formatAmount } from '../../utils/money';
import { IssuesPanel } from './IssuesPanel';
import { confirmButtonStyle } from './ReviewCard';
import { expectedActualText, failureKey } from './checkText';
import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, filterRows, flattenEntries } from './osvRows';
import { osvBodyHeight } from './osvTableSize';
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
const MONEY_WIDTH = 160;
/** Отступ одного уровня вложенности в колонке аналитики, px. */
const INDENT = 16;
/** Строки-группы (счёт, субсчёт, подразделение с вложенными строками) — жирным, как промежуточные итоги в 1С. */
const groupRowStyle = { fontWeight: 600 };
const money = (key: AmountKey, title: string) => ({
key,
title,
dataIndex: key,
align: 'right' as const,
width: MONEY_WIDTH,
render: (v: string | null) => formatAmount(v, { zeroDash: true }),
});
const group = (title: string, children: ColumnsType<OsvRow>): ColumnsType<OsvRow>[number] => ({ title, align: 'center', children });
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 [
{ key: 'account', title: 'Субсчёт', dataIndex: 'account', width: 100, fixed: 'left' },
...(hasDepartments ? [{ key: 'department', title: 'Подразделение', dataIndex: 'department', width: 160 }] : []),
{
key: 'analytics',
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
dataIndex: 'analytics',
width: 320,
// отступ по уровню вложенности — дерево строк как в 1С
render: (v: string | null, row: OsvRow) => <span style={{ paddingLeft: row.depth * INDENT }}>{v ?? '—'}</span>,
},
...(hasCounterparties ? [{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 }] : []),
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
];
}
interface ConfirmProblem {
code: string | undefined;
message: string;
failures: FailedCheckOut[];
}
/** Высота области прокрутки числом, пересчитывается при изменении размера окна. */
function useBodyHeight(): number {
const [height, setHeight] = useState(() => osvBodyHeight(window.innerHeight));
useEffect(() => {
const onResize = () => setHeight(osvBodyHeight(window.innerHeight));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return height;
}
export function OsvDocument({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
const [entries, setEntries] = useState<EntryOut[]>([]);
const [loading, setLoading] = useState(true);
const [confirming, setConfirming] = useState(false);
const [problem, setProblem] = useState<ConfirmProblem | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
getAllEntries(document.id)
.then((items) => !cancelled && setEntries(items))
.catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось получить строки ОСВ'))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [document.id]);
const bodyHeight = useBodyHeight();
const [query, setQuery] = useState('');
const [account, setAccount] = useState<string | undefined>();
const table = useMemo(() => flattenEntries(entries), [entries]);
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 labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0);
// предупреждения документа (P6-19): все записи issues, без привязки к строкам
const issueItems = useMemo(
() => (document.issues ?? []).map((i) => ({ id: i.id, severity: i.severity, message: i.message, place: '', targetKey: null })),
[document.issues],
);
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
const confirm = async () => {
setConfirming(true);
setProblem(null);
try {
await confirmDocument(document.id);
message.success('Документ подтверждён');
onChanged();
} catch (e: unknown) {
setProblem(confirmErrorInfo(e));
} finally {
setConfirming(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0 }}>
<div>
<Button
type='primary'
icon={<CheckOutlined />}
disabled={!canConfirm}
loading={confirming}
onClick={confirm}
style={canConfirm ? confirmButtonStyle : undefined}>
Подтвердить
</Button>
<span style={{ marginLeft: 12, color: '#667085' }}>Суммы в рублях; строки ОСВ только для чтения</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder={table.hasCounterparties ? 'Контрагент или ИНН' : 'Поиск по аналитике'}
style={{ width: 320 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Select
allowClear
placeholder='Счёт / субсчёт'
style={{ width: 160 }}
value={account}
onChange={setAccount}
options={table.accounts.map((code) => ({ value: code, label: code }))}
/>
<span style={{ marginLeft: 'auto', color: '#667085' }}>Строк: {rows.length}</span>
</div>
<IssuesPanel mode='document' items={issueItems} />
{problem && (
<Alert
type='error'
showIcon
closable
onClose={() => setProblem(null)}
title={<span title={problem.code}>{problem.message}</span>}
description={
problem.failures.length > 0 ? (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{problem.failures.map((f) => (
<li key={failureKey(f)}>
{f.relation}: {expectedActualText(f)}
</li>
))}
</ul>
) : undefined
}
/>
)}
<Table<OsvRow>
// virtual: в DOM только видимые строки (ОСВ на десятки тысяч строк иначе роняет вкладку); нужны числовые scroll.x и scroll.y
virtual
bordered
size='small'
rowKey='key'
onRow={(row) => ({ style: row.isGroup ? groupRowStyle : undefined })}
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
scroll={{ y: bodyHeight, x: widthBeforeAmounts(columnsOptions) + AMOUNT_KEYS.length * MONEY_WIDTH }}
summary={() =>
entries.length > 0 && (
<Table.Summary fixed>
<Table.Summary.Row style={{ fontWeight: 600, background: '#fafafa' }}>
<Table.Summary.Cell index={0} colSpan={labelSpan}>
Итого по документу
</Table.Summary.Cell>
{AMOUNT_KEYS.map((key, i) => (
<Table.Summary.Cell key={key} index={labelSpan + i} align='right'>
{formatAmount(table.total[key], { zeroDash: true })}
</Table.Summary.Cell>
))}
</Table.Summary.Row>
</Table.Summary>
)
}
/>
</div>
);
}