arm-web/src/widgets/DocumentReview/OsvDocument.tsx

227 lines
9.3 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 { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
import { osvBodyHeight } from './osvTableSize';
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
const MONEY_WIDTH = 160;
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,
render: (v: string | null) => v ?? '—',
},
...(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', 'Кредит')]),
];
}
/** Поиск по аналитике (и по ИНН, если он есть) без учёта регистра и фильтр по субсчёту — на клиенте: строки документа уже загружены целиком. */
function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
const q = query.trim().toLowerCase();
return rows.filter(
(row) =>
(!account || row.account === account) && (!q || (row.analytics ?? '').toLowerCase().includes(q) || (row.inn ?? '').includes(q)),
);
}
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={`${f.column_index}-${f.relation}`}>
{f.relation}: ожидалось {formatAmount(f.expected)}, получено {formatAmount(f.actual)}
</li>
))}
</ul>
) : undefined
}
/>
)}
<Table<OsvRow>
// virtual: в DOM только видимые строки (ОСВ на десятки тысяч строк иначе роняет вкладку); нужны числовые scroll.x и scroll.y
virtual
bordered
size='small'
rowKey='key'
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>
);
}