diff --git a/web/src/layouts/ConsolidatedOsvPage.tsx b/web/src/layouts/ConsolidatedOsvPage.tsx index f76f74d..485db65 100644 --- a/web/src/layouts/ConsolidatedOsvPage.tsx +++ b/web/src/layouts/ConsolidatedOsvPage.tsx @@ -1,6 +1,6 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Space, Tooltip, message } from 'antd'; -import { useCallback, useEffect, useState } from 'react'; +import { Alert, Button, InputNumber, Select, Space, Tooltip, message } from 'antd'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; 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 { exportFilename, saveBlob } from '../utils/download'; 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 { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; 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 { useCurrentUser } from '../auth/MeContext'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; +import { UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay'; function OsvContent({ versionId }: { versionId: string }) { const [card, setCard] = useState(null); @@ -31,6 +32,7 @@ function OsvContent({ versionId }: { versionId: string }) { const [mapping, setMapping] = useState(null); const osv = useConsolidatedOsv(versionId); const currentUser = useCurrentUser(); + const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay(); useEffect(() => { let actual = true; @@ -62,6 +64,7 @@ function OsvContent({ versionId }: { versionId: string }) { const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; 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 groupName = card?.company_group.name ?? ''; const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; @@ -71,7 +74,7 @@ function OsvContent({ versionId }: { versionId: string }) { backable location={['Отчётность', groupName, period]} header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'} - description={russianStatus} + description={description} extra={ card && ( @@ -121,7 +124,23 @@ function OsvContent({ versionId }: { versionId: string }) { flexDirection: 'column', gap: 12, }}> -
+
+
+
+
Единицы
+ ({ label: i, value: i }))} + value={precision} + onChange={(v) => setPrecision(v ?? 2)} + style={{ width: 90 }} + /> +
+
@@ -163,6 +182,7 @@ function OsvContent({ versionId }: { versionId: string }) { filters={osv.filters} members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))} onPage={osv.setPage} + numFormat={format} onFilter={osv.patchFilters} onMap={setMapping} /> diff --git a/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx index 606d72b..143e2e8 100644 --- a/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx +++ b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx @@ -10,7 +10,7 @@ import { Avatar, type TableColumnsType } from 'antd'; import type { ReactNode } from 'react'; import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; -import { statusMapping } from './consts'; +import { fileStatusMapping } from './consts'; /** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */ export type ChecklistTableRow = { @@ -60,7 +60,7 @@ export function getMarksItems() { fontSize: 11, }}> Условные обозначения - {(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => ( + {(Object.entries(fileStatusMapping) as [FileStatus, string][]).map(([status, label]) => (
{mapStatus(status)} {label}
diff --git a/web/src/widgets/ConsoleLegalEntities/config.tsx b/web/src/widgets/ConsoleLegalEntities/config.tsx index 3660861..92ad066 100644 --- a/web/src/widgets/ConsoleLegalEntities/config.tsx +++ b/web/src/widgets/ConsoleLegalEntities/config.tsx @@ -2,13 +2,12 @@ import { FileTextOutlined } from '@ant-design/icons'; import { Button } from 'antd'; import type { TableConfig } from '../../components/GenericTable'; import { TableTag } from '../../components/TableTag/TableTag'; -import { TAG_PRESETS } from '../../components/TableTag/presets'; import type { FileRussianStatus, MemberRow } from '../../types/consolidations/version_members'; -import { statusColorMapping } from './consts'; +import { fileStatusColorMapping } from './consts'; function StatusTag({ status }: { status: FileRussianStatus | undefined }) { if (!status) return null; - return ; + return ; } export const membersConfig: TableConfig = { diff --git a/web/src/widgets/ConsoleLegalEntities/consts.ts b/web/src/widgets/ConsoleLegalEntities/consts.ts index bada87e..1965c84 100644 --- a/web/src/widgets/ConsoleLegalEntities/consts.ts +++ b/web/src/widgets/ConsoleLegalEntities/consts.ts @@ -1,7 +1,7 @@ import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import type { FileRussianStatus, FileStatus } from '../../types/consolidations/version_members'; -export const statusMapping: Record = { +export const fileStatusMapping: Record = { CONFIRMED: 'Загружен', ERROR: 'Ошибка', MISSING: 'Отсутствует', @@ -10,7 +10,7 @@ export const statusMapping: Record = { RECOGNIZED: 'Распознан', }; -export const statusColorMapping: Record = { +export const fileStatusColorMapping: Record = { 'В обработке': TAG_PRESETS.geekblue, Загружен: TAG_PRESETS.green, Отсутствует: TAG_PRESETS.default, diff --git a/web/src/widgets/ConsoleLegalEntities/normalize.ts b/web/src/widgets/ConsoleLegalEntities/normalize.ts index 95ff1d5..e651aa6 100644 --- a/web/src/widgets/ConsoleLegalEntities/normalize.ts +++ b/web/src/widgets/ConsoleLegalEntities/normalize.ts @@ -1,5 +1,5 @@ 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[] { if (cons_data === null) { @@ -10,9 +10,9 @@ export function normalizeData(cons_data: IVersionCard | null): MemberRow[] { let ofr_status: FileRussianStatus | undefined; for (const c of m.checklist) { if (c.document_type === 'BALANCE') { - bb_status = statusMapping[c.status]; + bb_status = fileStatusMapping[c.status]; } else if (c.document_type === 'INCOME') { - ofr_status = statusMapping[c.status]; + ofr_status = fileStatusMapping[c.status]; } } return { diff --git a/web/src/widgets/ConsoleTable/ConsoleTable.tsx b/web/src/widgets/ConsoleTable/ConsoleTable.tsx index 714e931..aa21583 100644 --- a/web/src/widgets/ConsoleTable/ConsoleTable.tsx +++ b/web/src/widgets/ConsoleTable/ConsoleTable.tsx @@ -208,7 +208,14 @@ export function ConsoleTableWidget() { const onRow: TableProps['onRow'] = (row) => ({ // выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад» - onClick: () => setSearchParams({ version: row.id }, { replace: true }), + onClick: () => + setSearchParams( + (searchParams) => { + searchParams.set('version', row.id); + return searchParams; + }, + { replace: true }, + ), // выбранная версия подсвечена, как в макете [34] style: row.id === searchParams.get('version') ? { background: '#f0f7ee' } : undefined, }); diff --git a/web/src/widgets/ConsoleTable/consts.ts b/web/src/widgets/ConsoleTable/consts.ts index 1cb0a59..dba1fc2 100644 --- a/web/src/widgets/ConsoleTable/consts.ts +++ b/web/src/widgets/ConsoleTable/consts.ts @@ -30,3 +30,12 @@ export const statusColorMapping: Record = { Отклонён: TAG_PRESETS.red, Отменён: TAG_PRESETS.red, }; + +export const consStatusDescriptionMapping: Partial> = { + CONSOLIDATION_RF: 'Формирование и проверка КФО, внесение корректировок на уровне РФ', + CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО', + VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО', + VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ', + CANCELLED: 'Формирование КФО отменено', + VERIFIED: 'КФО верифицирована, внесены номер и дата решения УОБ', +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx index 1ee69cc..6d4fe5a 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -1,24 +1,23 @@ 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 { useMemo, 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) => ({ +const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({ key, title, dataIndex: key, align: 'right' as const, width: 140, - render: (v: string | null) => formatAmount(v, { zeroDash: true }), + render: (v: string | null) => format(v), }); const group = (title: string, children: TableColumnsType): TableColumnsType[number] => ({ @@ -195,6 +194,7 @@ type Props = { filters: OsvFilters; members: Member[]; onPage: (p: number) => void; + numFormat: (raw: string | null) => string; onFilter: OnChange; /** «Указать» или ИНН сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */ 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 columns: TableColumnsType = [ - { - key: 'legal_entity_name', - title: 'Компания ГК', - dataIndex: 'legal_entity_name', - width: 230, - fixed: 'left', - filterIcon: filterIcon(!!filters.legal_entity_id), - // ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра - filterDropdown: ({ close }) => ( - - ), - }, - { - key: 'account_code', - title: 'Счёт', - dataIndex: 'account_code', - width: 110, - filterIcon: filterIcon(!!filters.account_root), - filterDropdown: ({ 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 }) => , - }, - { - key: 'counterparty_inn', - title: 'ИНН', - align: 'center', - // шире макетных 128: «таблетка» с 12-значным ИНН ИП - width: 140, - onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }), - render: (_: unknown, r) => , - filterIcon: filterIcon(!!filters.unmapped), - filterDropdown: ({ 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) => , - filterIcon: filterIcon(!!filters.counterparty_type), - filterDropdown: ({ close }) => ( - - ), - }, - ]; + const columns: TableColumnsType = useMemo(() => { + return [ + { + key: 'legal_entity_name', + title: 'Компания ГК', + dataIndex: 'legal_entity_name', + width: 230, + fixed: 'left', + filterIcon: filterIcon(!!filters.legal_entity_id), + // ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра + filterDropdown: ({ close }) => ( + + ), + }, + { + key: 'account_code', + title: 'Счёт', + dataIndex: 'account_code', + width: 110, + filterIcon: filterIcon(!!filters.account_root), + filterDropdown: ({ 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 }) => , + }, + { + key: 'counterparty_inn', + title: 'ИНН', + align: 'center', + // шире макетных 128: «таблетка» с 12-значным ИНН ИП + width: 140, + onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }), + render: (_: unknown, r) => , + filterIcon: filterIcon(!!filters.unmapped), + filterDropdown: ({ close }) => ( + + ), + }, + group('Сальдо на начало периода', [money('opening_debit', 'Дебет', numFormat), money('opening_credit', 'Кредит', numFormat)]), + group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]), + group('Сальдо на конец периода', [money('closing_debit', 'Дебет', numFormat), money('closing_credit', 'Кредит', numFormat)]), + { + key: 'counterparty_type', + title: 'Тип контрагента', + dataIndex: 'counterparty_type', + width: 130, + render: (t: CounterpartyType) => , + filterIcon: filterIcon(!!filters.counterparty_type), + filterDropdown: ({ close }) => ( + + ), + }, + ]; + }, [ + filters.account_root, + filters.counterparty_type, + filters.legal_entity_id, + filters.q, + filters.unmapped, + members, + numFormat, + onFilter, + onMap, + ]); return ( bordered diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 9597458..512d0a9 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -8,7 +8,7 @@ export function OsvTabs() { return ( ОСВ }, ...TABS.map((t) => ({ diff --git a/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx new file mode 100644 index 0000000..80a1552 --- /dev/null +++ b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx @@ -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 = { + 'руб.': 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(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 }; +} diff --git a/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx b/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx index 9ee315c..1bfb3d6 100644 --- a/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx +++ b/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx @@ -23,11 +23,6 @@ const errorCodeMap = { TRANSITION_NOT_ALLOWED: 'Данный переход недоступен', }; -/* -"Инициация", "Обработка документов", "Корректировка данных" -> "Отменена" (комментарий обязателен); - из "Обработка документов", "Корректировка данных" -> "Инициация" (комментарий обязателен во фронте) -*/ - async function mockStatusMappingFetch() { const mapping: Record = { INITIATED: ['CANCELLED'], @@ -95,17 +90,19 @@ export default function ChangeStatusModal({ open, versionId, setClose, onSuccess Текущий этап:
-
- - - - - - -
+ {nextStatusOptions && ( +
+ + + + + + +
+ )} ); }