fix(1539): единицы измерения в сводной ОСВ, полоса прокрутки

This commit is contained in:
maxim 2026-10-22 23:14:21 +03:00
parent 723c84cc72
commit 71ef6333b5
11 changed files with 195 additions and 104 deletions

View File

@ -1,6 +1,6 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Tooltip, message } from 'antd'; import { Alert, Button, InputNumber, Select, Space, Tooltip, message } from 'antd';
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client'; import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle'; import PageTitle from '../components/PageTitle';
@ -13,7 +13,7 @@ import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members'; import type { VersionCard } from '../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../utils/download'; import { exportFilename, saveBlob } from '../utils/download';
import { quarterShortLabel } from '../utils/labels'; import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; import { consStatusDescriptionMapping, statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal'; import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
@ -22,6 +22,7 @@ import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv';
import { useCurrentUser } from '../auth/MeContext'; import { useCurrentUser } from '../auth/MeContext';
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
import { UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay';
function OsvContent({ versionId }: { versionId: string }) { function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null); const [card, setCard] = useState<VersionCard | null>(null);
@ -31,6 +32,7 @@ function OsvContent({ versionId }: { versionId: string }) {
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null); const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(versionId); const osv = useConsolidatedOsv(versionId);
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
useEffect(() => { useEffect(() => {
let actual = true; let actual = true;
@ -62,6 +64,7 @@ function OsvContent({ versionId }: { versionId: string }) {
const unmapped = s?.unmapped_total ?? 0; const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true; const onlyUnmapped = osv.filters.unmapped === true;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; const russianStatus = card ? (statusMapping[card.status] ?? card.status) : '';
const description = card ? (consStatusDescriptionMapping[card.status] ?? russianStatus) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
const groupName = card?.company_group.name ?? ''; const groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
@ -71,7 +74,7 @@ function OsvContent({ versionId }: { versionId: string }) {
backable backable
location={['Отчётность', groupName, period]} location={['Отчётность', groupName, period]}
header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'} header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'}
description={russianStatus} description={description}
extra={ extra={
card && ( card && (
<Space size={8} style={{ display: 'flex', gap: 4 }}> <Space size={8} style={{ display: 'flex', gap: 4 }}>
@ -121,7 +124,23 @@ function OsvContent({ versionId }: { versionId: string }) {
flexDirection: 'column', flexDirection: 'column',
gap: 12, gap: 12,
}}> }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <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={[0, 1, 2, 3].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 icon={<DownloadOutlined />} loading={exporting} onClick={download}>
Экспорт Экспорт
</Button> </Button>
@ -163,6 +182,7 @@ function OsvContent({ versionId }: { versionId: string }) {
filters={osv.filters} filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))} members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage} onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters} onFilter={osv.patchFilters}
onMap={setMapping} onMap={setMapping}
/> />

View File

@ -10,7 +10,7 @@ import { Avatar, type TableColumnsType } from 'antd';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members';
import { statusMapping } from './consts'; import { fileStatusMapping } from './consts';
/** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */ /** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */
export type ChecklistTableRow = { export type ChecklistTableRow = {
@ -60,7 +60,7 @@ export function getMarksItems() {
fontSize: 11, fontSize: 11,
}}> }}>
<b>Условные обозначения</b> <b>Условные обозначения</b>
{(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => ( {(Object.entries(fileStatusMapping) as [FileStatus, string][]).map(([status, label]) => (
<div key={status}> <div key={status}>
{mapStatus(status)} <span style={{ color: '#667085' }}>{label}</span> {mapStatus(status)} <span style={{ color: '#667085' }}>{label}</span>
</div> </div>

View File

@ -2,13 +2,12 @@ import { FileTextOutlined } from '@ant-design/icons';
import { Button } from 'antd'; import { Button } from 'antd';
import type { TableConfig } from '../../components/GenericTable'; import type { TableConfig } from '../../components/GenericTable';
import { TableTag } from '../../components/TableTag/TableTag'; import { TableTag } from '../../components/TableTag/TableTag';
import { TAG_PRESETS } from '../../components/TableTag/presets';
import type { FileRussianStatus, MemberRow } from '../../types/consolidations/version_members'; import type { FileRussianStatus, MemberRow } from '../../types/consolidations/version_members';
import { statusColorMapping } from './consts'; import { fileStatusColorMapping } from './consts';
function StatusTag({ status }: { status: FileRussianStatus | undefined }) { function StatusTag({ status }: { status: FileRussianStatus | undefined }) {
if (!status) return null; if (!status) return null;
return <TableTag {...statusColorMapping[status]} text={status} />; return <TableTag {...fileStatusColorMapping[status]} text={status} />;
} }
export const membersConfig: TableConfig<MemberRow> = { export const membersConfig: TableConfig<MemberRow> = {

View File

@ -1,7 +1,7 @@
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { FileRussianStatus, FileStatus } from '../../types/consolidations/version_members'; import type { FileRussianStatus, FileStatus } from '../../types/consolidations/version_members';
export const statusMapping: Record<FileStatus, FileRussianStatus> = { export const fileStatusMapping: Record<FileStatus, FileRussianStatus> = {
CONFIRMED: 'Загружен', CONFIRMED: 'Загружен',
ERROR: 'Ошибка', ERROR: 'Ошибка',
MISSING: 'Отсутствует', MISSING: 'Отсутствует',
@ -10,7 +10,7 @@ export const statusMapping: Record<FileStatus, FileRussianStatus> = {
RECOGNIZED: 'Распознан', RECOGNIZED: 'Распознан',
}; };
export const statusColorMapping: Record<FileRussianStatus, Preset> = { export const fileStatusColorMapping: Record<FileRussianStatus, Preset> = {
'В обработке': TAG_PRESETS.geekblue, 'В обработке': TAG_PRESETS.geekblue,
Загружен: TAG_PRESETS.green, Загружен: TAG_PRESETS.green,
Отсутствует: TAG_PRESETS.default, Отсутствует: TAG_PRESETS.default,

View File

@ -1,5 +1,5 @@
import type { FileRussianStatus, IVersionCard, MemberRow } from '../../types/consolidations/version_members'; import type { FileRussianStatus, IVersionCard, MemberRow } from '../../types/consolidations/version_members';
import { statusMapping } from './consts'; import { fileStatusMapping } from './consts';
export function normalizeData(cons_data: IVersionCard | null): MemberRow[] { export function normalizeData(cons_data: IVersionCard | null): MemberRow[] {
if (cons_data === null) { if (cons_data === null) {
@ -10,9 +10,9 @@ export function normalizeData(cons_data: IVersionCard | null): MemberRow[] {
let ofr_status: FileRussianStatus | undefined; let ofr_status: FileRussianStatus | undefined;
for (const c of m.checklist) { for (const c of m.checklist) {
if (c.document_type === 'BALANCE') { if (c.document_type === 'BALANCE') {
bb_status = statusMapping[c.status]; bb_status = fileStatusMapping[c.status];
} else if (c.document_type === 'INCOME') { } else if (c.document_type === 'INCOME') {
ofr_status = statusMapping[c.status]; ofr_status = fileStatusMapping[c.status];
} }
} }
return { return {

View File

@ -208,7 +208,14 @@ export function ConsoleTableWidget() {
const onRow: TableProps<ConsRow>['onRow'] = (row) => ({ const onRow: TableProps<ConsRow>['onRow'] = (row) => ({
// выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад» // выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад»
onClick: () => setSearchParams({ version: row.id }, { replace: true }), onClick: () =>
setSearchParams(
(searchParams) => {
searchParams.set('version', row.id);
return searchParams;
},
{ replace: true },
),
// выбранная версия подсвечена, как в макете [34] // выбранная версия подсвечена, как в макете [34]
style: row.id === searchParams.get('version') ? { background: '#f0f7ee' } : undefined, style: row.id === searchParams.get('version') ? { background: '#f0f7ee' } : undefined,
}); });

View File

@ -30,3 +30,12 @@ export const statusColorMapping: Record<ConsRussianStatus, Preset> = {
Отклонён: TAG_PRESETS.red, Отклонён: TAG_PRESETS.red,
Отменён: TAG_PRESETS.red, Отменён: TAG_PRESETS.red,
}; };
export const consStatusDescriptionMapping: Partial<Record<ConsStatus, string>> = {
CONSOLIDATION_RF: 'Формирование и проверка КФО, внесение корректировок на уровне РФ',
CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО',
VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО',
VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ',
CANCELLED: 'Формирование КФО отменено',
VERIFIED: 'КФО верифицирована, внесены номер и дата решения УОБ',
};

View File

@ -1,24 +1,23 @@
import { FilterFilled } from '@ant-design/icons'; import { FilterFilled } from '@ant-design/icons';
import { Button, Checkbox, Input, Select, Space, Table } from 'antd'; import { Button, Checkbox, Input, Select, Space, Table } from 'antd';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { TableTag } from '../../components/TableTag/TableTag'; import { TableTag } from '../../components/TableTag/TableTag';
import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api'; import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api';
import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels'; import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels';
import { formatAmount } from '../../utils/money';
import { innCell, needsMapping } from './counterpartyMapping'; import { innCell, needsMapping } from './counterpartyMapping';
import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge'; import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge';
import type { OsvFilterDraft } from './osvFilterState'; import type { OsvFilterDraft } from './osvFilterState';
type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit'; type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit';
const money = (key: MoneyKey, title: string) => ({ const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({
key, key,
title, title,
dataIndex: key, dataIndex: key,
align: 'right' as const, align: 'right' as const,
width: 140, width: 140,
render: (v: string | null) => formatAmount(v, { zeroDash: true }), render: (v: string | null) => format(v),
}); });
const group = (title: string, children: TableColumnsType<ConsolidatedRowOut>): TableColumnsType<ConsolidatedRowOut>[number] => ({ const group = (title: string, children: TableColumnsType<ConsolidatedRowOut>): TableColumnsType<ConsolidatedRowOut>[number] => ({
@ -195,6 +194,7 @@ type Props = {
filters: OsvFilters; filters: OsvFilters;
members: Member[]; members: Member[];
onPage: (p: number) => void; onPage: (p: number) => void;
numFormat: (raw: string | null) => string;
onFilter: OnChange; onFilter: OnChange;
/** «Указать» или ИНН сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */ /** «Указать» или ИНН сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */
onMap: (row: ConsolidatedRowOut) => void; onMap: (row: ConsolidatedRowOut) => void;
@ -237,74 +237,91 @@ function InnCell({ row, onMap }: { row: ConsolidatedRowOut; onMap: (row: Consoli
); );
} }
export function OsvTable({ rows, total, page, pageSize, loading, filters, members, onPage, onFilter, onMap }: Props) { export function OsvTable({ rows, total, page, pageSize, loading, filters, members, numFormat, onPage, onFilter, onMap }: Props) {
const filtered = Object.keys(filters).length > 0; const filtered = Object.keys(filters).length > 0;
const columns: TableColumnsType<ConsolidatedRowOut> = [ const columns: TableColumnsType<ConsolidatedRowOut> = useMemo(() => {
{ return [
key: 'legal_entity_name', {
title: 'Компания ГК', key: 'legal_entity_name',
dataIndex: 'legal_entity_name', title: 'Компания ГК',
width: 230, dataIndex: 'legal_entity_name',
fixed: 'left', width: 230,
filterIcon: filterIcon(!!filters.legal_entity_id), fixed: 'left',
// ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра filterIcon: filterIcon(!!filters.legal_entity_id),
filterDropdown: ({ close }) => ( // ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра
<EntityFilter filterDropdown: ({ close }) => (
key={filters.legal_entity_id ?? ''} <EntityFilter
members={members} key={filters.legal_entity_id ?? ''}
value={filters.legal_entity_id} members={members}
onChange={onFilter} value={filters.legal_entity_id}
close={close} onChange={onFilter}
/> close={close}
), />
}, ),
{ },
key: 'account_code', {
title: 'Счёт', key: 'account_code',
dataIndex: 'account_code', title: 'Счёт',
width: 110, dataIndex: 'account_code',
filterIcon: filterIcon(!!filters.account_root), width: 110,
filterDropdown: ({ close }) => ( filterIcon: filterIcon(!!filters.account_root),
<AccountFilter key={filters.account_root ?? ''} value={filters.account_root} onChange={onFilter} close={close} /> 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: 'period', title: 'Период', dataIndex: 'period', width: 140, render: (p: string) => osvPeriodLabel(p) },
key: 'counterparty', {
title: 'Контрагенты', key: 'counterparty',
width: 260, title: 'Контрагенты',
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—', width: 260,
filterIcon: filterIcon(!!filters.q), render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
filterDropdown: ({ close }) => <CounterpartyFilter key={filters.q ?? ''} q={filters.q} onChange={onFilter} close={close} />, filterIcon: filterIcon(!!filters.q),
}, filterDropdown: ({ close }) => <CounterpartyFilter key={filters.q ?? ''} q={filters.q} onChange={onFilter} close={close} />,
{ },
key: 'counterparty_inn', {
title: 'ИНН', key: 'counterparty_inn',
align: 'center', title: 'ИНН',
// шире макетных 128: «таблетка» с 12-значным ИНН ИП align: 'center',
width: 140, // шире макетных 128: «таблетка» с 12-значным ИНН ИП
onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }), width: 140,
render: (_: unknown, r) => <InnCell row={r} onMap={onMap} />, onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }),
filterIcon: filterIcon(!!filters.unmapped), render: (_: unknown, r) => <InnCell row={r} onMap={onMap} />,
filterDropdown: ({ close }) => ( filterIcon: filterIcon(!!filters.unmapped),
<UnmappedFilter key={String(!!filters.unmapped)} value={!!filters.unmapped} onChange={onFilter} close={close} /> 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('opening_debit', 'Дебет', numFormat), money('opening_credit', 'Кредит', numFormat)]),
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]), group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]),
{ group('Сальдо на конец периода', [money('closing_debit', 'Дебет', numFormat), money('closing_credit', 'Кредит', numFormat)]),
key: 'counterparty_type', {
title: 'Тип контрагента', key: 'counterparty_type',
dataIndex: 'counterparty_type', title: 'Тип контрагента',
width: 130, dataIndex: 'counterparty_type',
render: (t: CounterpartyType) => <TypeBadge type={t} />, width: 130,
filterIcon: filterIcon(!!filters.counterparty_type), render: (t: CounterpartyType) => <TypeBadge type={t} />,
filterDropdown: ({ close }) => ( filterIcon: filterIcon(!!filters.counterparty_type),
<CounterpartyTypeFilter key={filters.counterparty_type ?? ''} type={filters.counterparty_type} onChange={onFilter} close={close} /> filterDropdown: ({ close }) => (
), <CounterpartyTypeFilter
}, key={filters.counterparty_type ?? ''}
]; type={filters.counterparty_type}
onChange={onFilter}
close={close}
/>
),
},
];
}, [
filters.account_root,
filters.counterparty_type,
filters.legal_entity_id,
filters.q,
filters.unmapped,
members,
numFormat,
onFilter,
onMap,
]);
return ( return (
<Table<ConsolidatedRowOut> <Table<ConsolidatedRowOut>
bordered bordered

View File

@ -8,7 +8,7 @@ export function OsvTabs() {
return ( return (
<Segmented <Segmented
value='ОСВ' value='ОСВ'
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }} style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
options={[ options={[
{ value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> }, { value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> },
...TABS.map((t) => ({ ...TABS.map((t) => ({

View File

@ -0,0 +1,42 @@
import { useState, useMemo, useCallback } from 'react';
export type Unit = 'руб.' | 'тыс. руб.' | 'млн. руб.' | 'млрд. руб.';
export const UNITS: { value: Unit; label: string }[] = [
{ value: 'руб.', label: 'руб.' },
{ value: 'тыс. руб.', label: 'тыс. руб.' },
{ value: 'млн. руб.', label: 'млн. руб.' },
{ value: 'млрд. руб.', label: 'млрд. руб.' },
];
const DIVISORS: Record<Unit, number> = {
'руб.': 1,
'тыс. руб.': 1e3,
'млн. руб.': 1e6,
'млрд. руб.': 1e9,
};
export function createFormatter(precision: number): Intl.NumberFormat {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
}
export function formatNumeric(raw: string | null, unit: Unit, nf: Intl.NumberFormat): string {
if (raw == null || raw.trim() === '') return '—';
const n = Number(raw);
if (!Number.isFinite(n)) return raw;
return nf.format(n / DIVISORS[unit]);
}
export function useNumberDisplay(initial?: { unit?: Unit; precision?: number }) {
const [unit, setUnit] = useState<Unit>(initial?.unit ?? 'руб.');
const [precision, setPrecision] = useState(initial?.precision ?? 2);
const nf = useMemo(() => createFormatter(precision), [precision]);
const format = useCallback((raw: string | null) => formatNumeric(raw, unit, nf), [unit, nf]);
return { unit, setUnit, precision, setPrecision, format };
}

View File

@ -23,11 +23,6 @@ const errorCodeMap = {
TRANSITION_NOT_ALLOWED: 'Данный переход недоступен', TRANSITION_NOT_ALLOWED: 'Данный переход недоступен',
}; };
/*
"Инициация", "Обработка документов", "Корректировка данных" -> "Отменена" (комментарий обязателен);
из "Обработка документов", "Корректировка данных" -> "Инициация" (комментарий обязателен во фронте)
*/
async function mockStatusMappingFetch() { async function mockStatusMappingFetch() {
const mapping: Record<ConsStatus, ConsStatus[]> = { const mapping: Record<ConsStatus, ConsStatus[]> = {
INITIATED: ['CANCELLED'], INITIATED: ['CANCELLED'],
@ -95,17 +90,19 @@ export default function ChangeStatusModal({ open, versionId, setClose, onSuccess
Текущий этап: <TableTag text={russianStatus} {...badgePreset} /> Текущий этап: <TableTag text={russianStatus} {...badgePreset} />
</div> </div>
<Form form={form} layout='vertical' requiredMark={false} initialValues={{ comment: '', to_status: nextStatusOptions[0] }}> {nextStatusOptions && (
<Form.Item name='to_status' label='Новый этап'> <Form form={form} layout='vertical' requiredMark={false} initialValues={{ comment: '', to_status: nextStatusOptions[0] }}>
<Select <Form.Item name='to_status' label='Новый этап'>
options={nextStatusOptions.map((i) => ({ label: i, value: i }))} <Select
labelRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)} options={nextStatusOptions.map((i) => ({ label: i, value: i }))}
optionRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)}></Select> labelRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)}
</Form.Item> optionRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)}></Select>
<Form.Item name='comment' rules={[{ required: true, message: 'Комментарий необходим' }]} label='Комментарий'> </Form.Item>
<Input.TextArea maxLength={1000} placeholder='Укажите комментарий к изменению статуса'></Input.TextArea> <Form.Item name='comment' rules={[{ required: true, message: 'Комментарий необходим' }]} label='Комментарий'>
</Form.Item> <Input.TextArea maxLength={1000} placeholder='Укажите комментарий к изменению статуса'></Input.TextArea>
</Form> </Form.Item>
</Form>
)}
</Modal> </Modal>
); );
} }