Конфликты после #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>
145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
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}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|