Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
227 lines
9.3 KiB
TypeScript
227 lines
9.3 KiB
TypeScript
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>
|
||
);
|
||
}
|