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): ColumnsType[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 { 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 ? {analyticsTitle} : 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([]); const [loading, setLoading] = useState(true); const [confirming, setConfirming] = useState(false); const [problem, setProblem] = useState(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(); 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 (
Суммы в рублях; строки ОСВ только для чтения
} placeholder={table.hasCounterparties ? 'Контрагент или ИНН' : 'Поиск по аналитике'} style={{ width: 320 }} value={query} onChange={(e) => setQuery(e.target.value)} />