feat: формат сумм как в макете (запятая, «—» для нуля, без копеек в формах), подписи К3 и «9 мес.», крошки « › » и слот справа в PageTitle (#3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 03:06:42 +04:00
parent 977a0efeaf
commit 6d8998988e
5 changed files with 72 additions and 14 deletions

View File

@ -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 (
<header style={{ display: 'flex', marginBottom: 12, flex: '0 0 auto' }}>
<div>
<header style={{ display: 'flex', alignItems: 'flex-start', gap: 16, marginBottom: 12, flex: '0 0 auto' }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<div style={{ fontSize: 12, color: '#667085' }}>
{backable && (
<Button
@ -24,7 +31,7 @@ export default function PageTitle({ location, header, description, backable = fa
Назад
</Button>
)}
{location}
{crumbs(location)}
</div>
<h1
style={{
@ -38,6 +45,7 @@ export default function PageTitle({ location, header, description, backable = fa
</h1>
{description && <div style={{ color: '#667085' }}>{description}</div>}
</div>
{extra && <div style={{ flex: '0 0 auto' }}>{extra}</div>}
</header>
);
}

View File

@ -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('странное');
});
});

View File

@ -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 'период не определён';

View File

@ -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');
});

View File

@ -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. */