- Строка = аналитика нижнего уровня (контрагент, объект ОС); строки счетов и субсчетов не показываются, договоры (CONTRACT) — в строке контрагента, как в сводной ОСВ (§3); субсчёт/подразделение без нижних строк с суммами — строкой с пустой аналитикой. - Колонки: Субсчёт · Подразделение (если уровень есть в файле) · Контрагент / аналитика · ИНН · 6 сумм; закреплённая строка «Итого по документу» = сумма корневых строк счёта (= «Итого» файла 1С). - Поиск по контрагенту/ИНН, фильтр по субсчёту, «Строк: N» — на клиенте. - flattenEntries (osvRows.ts) и точное sumAmounts (копейками, BigInt) с тестами; EntryOut.group_type — как в схеме arm-core; entryTree удалён. - Пустой документ: «В документе нет строк» вместо «No data». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
6.8 KiB
TypeScript
175 lines
6.8 KiB
TypeScript
import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
|
||
import { Alert, Button, Input, Select, Table, 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 { confirmButtonStyle } from './ReviewCard';
|
||
import { confirmErrorInfo } from './confirmError';
|
||
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
|
||
|
||
const money = (key: AmountKey, title: string) => ({
|
||
key,
|
||
title,
|
||
dataIndex: key,
|
||
align: 'right' as const,
|
||
width: 140,
|
||
render: (v: string | null) => formatAmount(v, { zeroDash: true }),
|
||
});
|
||
|
||
const group = (title: string, children: ColumnsType<OsvRow>): ColumnsType<OsvRow>[number] => ({ title, align: 'center', children });
|
||
|
||
function buildColumns(hasDepartments: boolean): ColumnsType<OsvRow> {
|
||
return [
|
||
{ key: 'account', title: 'Субсчёт', dataIndex: 'account', width: 100, fixed: 'left' },
|
||
...(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 },
|
||
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[];
|
||
}
|
||
|
||
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 [query, setQuery] = useState('');
|
||
const [account, setAccount] = useState<string | undefined>();
|
||
const table = useMemo(() => flattenEntries(entries), [entries]);
|
||
const columns = useMemo(() => buildColumns(table.hasDepartments), [table.hasDepartments]);
|
||
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
|
||
// колонки до сумм: Субсчёт, [Подразделение], Контрагент, ИНН — под ними подпись «Итого по документу»
|
||
const labelSpan = table.hasDepartments ? 4 : 3;
|
||
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='Контрагент или ИНН'
|
||
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>
|
||
{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>
|
||
bordered
|
||
size='small'
|
||
rowKey='key'
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={rows}
|
||
pagination={false}
|
||
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
|
||
scroll={{ y: 'calc(100vh - 340px)', x: table.hasDepartments ? 1540 : 1380 }}
|
||
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>
|
||
);
|
||
}
|