From 6d8998988e92d9d81d7ac491ab0e6d0c505e02e4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 03:06:42 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20?= =?UTF-8?q?=D1=81=D1=83=D0=BC=D0=BC=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D0=B5=20(=D0=B7=D0=B0=D0=BF=D1=8F?= =?UTF-8?q?=D1=82=D0=B0=D1=8F,=20=C2=AB=E2=80=94=C2=BB=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BD=D1=83=D0=BB=D1=8F,=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B5=D0=B5=D0=BA=20=D0=B2=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D1=85),=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B8=20=D0=9A3=20=D0=B8=20=C2=AB9=20=D0=BC=D0=B5=D1=81.?= =?UTF-8?q?=C2=BB,=20=D0=BA=D1=80=D0=BE=D1=88=D0=BA=D0=B8=20=C2=AB=20?= =?UTF-8?q?=E2=80=BA=20=C2=BB=20=D0=B8=20=D1=81=D0=BB=D0=BE=D1=82=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=B2=20PageTitle=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- src/components/PageTitle.tsx | 18 +++++++++++++----- src/utils/labels.test.ts | 12 ++++++++++++ src/utils/labels.ts | 9 +++++++++ src/utils/money.test.ts | 21 ++++++++++++++++----- src/utils/money.ts | 26 ++++++++++++++++++++++---- 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/components/PageTitle.tsx b/src/components/PageTitle.tsx index 8ac37c2..c79a8cf 100644 --- a/src/components/PageTitle.tsx +++ b/src/components/PageTitle.tsx @@ -4,16 +4,23 @@ import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; interface PageTitleProps { - location: string; + /** Хлебные крошки: массив сегментов или строка с разделителем «>»/«›» — выводятся через « › », как в макете. */ + location: string | string[]; header: string; description?: ReactNode; backable?: boolean; + /** Правая часть шапки (бейдж статуса, кнопки). */ + extra?: ReactNode; } -export default function PageTitle({ location, header, description, backable = false }: PageTitleProps) { + +const crumbs = (location: string | string[]) => + (Array.isArray(location) ? location : location.split(/\s*[>›]\s*/)).filter((s) => s.trim() !== '').join(' › '); + +export default function PageTitle({ location, header, description, backable = false, extra }: PageTitleProps) { const navigate = useNavigate(); return ( -
-
+
+
{backable && (

{description &&
{description}
}

+ {extra &&
{extra}
}
); } diff --git a/src/utils/labels.test.ts b/src/utils/labels.test.ts index 0a7457d..4449c71 100644 --- a/src/utils/labels.test.ts +++ b/src/utils/labels.test.ts @@ -8,6 +8,7 @@ import { periodLabel, quarterLabel, } from './labels'; +import { osvPeriodLabel, quarterShortLabel } from './labels'; describe('labels', () => { it('тип документа', () => { @@ -57,3 +58,14 @@ describe('подписи свода', () => { expect(matchKindLabel(null)).toBe('не сопоставлен'); }); }); + +describe('подписи периода', () => { + it('квартал коротко', () => { + expect(quarterShortLabel('Q3')).toBe('К3'); + }); + it('период строки свода', () => { + expect(osvPeriodLabel('2024 Q3')).toBe('9 мес. 2024 г.'); + expect(osvPeriodLabel('2025 Q1')).toBe('3 мес. 2025 г.'); + expect(osvPeriodLabel('странное')).toBe('странное'); + }); +}); diff --git a/src/utils/labels.ts b/src/utils/labels.ts index da5e553..3b1cd83 100644 --- a/src/utils/labels.ts +++ b/src/utils/labels.ts @@ -16,6 +16,15 @@ export function documentTypeLabel(type: DocumentType, account: string | null): s export const quarterLabel = (q: Quarter): string => `${q.slice(1)} квартал`; +/** «К3» — как в таблицах макета. */ +export const quarterShortLabel = (q: Quarter): string => `К${q.slice(1)}`; + +/** Период строки свода «2024 Q3» → «9 мес. 2024 г.» (свод приведён к нарастающему итогу, §2). */ +export function osvPeriodLabel(period: string): string { + const match = /^(\d{4}) (Q[1-4])$/.exec(period); + return match ? `${MONTHS[match[2] as Quarter]} мес. ${match[1]} г.` : period; +} + export function periodLabel(year: number | null, quarter: Quarter | null, isCumulative: boolean | null): string { if (year === null || quarter === null) { return 'период не определён'; diff --git a/src/utils/money.test.ts b/src/utils/money.test.ts index 7535771..a50cce1 100644 --- a/src/utils/money.test.ts +++ b/src/utils/money.test.ts @@ -4,19 +4,30 @@ import { formatAmount, parseAmountInput } from './money'; const NBSP = ' '; describe('formatAmount', () => { - it('разделяет разряды и держит два знака', () => { - expect(formatAmount('4454151.93')).toBe(`4${NBSP}454${NBSP}151.93`); - expect(formatAmount('100')).toBe('100.00'); - expect(formatAmount('123.5')).toBe('123.50'); + it('разделяет разряды, запятая и два знака (как в макете)', () => { + expect(formatAmount('4454151.93')).toBe(`4${NBSP}454${NBSP}151,93`); + expect(formatAmount('100')).toBe('100,00'); + expect(formatAmount('123.5')).toBe('123,50'); }); it('отрицательные', () => { - expect(formatAmount('-1234567.1')).toBe(`-1${NBSP}234${NBSP}567.10`); + expect(formatAmount('-1234567.1')).toBe(`-1${NBSP}234${NBSP}567,10`); }); it('пустое — прочерк', () => { expect(formatAmount(null)).toBe('—'); expect(formatAmount(undefined)).toBe('—'); expect(formatAmount('')).toBe('—'); }); + it('ноль — число по умолчанию, прочерк с zeroDash', () => { + expect(formatAmount('0.00')).toBe('0,00'); + expect(formatAmount('0.00', { zeroDash: true })).toBe('—'); + expect(formatAmount('-0', { zeroDash: true })).toBe('—'); + expect(formatAmount('0.01', { zeroDash: true })).toBe('0,01'); + }); + it('fraction auto — без копеек, если они нулевые (формы ББ/ОФР)', () => { + expect(formatAmount('70045.00', { fraction: 'auto' })).toBe(`70${NBSP}045`); + expect(formatAmount('70045.5', { fraction: 'auto' })).toBe(`70${NBSP}045,50`); + expect(formatAmount(null, { fraction: 'auto', empty: '-' })).toBe('-'); + }); it('нераспознанная запись возвращается как есть', () => { expect(formatAmount('1E+2')).toBe('1E+2'); }); diff --git a/src/utils/money.ts b/src/utils/money.ts index ffe35ac..954b116 100644 --- a/src/utils/money.ts +++ b/src/utils/money.ts @@ -2,10 +2,20 @@ const NBSP = ' '; const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/; const INPUT = /^-?\d+(\.\d{1,2})?$/; -/** Сумма из API (строка Decimal) → «1 234 567.80». Без арифметики над float; нераспознанное — как есть. */ -export function formatAmount(value: string | null | undefined): string { +export type AmountFormat = { + /** Ноль показывать прочерком (свод, ОСВ — как в макете [30]). */ + zeroDash?: boolean; + /** 'auto' — без копеек, если они нулевые (формы ББ/ОФР в тыс. руб., макет [41]). */ + fraction?: 'always' | 'auto'; + /** Что показать вместо пустого значения. */ + empty?: string; +}; + +/** Сумма из API (строка Decimal) → «1 234 567,80». Без арифметики над float; нераспознанное — как есть. */ +export function formatAmount(value: string | null | undefined, format: AmountFormat = {}): string { + const empty = format.empty ?? '—'; if (value === null || value === undefined || value === '') { - return '—'; + return empty; } const match = PLAIN.exec(value); if (!match) { @@ -15,8 +25,16 @@ export function formatAmount(value: string | null | undefined): string { if (frac.length > 2) { return value; } + const isZero = /^0+$/.test(intPart) && /^0*$/.test(frac); + if (isZero && format.zeroDash) { + return '—'; + } const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); - return `${sign}${grouped}.${frac.padEnd(2, '0')}`; + const cents = frac.padEnd(2, '0'); + if (format.fraction === 'auto' && cents === '00') { + return `${isZero ? '' : sign}${grouped}`; + } + return `${isZero ? '' : sign}${grouped},${cents}`; } /** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */