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:
parent
977a0efeaf
commit
6d8998988e
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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('странное');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 'период не определён';
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user