Сводная ОСВ: колонка «Тип контрагента» по макету [30] (#8) #9

Merged
aep merged 1 commits from feat/8-osv-counterparty-type into test 2026-10-08 11:03:11 +03:00
4 changed files with 151 additions and 40 deletions

View File

@ -16,10 +16,12 @@
### Requirement: Таблица свода и фильтры
Экран SHALL показывать строки `GET /consolidations/versions/{id}/osv` постранично (размер страницы ≤ 2000): ЮЛ, счёт,
контрагент, ИНН контрагента, признак контрагента (ГСК/ГК/Внешний), способ сопоставления, сальдо на начало Дт/Кт,
обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов). Фильтры: ЮЛ (`legal_entity_id`),
корень счёта (`account_root`), признак контрагента (`counterparty_type`), только несопоставленные (`unmapped`), поиск по
контрагенту (`q`, ≤ 500 символов). Смена фильтра SHALL перечитывать первую страницу.
контрагент, ИНН контрагента, сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с
разделителями разрядов) и последней колонкой «Тип контрагента» — бейдж ГСК (синий) / ГК (зелёный) / Внешний (серый), как
в макете [30]. Фильтры — в заголовках колонок: ЮЛ (`legal_entity_id`), корень счёта (`account_root`), поиск по
контрагенту (`q`, ≤ 500 символов), тип контрагента (`counterparty_type`, одно значение — на колонке «Тип контрагента»);
только несопоставленные (`unmapped`) — кнопкой над таблицей. «Применить» и «Сбросить» SHALL закрывать окно фильтра.
Смена фильтра SHALL перечитывать первую страницу.
#### Scenario: Строка из контрольных цифр
- **WHEN** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит»

View File

@ -2,9 +2,11 @@ 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';
@ -26,16 +28,34 @@ const group = (title: string, children: TableColumnsType<ConsolidatedRowOut>): T
type Member = { id: string; name: string };
type OnChange = (patch: OsvFilterDraft) => void;
/** Фильтр в заголовке колонки: `close` закрывает выпадающее окно после «Применить» / «Сбросить». */
type FilterProps = { onChange: OnChange; close: () => void };
function DropdownShell({ onApply, onReset, children }: { onApply: () => void; onReset: () => void; children: React.ReactNode }) {
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}>
<Button
type='primary'
size='small'
onClick={() => {
onApply();
close();
}}>
Применить
</Button>
<Button size='small' onClick={onReset}>
<Button
size='small'
onClick={() => {
onReset();
close();
}}>
Сбросить
</Button>
</Space>
@ -43,10 +63,11 @@ function DropdownShell({ onApply, onReset, children }: { onApply: () => void; on
);
}
function EntityFilter({ members, value, onChange }: { members: Member[]; value?: string; onChange: OnChange }) {
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);
@ -64,10 +85,11 @@ function EntityFilter({ members, value, onChange }: { members: Member[]; value?:
);
}
function AccountFilter({ value, onChange }: { value?: string; onChange: OnChange }) {
function AccountFilter({ value, onChange, close }: FilterProps & { value?: string }) {
const [draft, setDraft] = useState(value ?? '');
return (
<DropdownShell
close={close}
onApply={() => onChange({ account_root: draft })}
onReset={() => {
setDraft('');
@ -78,42 +100,66 @@ function AccountFilter({ value, onChange }: { value?: string; onChange: OnChange
placeholder='корень счёта, напр. 60'
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPressEnter={() => onChange({ account_root: draft })}
onPressEnter={() => {
onChange({ account_root: draft });
close();
}}
/>
</DropdownShell>
);
}
function CounterpartyFilter({ q, type, onChange }: { q?: string; type?: CounterpartyType; onChange: OnChange }) {
const [draftQ, setDraftQ] = useState(q ?? '');
const [draftType, setDraftType] = useState<CounterpartyType | undefined>(type);
function CounterpartyFilter({ q, onChange, close }: FilterProps & { q?: string }) {
const [draft, setDraft] = useState(q ?? '');
return (
<DropdownShell
onApply={() => onChange({ q: draftQ, counterparty_type: draftType })}
close={close}
onApply={() => onChange({ q: draft })}
onReset={() => {
setDraftQ('');
setDraftType(undefined);
onChange({ q: '', counterparty_type: undefined });
setDraft('');
onChange({ q: '' });
}}>
<Input
allowClear
placeholder='Название или ИНН'
maxLength={500}
value={draftQ}
onChange={(e) => setDraftQ(e.target.value)}
onPressEnter={() => onChange({ q: draftQ, counterparty_type: draftType })}
/>
<Select
allowClear
placeholder='Признак контрагента'
value={draftType}
onChange={setDraftType}
options={(['GSK', 'GK', 'EXTERNAL'] as const).map((t) => ({ value: t, label: counterpartyTypeLabel(t) }))}
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 = {
@ -139,8 +185,14 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
fixed: 'left',
filterIcon: filterIcon(!!filters.legal_entity_id),
// ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра
filterDropdown: () => (
<EntityFilter key={filters.legal_entity_id ?? ''} members={members} value={filters.legal_entity_id} onChange={onFilter} />
filterDropdown: ({ close }) => (
<EntityFilter
key={filters.legal_entity_id ?? ''}
members={members}
value={filters.legal_entity_id}
onChange={onFilter}
close={close}
/>
),
},
{
@ -149,7 +201,9 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
dataIndex: 'account_code',
width: 110,
filterIcon: filterIcon(!!filters.account_root),
filterDropdown: () => <AccountFilter key={filters.account_root ?? ''} value={filters.account_root} onChange={onFilter} />,
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) },
{
@ -157,20 +211,24 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
title: 'Контрагенты',
width: 260,
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
filterIcon: filterIcon(!!filters.q || !!filters.counterparty_type),
filterDropdown: () => (
<CounterpartyFilter
key={`${filters.q ?? ''}|${filters.counterparty_type ?? ''}`}
q={filters.q}
type={filters.counterparty_type}
onChange={onFilter}
/>
),
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>
@ -183,7 +241,7 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
locale={{
emptyText: filtered ? 'По заданным фильтрам строк нет' : 'В своде пока нет строк: загрузите и подтвердите ОСВ организаций',
}}
scroll={{ x: 1700, y: 'calc(100vh - 460px)' }}
scroll={{ x: 1830, y: 'calc(100vh - 460px)' }}
pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }}
/>
);

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge';
describe('counterpartyTypeBadge', () => {
it('цвета бейджей — как в макете [30]', () => {
expect(counterpartyTypeBadge('GSK')).toEqual({
text: 'ГСК',
color: 'rgb(47, 95, 185)',
bgColor: 'rgba(59, 130, 246, 0.12)',
borderColor: 'transparent',
});
expect(counterpartyTypeBadge('GK')).toEqual({
text: 'ГК',
color: 'rgb(31, 114, 55)',
bgColor: 'rgba(46, 166, 75, 0.14)',
borderColor: 'transparent',
});
expect(counterpartyTypeBadge('EXTERNAL')).toEqual({
text: 'Внешний',
color: 'rgb(102, 112, 133)',
bgColor: 'rgba(15, 23, 42, 0.06)',
borderColor: 'transparent',
});
});
it('неизвестный признак — серый бейдж с исходным кодом', () => {
expect(counterpartyTypeBadge('OTHER' as never)).toMatchObject({ text: 'OTHER', color: 'rgb(102, 112, 133)' });
});
it('порядок значений фильтра — ГСК, ГК, Внешний', () => {
expect(COUNTERPARTY_TYPES).toEqual(['GSK', 'GK', 'EXTERNAL']);
});
});

View File

@ -0,0 +1,18 @@
import type { CounterpartyType } from '../../types/api';
import { counterpartyTypeLabel } from '../../utils/labels';
export type Badge = { text: string; color: string; bgColor: string; borderColor: string };
/** Порядок значений в фильтре «Тип контрагента». */
export const COUNTERPARTY_TYPES: readonly CounterpartyType[] = ['GSK', 'GK', 'EXTERNAL'];
// Цвета — из бейджей `osv-type-badge--*` макета [30]
const COLORS: Record<CounterpartyType, Pick<Badge, 'color' | 'bgColor'>> = {
GSK: { color: 'rgb(47, 95, 185)', bgColor: 'rgba(59, 130, 246, 0.12)' },
GK: { color: 'rgb(31, 114, 55)', bgColor: 'rgba(46, 166, 75, 0.14)' },
EXTERNAL: { color: 'rgb(102, 112, 133)', bgColor: 'rgba(15, 23, 42, 0.06)' },
};
export function counterpartyTypeBadge(type: CounterpartyType): Badge {
return { text: counterpartyTypeLabel(type), ...(COLORS[type] ?? COLORS.EXTERNAL), borderColor: 'transparent' };
}