- Prettier — единственный форматтер (табы, 140, одинарные кавычки, в CSS — двойные); форматтер biome выключен, biome остаётся линтером и сортирует импорты. - ESLint (flat config): js/typescript-eslint/react-hooks recommended + правила biome.json, eslint-config-prettier последним. - npm run format / make format: eslint --fix → biome check --write → prettier --write. - npm run lint: eslint + prettier --check; входит в make lint и make check. - Разовое переформатирование 4 файлов, `(A | B)[]` вместо Array<…> в 3 местах, две находки ESLint в ConsoleTable (лишний `?? null`, тернарник-инструкция). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { FilterFilled } from '@ant-design/icons';
|
||
import { Button, Checkbox, 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 { innCell, needsMapping } from './counterpartyMapping';
|
||
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>
|
||
);
|
||
}
|
||
|
||
// «Показать ЮЛ без ИНН» баннера — тот же фильтр `unmapped`; в макете [32-osv-no-inn] его воронка в колонке ИНН подсвечена
|
||
function UnmappedFilter({ value, onChange, close }: FilterProps & { value: boolean }) {
|
||
const [draft, setDraft] = useState(value);
|
||
return (
|
||
<DropdownShell
|
||
close={close}
|
||
onApply={() => onChange({ unmapped: draft })}
|
||
onReset={() => {
|
||
setDraft(false);
|
||
onChange({ unmapped: false });
|
||
}}>
|
||
<Checkbox checked={draft} onChange={(e) => setDraft(e.target.checked)}>
|
||
Только без ИНН и без связи с ЮЛ
|
||
</Checkbox>
|
||
</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;
|
||
/** «Указать» или ИНН сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */
|
||
onMap: (row: ConsolidatedRowOut) => void;
|
||
};
|
||
|
||
// Ячейка ИНН макета [32-osv-no-inn]: `osv-table__inn-cell` (моноширинный, по центру), `--required` (подсветка),
|
||
// `osv-inn-link-button` («Указать»), `osv-inn-mapped-button` (ИНН сопоставленного ЮЛ — пересопоставить)
|
||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
|
||
const INN_CELL: React.CSSProperties = { fontFamily: MONO, fontVariantNumeric: 'tabular-nums', color: '#667085', textAlign: 'center' };
|
||
const REQUIRED_CELL: React.CSSProperties = { ...INN_CELL, background: '#fff4ed', boxShadow: 'inset 0 0 0 1px #fdba74' };
|
||
const MAP_LINK: React.CSSProperties = { padding: 0, height: 'auto', color: '#b42318', fontSize: 13, fontWeight: 700, lineHeight: 1.4 };
|
||
const MAPPED_PILL: React.CSSProperties = {
|
||
minHeight: 26,
|
||
height: 'auto',
|
||
padding: '2px 9px',
|
||
border: '1px solid #d0d5dd',
|
||
borderRadius: 999,
|
||
background: '#f9fafb',
|
||
color: '#667085',
|
||
fontFamily: MONO,
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
lineHeight: 1.3,
|
||
boxShadow: 'none',
|
||
};
|
||
|
||
function InnCell({ row, onMap }: { row: ConsolidatedRowOut; onMap: (row: ConsolidatedRowOut) => void }) {
|
||
const view = innCell(row);
|
||
if (view.kind === 'inn') return <>{view.inn}</>;
|
||
if (view.kind === 'mapped')
|
||
return (
|
||
<Button style={MAPPED_PILL} title='Связано с организацией справочника — изменить' onClick={() => onMap(row)}>
|
||
{view.inn ?? 'Изменить'}
|
||
</Button>
|
||
);
|
||
return (
|
||
<Button type='link' style={MAP_LINK} onClick={() => onMap(row)}>
|
||
Указать
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
export function OsvTable({ rows, total, page, pageSize, loading, filters, members, onPage, onFilter, onMap }: 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: 'ИНН',
|
||
align: 'center',
|
||
// шире макетных 128: «таблетка» с 12-значным ИНН ИП
|
||
width: 140,
|
||
onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }),
|
||
render: (_: unknown, r) => <InnCell row={r} onMap={onMap} />,
|
||
filterIcon: filterIcon(!!filters.unmapped),
|
||
filterDropdown: ({ close }) => (
|
||
<UnmappedFilter key={String(!!filters.unmapped)} value={!!filters.unmapped} onChange={onFilter} close={close} />
|
||
),
|
||
},
|
||
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: 1840, y: 'calc(100vh - 460px)' }}
|
||
pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }}
|
||
/>
|
||
);
|
||
}
|