Merge pull request 'Сводная ОСВ: колонка «Тип контрагента» по макету [30] (#8)' (#9) from feat/8-osv-counterparty-type into test
Reviewed-on: #9
This commit is contained in:
commit
08971736b1
@ -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** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит»
|
||||
|
||||
@ -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}` }}
|
||||
/>
|
||||
);
|
||||
|
||||
33
src/widgets/ConsolidatedOsv/counterpartyTypeBadge.test.ts
Normal file
33
src/widgets/ConsolidatedOsv/counterpartyTypeBadge.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
18
src/widgets/ConsolidatedOsv/counterpartyTypeBadge.ts
Normal file
18
src/widgets/ConsolidatedOsv/counterpartyTypeBadge.ts
Normal 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' };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user