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: Таблица свода и фильтры
|
### Requirement: Таблица свода и фильтры
|
||||||
Экран SHALL показывать строки `GET /consolidations/versions/{id}/osv` постранично (размер страницы ≤ 2000): ЮЛ, счёт,
|
Экран SHALL показывать строки `GET /consolidations/versions/{id}/osv` постранично (размер страницы ≤ 2000): ЮЛ, счёт,
|
||||||
контрагент, ИНН контрагента, признак контрагента (ГСК/ГК/Внешний), способ сопоставления, сальдо на начало Дт/Кт,
|
контрагент, ИНН контрагента, сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с
|
||||||
обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов). Фильтры: ЮЛ (`legal_entity_id`),
|
разделителями разрядов) и последней колонкой «Тип контрагента» — бейдж ГСК (синий) / ГК (зелёный) / Внешний (серый), как
|
||||||
корень счёта (`account_root`), признак контрагента (`counterparty_type`), только несопоставленные (`unmapped`), поиск по
|
в макете [30]. Фильтры — в заголовках колонок: ЮЛ (`legal_entity_id`), корень счёта (`account_root`), поиск по
|
||||||
контрагенту (`q`, ≤ 500 символов). Смена фильтра SHALL перечитывать первую страницу.
|
контрагенту (`q`, ≤ 500 символов), тип контрагента (`counterparty_type`, одно значение — на колонке «Тип контрагента»);
|
||||||
|
только несопоставленные (`unmapped`) — кнопкой над таблицей. «Применить» и «Сбросить» SHALL закрывать окно фильтра.
|
||||||
|
Смена фильтра SHALL перечитывать первую страницу.
|
||||||
|
|
||||||
#### Scenario: Строка из контрольных цифр
|
#### Scenario: Строка из контрольных цифр
|
||||||
- **WHEN** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит»
|
- **WHEN** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит»
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import { FilterFilled } from '@ant-design/icons';
|
|||||||
import { Button, Input, Select, Space, Table } from 'antd';
|
import { Button, Input, Select, Space, Table } from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { TableTag } from '../../components/TableTag/TableTag';
|
||||||
import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api';
|
import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api';
|
||||||
import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels';
|
import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels';
|
||||||
import { formatAmount } from '../../utils/money';
|
import { formatAmount } from '../../utils/money';
|
||||||
|
import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge';
|
||||||
import type { OsvFilterDraft } from './osvFilterState';
|
import type { OsvFilterDraft } from './osvFilterState';
|
||||||
|
|
||||||
type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit';
|
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 Member = { id: string; name: string };
|
||||||
type OnChange = (patch: OsvFilterDraft) => void;
|
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 (
|
return (
|
||||||
<div style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8, width: 260 }}>
|
<div style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8, width: 260 }}>
|
||||||
{children}
|
{children}
|
||||||
<Space>
|
<Space>
|
||||||
<Button type='primary' size='small' onClick={onApply}>
|
<Button
|
||||||
|
type='primary'
|
||||||
|
size='small'
|
||||||
|
onClick={() => {
|
||||||
|
onApply();
|
||||||
|
close();
|
||||||
|
}}>
|
||||||
Применить
|
Применить
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='small' onClick={onReset}>
|
<Button
|
||||||
|
size='small'
|
||||||
|
onClick={() => {
|
||||||
|
onReset();
|
||||||
|
close();
|
||||||
|
}}>
|
||||||
Сбросить
|
Сбросить
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</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);
|
const [draft, setDraft] = useState<string | undefined>(value);
|
||||||
return (
|
return (
|
||||||
<DropdownShell
|
<DropdownShell
|
||||||
|
close={close}
|
||||||
onApply={() => onChange({ legal_entity_id: draft })}
|
onApply={() => onChange({ legal_entity_id: draft })}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
setDraft(undefined);
|
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 ?? '');
|
const [draft, setDraft] = useState(value ?? '');
|
||||||
return (
|
return (
|
||||||
<DropdownShell
|
<DropdownShell
|
||||||
|
close={close}
|
||||||
onApply={() => onChange({ account_root: draft })}
|
onApply={() => onChange({ account_root: draft })}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
setDraft('');
|
setDraft('');
|
||||||
@ -78,42 +100,66 @@ function AccountFilter({ value, onChange }: { value?: string; onChange: OnChange
|
|||||||
placeholder='корень счёта, напр. 60'
|
placeholder='корень счёта, напр. 60'
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onPressEnter={() => onChange({ account_root: draft })}
|
onPressEnter={() => {
|
||||||
|
onChange({ account_root: draft });
|
||||||
|
close();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</DropdownShell>
|
</DropdownShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CounterpartyFilter({ q, type, onChange }: { q?: string; type?: CounterpartyType; onChange: OnChange }) {
|
function CounterpartyFilter({ q, onChange, close }: FilterProps & { q?: string }) {
|
||||||
const [draftQ, setDraftQ] = useState(q ?? '');
|
const [draft, setDraft] = useState(q ?? '');
|
||||||
const [draftType, setDraftType] = useState<CounterpartyType | undefined>(type);
|
|
||||||
return (
|
return (
|
||||||
<DropdownShell
|
<DropdownShell
|
||||||
onApply={() => onChange({ q: draftQ, counterparty_type: draftType })}
|
close={close}
|
||||||
|
onApply={() => onChange({ q: draft })}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
setDraftQ('');
|
setDraft('');
|
||||||
setDraftType(undefined);
|
onChange({ q: '' });
|
||||||
onChange({ q: '', counterparty_type: undefined });
|
|
||||||
}}>
|
}}>
|
||||||
<Input
|
<Input
|
||||||
allowClear
|
allowClear
|
||||||
placeholder='Название или ИНН'
|
placeholder='Название или ИНН'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
value={draftQ}
|
value={draft}
|
||||||
onChange={(e) => setDraftQ(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onPressEnter={() => onChange({ q: draftQ, counterparty_type: draftType })}
|
onPressEnter={() => {
|
||||||
/>
|
onChange({ q: draft });
|
||||||
<Select
|
close();
|
||||||
allowClear
|
}}
|
||||||
placeholder='Признак контрагента'
|
|
||||||
value={draftType}
|
|
||||||
onChange={setDraftType}
|
|
||||||
options={(['GSK', 'GK', 'EXTERNAL'] as const).map((t) => ({ value: t, label: counterpartyTypeLabel(t) }))}
|
|
||||||
/>
|
/>
|
||||||
</DropdownShell>
|
</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 }} />;
|
const filterIcon = (active: boolean) => <FilterFilled style={{ color: active ? '#2a8c3a' : undefined }} />;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -139,8 +185,14 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
|
|||||||
fixed: 'left',
|
fixed: 'left',
|
||||||
filterIcon: filterIcon(!!filters.legal_entity_id),
|
filterIcon: filterIcon(!!filters.legal_entity_id),
|
||||||
// ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра
|
// ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра
|
||||||
filterDropdown: () => (
|
filterDropdown: ({ close }) => (
|
||||||
<EntityFilter key={filters.legal_entity_id ?? ''} members={members} value={filters.legal_entity_id} onChange={onFilter} />
|
<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',
|
dataIndex: 'account_code',
|
||||||
width: 110,
|
width: 110,
|
||||||
filterIcon: filterIcon(!!filters.account_root),
|
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) },
|
{ 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: 'Контрагенты',
|
title: 'Контрагенты',
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
|
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
|
||||||
filterIcon: filterIcon(!!filters.q || !!filters.counterparty_type),
|
filterIcon: filterIcon(!!filters.q),
|
||||||
filterDropdown: () => (
|
filterDropdown: ({ close }) => <CounterpartyFilter key={filters.q ?? ''} q={filters.q} onChange={onFilter} close={close} />,
|
||||||
<CounterpartyFilter
|
|
||||||
key={`${filters.q ?? ''}|${filters.counterparty_type ?? ''}`}
|
|
||||||
q={filters.q}
|
|
||||||
type={filters.counterparty_type}
|
|
||||||
onChange={onFilter}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ key: 'counterparty_inn', title: 'ИНН', dataIndex: 'counterparty_inn', width: 130 },
|
{ key: 'counterparty_inn', title: 'ИНН', dataIndex: 'counterparty_inn', width: 130 },
|
||||||
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
|
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
|
||||||
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
|
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
|
||||||
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_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 (
|
return (
|
||||||
<Table<ConsolidatedRowOut>
|
<Table<ConsolidatedRowOut>
|
||||||
@ -183,7 +241,7 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
|
|||||||
locale={{
|
locale={{
|
||||||
emptyText: filtered ? 'По заданным фильтрам строк нет' : 'В своде пока нет строк: загрузите и подтвердите ОСВ организаций',
|
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}` }}
|
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