arm-core/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx
Andrey Epifancev 36224dd81c Merge origin/main в AURORA-1589: шапка, «Единицы» и [31-00] — в страницу отчёта с вкладками #158 (ревью #166)
Конфликты после #158 (страница отчёта с вкладками по адресу):
- layouts/ConsolidationReportPage: пояснение статуса, тег, «Изменить статус» и модалка [33-01] из ConsolidatedOsvPage;
- widgets/ConsolidatedOsvContent: «Единицы / Знаков» и onUnmappedChange (unmapped_total наверх);
- OsvTabs: вкладки по адресу из main + модалка [31-00] при уходе с «ОСВ»;
- types/api.ts: обе части.
Спека consolidations-ui: решение (ц), Known issues 11 и 15 (lucide-react в зеркале есть), tasks 2.5, 2.8;
ARCHITECTURE.md и web/CLAUDE.md — новая страница отчёта.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:59:14 +04:00

145 lines
5.4 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 { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Select, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { exportOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../../utils/download';
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../ConsolidatedOsv/OsvTable';
import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
type Props = {
card: VersionCard | null;
/** Сообщает странице отчёта `unmapped_total` сводки — для модалки [31-00] при уходе с вкладки «ОСВ». */
onUnmappedChange?: (total: number) => void;
};
export function OsvContent({ card, onUnmappedChange }: Props) {
const [exporting, setExporting] = useState(false);
const { version_id } = useParams<{ version_id: string }>();
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(version_id ?? '');
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
const download = async () => {
if (!version_id) {
return;
}
setExporting(true);
try {
const { blob, filename } = await exportOsv(version_id, osv.filters);
saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? ''));
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод');
} finally {
setExporting(false);
}
};
const s = osv.summary;
const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true;
useEffect(() => {
onUnmappedChange?.(unmapped);
}, [unmapped, onUnmappedChange]);
// вне вкладки «ОСВ» предупреждать не о чем
useEffect(() => () => onUnmappedChange?.(0), [onUnmappedChange]);
return (
<>
<main
style={{
flex: '1 1 auto',
minHeight: 0,
background: 'white',
borderRadius: 12,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<div style={{ fontSize: 10, color: '#667085' }}>Единицы</div>
<Select variant='underlined' value={unit} onChange={setUnit} options={UNITS} style={{ width: 140 }} />
</div>
<div>
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
<Select
variant='underlined'
options={PRECISIONS.map((i) => ({ label: i, value: i }))}
value={precision}
onChange={(v) => setPrecision(v ?? 2)}
style={{ width: 90 }}
/>
</div>
</div>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
Экспорт
</Button>
</div>
{osv.error && <Alert type='error' showIcon title={osv.error} />}
{unmapped > 0 && (
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
background: '#fffaeb',
border: '1px solid #fedf89',
borderRadius: 12,
padding: '10px 12px',
color: '#93370d',
fontSize: 13,
}}>
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
<Button
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
icon={<FilterOutlined />}
onClick={() => osv.patchFilters({ unmapped: !onlyUnmapped })}>
{onlyUnmapped ? 'Показать все строки' : 'Показать ЮЛ без ИНН'}
</Button>
</div>
)}
<OsvGaps gaps={s?.gaps ?? []} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters}
onMap={setMapping}
/>
</main>
{mapping && version_id && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={version_id}
row={mapping}
onClose={() => setMapping(null)}
onMapped={osv.refresh}
/>
)}
</>
);
}