From 519061e859f7a42fd9b86c39cb6c5f106fb041c4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 11:56:12 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=C2=AB=D0=A2=D0=B8=D0=BF=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=C2=BB=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=9E=D0=A1?= =?UTF-8?q?=D0=92=20=D0=BF=D0=BE=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=D1=83=20?= =?UTF-8?q?[30]=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Последняя колонка — бейдж ГСК (синий) / ГК (зелёный) / Внешний (серый), цвета из osv-type-badge макета; чистая функция counterpartyTypeBadge с тестом. - Фильтр признака перенесён из «Контрагенты» на колонку «Тип контрагента» (одно значение: API принимает один counterparty_type); «Контрагенты» — только поиск. - «Применить» / «Сбросить» и Enter закрывают окно фильтра (close из antd). - Спека consolidated-osv-ui: колонка и фильтры по макету, «способ сопоставления» убран из колонок (в макете [30] его нет). Co-Authored-By: Claude Opus 5.5 --- openspec/specs/consolidated-osv-ui/spec.md | 10 +- src/widgets/ConsolidatedOsv/OsvTable.tsx | 130 +++++++++++++----- .../counterpartyTypeBadge.test.ts | 33 +++++ .../ConsolidatedOsv/counterpartyTypeBadge.ts | 18 +++ 4 files changed, 151 insertions(+), 40 deletions(-) create mode 100644 src/widgets/ConsolidatedOsv/counterpartyTypeBadge.test.ts create mode 100644 src/widgets/ConsolidatedOsv/counterpartyTypeBadge.ts diff --git a/openspec/specs/consolidated-osv-ui/spec.md b/openspec/specs/consolidated-osv-ui/spec.md index 7b94ef9..770ef38 100644 --- a/openspec/specs/consolidated-osv-ui/spec.md +++ b/openspec/specs/consolidated-osv-ui/spec.md @@ -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** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит» diff --git a/src/widgets/ConsolidatedOsv/OsvTable.tsx b/src/widgets/ConsolidatedOsv/OsvTable.tsx index c9a45d9..5c93fdf 100644 --- a/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -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): 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 (
{children} - - @@ -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(value); return ( 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 ( 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(); + }} /> ); } -function CounterpartyFilter({ q, type, onChange }: { q?: string; type?: CounterpartyType; onChange: OnChange }) { - const [draftQ, setDraftQ] = useState(q ?? ''); - const [draftType, setDraftType] = useState(type); +function CounterpartyFilter({ q, onChange, close }: FilterProps & { q?: string }) { + const [draft, setDraft] = useState(q ?? ''); return ( onChange({ q: draftQ, counterparty_type: draftType })} + close={close} + onApply={() => onChange({ q: draft })} onReset={() => { - setDraftQ(''); - setDraftType(undefined); - onChange({ q: '', counterparty_type: undefined }); + setDraft(''); + onChange({ q: '' }); }}> setDraftQ(e.target.value)} - onPressEnter={() => onChange({ q: draftQ, counterparty_type: draftType })} - /> - ({ value: t, label: counterpartyTypeLabel(t) }))} + /> + + ); +} + +function TypeBadge({ type }: { type: CounterpartyType }) { + return ; +} + const filterIcon = (active: boolean) => ; 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: () => ( - + filterDropdown: ({ 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: () => , + filterDropdown: ({ 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: () => ( - - ), + filterIcon: filterIcon(!!filters.q), + filterDropdown: ({ 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) => , + filterIcon: filterIcon(!!filters.counterparty_type), + filterDropdown: ({ close }) => ( + + ), + }, ]; return ( @@ -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}` }} /> ); diff --git a/src/widgets/ConsolidatedOsv/counterpartyTypeBadge.test.ts b/src/widgets/ConsolidatedOsv/counterpartyTypeBadge.test.ts new file mode 100644 index 0000000..84db857 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/counterpartyTypeBadge.test.ts @@ -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']); + }); +}); diff --git a/src/widgets/ConsolidatedOsv/counterpartyTypeBadge.ts b/src/widgets/ConsolidatedOsv/counterpartyTypeBadge.ts new file mode 100644 index 0000000..afb6c34 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/counterpartyTypeBadge.ts @@ -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> = { + 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' }; +} -- 2.47.2