Andrey Epifancev f7cdd42c3d fix(web): синхронизация встроенного фронта с arm-web — ячейка ИНН в сводной ОСВ (#94)
Перенесён коммит arm-web 0f7a101 (#17): «Указать» только у строк без ИНН и без
связи с ЮЛ, «таблетка» с ИНН сопоставленного ЮЛ и пересопоставление («Связано»
в модалке), фильтр «только без ИНН» в колонке, баннер по макету [32-osv-no-inn],
поле counterparty_entity_inn в типах (бэкенд — arm-core #74). После переноса
исходники web/ и arm-web@test совпадают.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:38:22 +04:00

319 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}` }}
/>
);
}