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';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface PageTitleProps {
|
interface PageTitleProps {
|
||||||
location: string;
|
/** Хлебные крошки: массив сегментов или строка с разделителем «>»/«›» — выводятся через « › », как в макете. */
|
||||||
|
location: string | string[];
|
||||||
header: string;
|
header: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
backable?: boolean;
|
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();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<header style={{ display: 'flex', marginBottom: 12, flex: '0 0 auto' }}>
|
<header style={{ display: 'flex', alignItems: 'flex-start', gap: 16, marginBottom: 12, flex: '0 0 auto' }}>
|
||||||
<div>
|
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 12, color: '#667085' }}>
|
<div style={{ fontSize: 12, color: '#667085' }}>
|
||||||
{backable && (
|
{backable && (
|
||||||
<Button
|
<Button
|
||||||
@ -24,7 +31,7 @@ export default function PageTitle({ location, header, description, backable = fa
|
|||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{location}
|
{crumbs(location)}
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
@ -38,6 +45,7 @@ export default function PageTitle({ location, header, description, backable = fa
|
|||||||
</h1>
|
</h1>
|
||||||
{description && <div style={{ color: '#667085' }}>{description}</div>}
|
{description && <div style={{ color: '#667085' }}>{description}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
{extra && <div style={{ flex: '0 0 auto' }}>{extra}</div>}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
periodLabel,
|
periodLabel,
|
||||||
quarterLabel,
|
quarterLabel,
|
||||||
} from './labels';
|
} from './labels';
|
||||||
|
import { osvPeriodLabel, quarterShortLabel } from './labels';
|
||||||
|
|
||||||
describe('labels', () => {
|
describe('labels', () => {
|
||||||
it('тип документа', () => {
|
it('тип документа', () => {
|
||||||
@ -57,3 +58,14 @@ describe('подписи свода', () => {
|
|||||||
expect(matchKindLabel(null)).toBe('не сопоставлен');
|
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)} квартал`;
|
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 {
|
export function periodLabel(year: number | null, quarter: Quarter | null, isCumulative: boolean | null): string {
|
||||||
if (year === null || quarter === null) {
|
if (year === null || quarter === null) {
|
||||||
return 'период не определён';
|
return 'период не определён';
|
||||||
|
|||||||
@ -4,19 +4,30 @@ import { formatAmount, parseAmountInput } from './money';
|
|||||||
const NBSP = ' ';
|
const NBSP = ' ';
|
||||||
|
|
||||||
describe('formatAmount', () => {
|
describe('formatAmount', () => {
|
||||||
it('разделяет разряды и держит два знака', () => {
|
it('разделяет разряды, запятая и два знака (как в макете)', () => {
|
||||||
expect(formatAmount('4454151.93')).toBe(`4${NBSP}454${NBSP}151.93`);
|
expect(formatAmount('4454151.93')).toBe(`4${NBSP}454${NBSP}151,93`);
|
||||||
expect(formatAmount('100')).toBe('100.00');
|
expect(formatAmount('100')).toBe('100,00');
|
||||||
expect(formatAmount('123.5')).toBe('123.50');
|
expect(formatAmount('123.5')).toBe('123,50');
|
||||||
});
|
});
|
||||||
it('отрицательные', () => {
|
it('отрицательные', () => {
|
||||||
expect(formatAmount('-1234567.1')).toBe(`-1${NBSP}234${NBSP}567.10`);
|
expect(formatAmount('-1234567.1')).toBe(`-1${NBSP}234${NBSP}567,10`);
|
||||||
});
|
});
|
||||||
it('пустое — прочерк', () => {
|
it('пустое — прочерк', () => {
|
||||||
expect(formatAmount(null)).toBe('—');
|
expect(formatAmount(null)).toBe('—');
|
||||||
expect(formatAmount(undefined)).toBe('—');
|
expect(formatAmount(undefined)).toBe('—');
|
||||||
expect(formatAmount('')).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('нераспознанная запись возвращается как есть', () => {
|
it('нераспознанная запись возвращается как есть', () => {
|
||||||
expect(formatAmount('1E+2')).toBe('1E+2');
|
expect(formatAmount('1E+2')).toBe('1E+2');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,10 +2,20 @@ const NBSP = ' ';
|
|||||||
const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/;
|
const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/;
|
||||||
const INPUT = /^-?\d+(\.\d{1,2})?$/;
|
const INPUT = /^-?\d+(\.\d{1,2})?$/;
|
||||||
|
|
||||||
/** Сумма из API (строка Decimal) → «1 234 567.80». Без арифметики над float; нераспознанное — как есть. */
|
export type AmountFormat = {
|
||||||
export function formatAmount(value: string | null | undefined): string {
|
/** Ноль показывать прочерком (свод, ОСВ — как в макете [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 === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '—';
|
return empty;
|
||||||
}
|
}
|
||||||
const match = PLAIN.exec(value);
|
const match = PLAIN.exec(value);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@ -15,8 +25,16 @@ export function formatAmount(value: string | null | undefined): string {
|
|||||||
if (frac.length > 2) {
|
if (frac.length > 2) {
|
||||||
return value;
|
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);
|
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. */
|
/** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user