Andrey Epifancev 519061e859 feat(osv): колонка «Тип контрагента» в сводной ОСВ по макету [30] (#8)
- Последняя колонка — бейдж ГСК (синий) / ГК (зелёный) / Внешний (серый), цвета из
  osv-type-badge макета; чистая функция counterpartyTypeBadge с тестом.
- Фильтр признака перенесён из «Контрагенты» на колонку «Тип контрагента» (одно
  значение: API принимает один counterparty_type); «Контрагенты» — только поиск.
- «Применить» / «Сбросить» и Enter закрывают окно фильтра (close из antd).
- Спека consolidated-osv-ui: колонка и фильтры по макету, «способ сопоставления»
  убран из колонок (в макете [30] его нет).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:56:12 +04:00

249 lines
7.9 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 { FilterFilled } from '@ant-design/icons';
import { Button, Input, Select, Space, Table } from 'antd';
import type { TableColumnsType } from 'antd';
import { useState } from 'react';
import { TableTag } from '../../components/TableTag/TableTag';
import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api';
import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels';
import { formatAmount } from '../../utils/money';
import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge';
import type { OsvFilterDraft } from './osvFilterState';
type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit';
const money = (key: MoneyKey, 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: TableColumnsType<ConsolidatedRowOut>): TableColumnsType<ConsolidatedRowOut>[number] => ({
title,
align: 'center',
children,
});
type Member = { id: string; name: string };
type OnChange = (patch: OsvFilterDraft) => void;
/** Фильтр в заголовке колонки: `close` закрывает выпадающее окно после «Применить» / «Сбросить». */
type FilterProps = { onChange: OnChange; close: () => void };
function DropdownShell({
onApply,
onReset,
close,
children,
}: { onApply: () => void; onReset: () => void; close: () => void; children: React.ReactNode }) {
return (
<div style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8, width: 260 }}>
{children}
<Space>
<Button
type='primary'
size='small'
onClick={() => {
onApply();
close();
}}>
Применить
</Button>
<Button
size='small'
onClick={() => {
onReset();
close();
}}>
Сбросить
</Button>
</Space>
</div>
);
}
function EntityFilter({ members, value, onChange, close }: FilterProps & { members: Member[]; value?: string }) {
const [draft, setDraft] = useState<string | undefined>(value);
return (
<DropdownShell
close={close}
onApply={() => onChange({ legal_entity_id: draft })}
onReset={() => {
setDraft(undefined);
onChange({ legal_entity_id: undefined });
}}>
<Select
allowClear
showSearch={{ optionFilterProp: 'label' }}
placeholder='Организация'
value={draft}
onChange={setDraft}
options={members.map((m) => ({ value: m.id, label: m.name }))}
/>
</DropdownShell>
);
}
function AccountFilter({ value, onChange, close }: FilterProps & { value?: string }) {
const [draft, setDraft] = useState(value ?? '');
return (
<DropdownShell
close={close}
onApply={() => onChange({ account_root: draft })}
onReset={() => {
setDraft('');
onChange({ account_root: '' });
}}>
<Input
allowClear
placeholder='корень счёта, напр. 60'
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPressEnter={() => {
onChange({ account_root: draft });
close();
}}
/>
</DropdownShell>
);
}
function CounterpartyFilter({ q, onChange, close }: FilterProps & { q?: string }) {
const [draft, setDraft] = useState(q ?? '');
return (
<DropdownShell
close={close}
onApply={() => onChange({ q: draft })}
onReset={() => {
setDraft('');
onChange({ q: '' });
}}>
<Input
allowClear
placeholder='Название или ИНН'
maxLength={500}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPressEnter={() => {
onChange({ q: draft });
close();
}}
/>
</DropdownShell>
);
}
// API принимает один counterparty_type — поэтому выбор одного значения, а не галочки макета
function CounterpartyTypeFilter({ type, onChange, close }: FilterProps & { type?: CounterpartyType }) {
const [draft, setDraft] = useState<CounterpartyType | undefined>(type);
return (
<DropdownShell
close={close}
onApply={() => onChange({ counterparty_type: draft })}
onReset={() => {
setDraft(undefined);
onChange({ counterparty_type: undefined });
}}>
<Select
allowClear
placeholder='Тип контрагента'
value={draft}
onChange={setDraft}
options={COUNTERPARTY_TYPES.map((t) => ({ value: t, label: counterpartyTypeLabel(t) }))}
/>
</DropdownShell>
);
}
function TypeBadge({ type }: { type: CounterpartyType }) {
return <TableTag {...counterpartyTypeBadge(type)} style={{ fontSize: 11, fontWeight: 600, padding: '1px 10px' }} />;
}
const filterIcon = (active: boolean) => <FilterFilled style={{ color: active ? '#2a8c3a' : undefined }} />;
type Props = {
rows: ConsolidatedRowOut[];
total: number;
page: number;
pageSize: number;
loading: boolean;
filters: OsvFilters;
members: Member[];
onPage: (p: number) => void;
onFilter: OnChange;
};
export function OsvTable({ rows, total, page, pageSize, loading, filters, members, onPage, onFilter }: Props) {
const filtered = Object.keys(filters).length > 0;
const columns: TableColumnsType<ConsolidatedRowOut> = [
{
key: 'legal_entity_name',
title: 'Компания ГК',
dataIndex: 'legal_entity_name',
width: 230,
fixed: 'left',
filterIcon: filterIcon(!!filters.legal_entity_id),
// ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра
filterDropdown: ({ close }) => (
<EntityFilter
key={filters.legal_entity_id ?? ''}
members={members}
value={filters.legal_entity_id}
onChange={onFilter}
close={close}
/>
),
},
{
key: 'account_code',
title: 'Счёт',
dataIndex: 'account_code',
width: 110,
filterIcon: filterIcon(!!filters.account_root),
filterDropdown: ({ close }) => (
<AccountFilter key={filters.account_root ?? ''} value={filters.account_root} onChange={onFilter} close={close} />
),
},
{ key: 'period', title: 'Период', dataIndex: 'period', width: 140, render: (p: string) => osvPeriodLabel(p) },
{
key: 'counterparty',
title: 'Контрагенты',
width: 260,
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
filterIcon: filterIcon(!!filters.q),
filterDropdown: ({ close }) => <CounterpartyFilter key={filters.q ?? ''} q={filters.q} onChange={onFilter} close={close} />,
},
{ key: 'counterparty_inn', title: 'ИНН', dataIndex: 'counterparty_inn', width: 130 },
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
{
key: 'counterparty_type',
title: 'Тип контрагента',
dataIndex: 'counterparty_type',
width: 130,
render: (t: CounterpartyType) => <TypeBadge type={t} />,
filterIcon: filterIcon(!!filters.counterparty_type),
filterDropdown: ({ close }) => (
<CounterpartyTypeFilter key={filters.counterparty_type ?? ''} type={filters.counterparty_type} onChange={onFilter} close={close} />
),
},
];
return (
<Table<ConsolidatedRowOut>
bordered
size='small'
rowKey={(r) => r.entry_ids.join(',')}
loading={loading}
columns={columns}
dataSource={rows}
locale={{
emptyText: filtered ? 'По заданным фильтрам строк нет' : 'В своде пока нет строк: загрузите и подтвердите ОСВ организаций',
}}
scroll={{ x: 1830, y: 'calc(100vh - 460px)' }}
pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }}
/>
);
}