diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts index dbf7f2e..bc05bfb 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts @@ -19,6 +19,5 @@ export function eventTypeLabel(type: BrokerEventItem['type']): string { export function eventStatusLabel(event: BrokerEventItem): string { if (event.source === 'actual') return 'Поступило' - if (event.type === 'offer') return 'Оферта' - return 'Прогноз' + return 'Ожидается' } diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts index 53e3a24..95f529c 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts @@ -64,12 +64,11 @@ describe('dashboardIncome', () => { expect(rows[0].amount.value).toBe(12) }) - it('splits instrument into main and subtitle, keeping the legacy alias', () => { + it('splits instrument into main and subtitle', () => { const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)]) expect(rows[0].instrumentMain).toBe('AAPL') expect(rows[0].instrumentSubtitle).toBe('Apple Inc.') - expect(rows[0].instrument).toBe('AAPL') }) it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => { @@ -78,7 +77,6 @@ describe('dashboardIncome', () => { expect(rows[0].instrumentMain).toBe('—') expect(rows[0].instrumentSubtitle).toBeNull() - expect(rows[0].instrument).toBe('—') }) it('does not duplicate subtitle when name equals ticker', () => { diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts index 8d58e10..c377546 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts @@ -14,7 +14,6 @@ export type DashboardIncomeRow = { date: string | null instrumentMain: string instrumentSubtitle: string | null - instrument: string typeLabel: DashboardIncomeTypeLabel amount: BrokerMoney } @@ -41,7 +40,6 @@ export function getDashboardIncomeRows(operations: BrokerOperation[]): Dashboard date: typeof operation.date === 'string' ? operation.date : null, instrumentMain: main, instrumentSubtitle: subtitle, - instrument: main, typeLabel: typeLabel(operation.type), amount: operation.payment!, } diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts index aa1c4a3..4082f8d 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts @@ -6,6 +6,7 @@ import { incomeTypeTone, instrumentDisplay, moneyTone, + moneyToneToColor, } from './dashboardVisual' function money(currency: string, value: number): BrokerMoney { @@ -85,6 +86,15 @@ describe('eventTypeTone', () => { }) }) +describe('moneyToneToColor', () => { + it('maps each MoneyTone to a stable MUI color', () => { + expect(moneyToneToColor('positive')).toBe('success.main') + expect(moneyToneToColor('negative')).toBe('error.main') + expect(moneyToneToColor('planned')).toBe('text.disabled') + expect(moneyToneToColor('neutral')).toBe('text.disabled') + }) +}) + describe('incomeTypeTone', () => { it('maps each income label to a stable tone', () => { expect(incomeTypeTone('Дивиденд')).toBe('success') diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts index 59dcdf3..325cce5 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts @@ -17,6 +17,18 @@ export function moneyTone(value: number | null | undefined, source?: MoneySource return source === 'forecast' ? 'planned' : 'positive' } +export function moneyToneToColor(tone: MoneyTone): string { + switch (tone) { + case 'positive': + return 'success.main' + case 'negative': + return 'error.main' + case 'planned': + case 'neutral': + return 'text.disabled' + } +} + export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string { if (!money) return '—' try { diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx index 944d1ed..b60b003 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx @@ -1,10 +1,10 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrokerPortfolio } from '@/shared/api' +import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api' import { BrokerDashboard } from './BrokerDashboard' const hookMocks = vi.hoisted(() => ({ @@ -74,6 +74,78 @@ function renderWithProviders(ui: ReactNode) { return render({ui}) } +function buildEvent(overrides: Partial = {}): BrokerEventItem { + return { + id: 'evt-1', + type: 'coupon', + source: 'actual', + category: 'cashflow', + eventDate: '2026-06-15T00:00:00.000Z', + paymentDate: null, + ticker: 'RU000A10AEF9', + name: 'РЖД 001Р-37R', + instrumentUid: null, + instrumentType: 'bond', + quantitySnapshot: null, + payoutPerUnit: null, + estimatedAmount: null, + actualAmount: 43.56, + currency: 'RUB', + estimateMode: null, + ...overrides, + } +} + +function buildOperation(overrides: Partial = {}): BrokerOperation { + return { + cursor: null, + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-15T00:00:00.000Z', + type: 'OPERATION_TYPE_COUPON', + category: 'income', + description: null, + name: 'ОФЗ 26241', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: null, + figi: null, + ticker: 'SU26249RMFS1', + classCode: null, + instrumentType: 'bond', + payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + ...overrides, + } +} + +function mockEventsLoaded(items: BrokerEventItem[]) { + hookMocks.useBrokerEvents.mockReturnValue({ + data: { items, summary: {}, asOf: '2026-06-26T00:00:00.000Z' }, + isLoading: false, + isError: false, + }) +} + +function mockOperationsLoaded(items: BrokerOperation[]) { + hookMocks.useBrokerOperations.mockReturnValue({ + data: { + accountId: 'acc-1', + items, + nextCursor: null, + hasNext: false, + asOf: '2026-06-26T00:00:00.000Z', + }, + isLoading: false, + isError: false, + }) +} + describe('BrokerDashboard', () => { beforeEach(() => { hookMocks.useBrokerEvents.mockReturnValue({ @@ -211,4 +283,156 @@ describe('BrokerDashboard', () => { expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() }) + + it('renders events table thead with semantic column headers', () => { + mockEventsLoaded([buildEvent()]) + + renderWithProviders() + + const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') + expect(eventsTable.querySelector('thead')).not.toBeNull() + const headers = within(eventsTable).getAllByRole('columnheader') + expect(headers.map((h) => h.textContent)).toEqual([ + 'Дата', + 'Инструмент', + 'Тип', + 'Сумма', + 'Статус', + ]) + }) + + it('renders income table thead without status column', () => { + mockOperationsLoaded([buildOperation()]) + + renderWithProviders() + + const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта') + expect(incomeTable.querySelector('thead')).not.toBeNull() + const headers = within(incomeTable).getAllByRole('columnheader') + expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма']) + }) + + it('renders event type badge, instrument subtitle and status pill for loaded events', () => { + mockEventsLoaded([ + buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }), + buildEvent({ + id: 'evt-forecast', + source: 'forecast', + type: 'dividend', + actualAmount: null, + estimatedAmount: 197.56, + }), + ]) + + renderWithProviders() + + const rows = screen.getAllByTestId('dashboard-events-row') + expect(rows).toHaveLength(2) + + const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle') + expect(subtitles).toHaveLength(2) + for (const subtitle of subtitles) { + expect(subtitle.textContent).toBe('РЖД 001Р-37R') + } + + const amounts = screen.getAllByTestId('dashboard-events-amount') + expect(amounts).toHaveLength(2) + expect(amounts[0].getAttribute('data-tone')).toBe('positive') + expect(amounts[0].textContent).toContain('+43,56') + expect(amounts[1].getAttribute('data-tone')).toBe('planned') + expect(amounts[1].textContent).toContain('~197,56') + + expect(screen.getByText('Купон')).toBeInTheDocument() + expect(screen.getByText('Дивиденд')).toBeInTheDocument() + expect(screen.getByText('Поступило')).toBeInTheDocument() + expect(screen.getByText('Ожидается')).toBeInTheDocument() + }) + + it('uses negative tone when event actual amount is negative', () => { + mockEventsLoaded([ + buildEvent({ + id: 'evt-tax', + type: 'coupon', + source: 'actual', + actualAmount: -87.0, + }), + ]) + + renderWithProviders() + + const [amount] = screen.getAllByTestId('dashboard-events-amount') + expect(amount.getAttribute('data-tone')).toBe('negative') + expect(amount.textContent).toContain('-87,00') + }) + + it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => { + mockEventsLoaded([ + buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }), + buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }), + ]) + mockOperationsLoaded([ + buildOperation({ + id: 'op-positive', + type: 'OPERATION_TYPE_COUPON', + ticker: 'SU26249RMFS1', + name: 'ОФЗ 26241', + payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 }, + }), + buildOperation({ + id: 'op-negative', + type: 'OPERATION_TYPE_DIVIDEND', + ticker: 'IRAO', + name: 'Интер РАО', + payment: { currency: 'RUB', units: '35', nano: 0, value: -35 }, + }), + ]) + + renderWithProviders() + + const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') + const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта') + for (const table of [eventsTable, incomeTable]) { + expect(table.textContent ?? '').toContain('₽') + expect(table.textContent ?? '').not.toContain('RUB') + } + }) + + it('renders income rows with main + subtitle, type badge and signed amount tone', () => { + mockOperationsLoaded([ + buildOperation({ + id: 'op-div', + type: 'OPERATION_TYPE_DIVIDEND', + ticker: 'IRAO', + name: 'Интер РАО', + payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 }, + }), + buildOperation({ + id: 'op-div-ext', + type: 'OPERATION_TYPE_DIV_EXT', + ticker: 'AAPL', + name: 'Apple Inc.', + payment: { currency: 'RUB', units: '100', nano: 0, value: -35 }, + }), + ]) + + renderWithProviders() + + const rows = screen.getAllByTestId('dashboard-income-row') + expect(rows).toHaveLength(2) + + const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main') + expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL']) + + const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle') + expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.']) + + expect(screen.getByText('Дивиденд')).toBeInTheDocument() + expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument() + + const amounts = screen.getAllByTestId('dashboard-income-amount') + expect(amounts[0].getAttribute('data-tone')).toBe('positive') + expect(amounts[0].textContent).toContain('+649,25') + expect(amounts[1].getAttribute('data-tone')).toBe('negative') + expect(amounts[1].textContent).toContain('−35,00') + }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index 26cb870..13a3e71 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -2,9 +2,18 @@ import { Button, Chip, Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' import { Link } from '@tanstack/react-router' import type { BrokerEventItem, BrokerEventsData } from '@/shared/api' -import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' +import { formatBrokerDate } from '@/shared/lib/formatters' import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' +import { + eventTypeTone, + formatDashboardCurrency, + instrumentDisplay, + type MoneyTone, + moneyTone, + moneyToneToColor, + type TypeTone, +} from '../lib/dashboardVisual' import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' @@ -13,7 +22,7 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' const EVENT_FILTERS: Array<{ type: DashboardEventType label: string - tone: 'success' | 'info' | 'warning' | 'neutral' + tone: TypeTone }> = [ { type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'coupon', label: 'Купоны', tone: 'info' }, @@ -21,6 +30,38 @@ const EVENT_FILTERS: Array<{ { type: 'offer', label: 'Оферты', tone: 'neutral' }, ] +const TH_SX = { + textAlign: 'left' as const, + borderBottom: '1px solid', + borderColor: 'divider', + px: 1.25, + py: 1, + color: 'text.secondary', + fontWeight: 600, + fontSize: 12, + letterSpacing: 0.2, + textTransform: 'uppercase' as const, + whiteSpace: 'nowrap' as const, +} + +const TD_SX_LEFT = { + px: 1.25, + py: 1, + borderBottom: '1px solid', + borderColor: 'divider', + verticalAlign: 'middle' as const, +} + +const TD_SX_RIGHT = { + ...TD_SX_LEFT, + textAlign: 'right' as const, +} + +const TD_SX_INSTRUMENT = { + ...TD_SX_LEFT, + minWidth: 180, +} + type BrokerDashboardEventsCardProps = { accountId: string data: BrokerEventsData | undefined @@ -45,11 +86,16 @@ type BrokerDashboardEventsCardProps = { canGoForward: boolean } -function eventAmount(event: BrokerEventItem): string { - const amount = event.source === 'actual' ? event.actualAmount : event.estimatedAmount - if (amount === null || amount === undefined) return '\u2014' - const prefix = event.source === 'actual' ? '+' : '~' - return `${prefix}${formatBrokerCurrencyValue(event.currency ?? 'RUB', amount)}` +function eventStatusTone(event: BrokerEventItem): TypeTone { + return event.source === 'actual' ? 'success' : 'neutral' +} + +function eventAmountValue(event: BrokerEventItem): number | null { + return event.source === 'actual' ? event.actualAmount : event.estimatedAmount +} + +function eventMoneyTone(event: BrokerEventItem): MoneyTone { + return moneyTone(eventAmountValue(event), event.source) } export function BrokerDashboardEventsCard({ @@ -119,58 +165,96 @@ export function BrokerDashboardEventsCard({ ) : ( - - - {events.map((event) => ( - - - {formatBrokerDate(event.eventDate)} - - - {event.ticker ?? event.name ?? '\u2014'} - - - {eventTypeLabel(event.type)} - - - {eventAmount(event)} - - - {eventStatusLabel(event)} - + + + + + Дата - ))} + + Инструмент + + + Тип + + + Сумма + + + Статус + + + + + {events.map((event) => { + const display = instrumentDisplay({ + ticker: event.ticker, + name: event.name, + }) + const amount = eventAmountValue(event) + const formattedAmount = + amount === null || amount === undefined + ? '—' + : `${event.source === 'actual' ? '+' : '~'}${formatDashboardCurrency({ + currency: event.currency ?? 'RUB', + value: amount, + })}` + return ( + + + {formatBrokerDate(event.eventDate) ?? '—'} + + + + + {display.main} + + {display.subtitle ? ( + + {display.subtitle} + + ) : null} + + + + + + + {formattedAmount} + + + + + + ) + })} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx index 3cd4138..349da27 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx @@ -2,7 +2,7 @@ import { Metric, Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api' import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters' -import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual' +import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual' type BrokerDashboardHeroProps = { portfolio: BrokerPortfolio @@ -13,18 +13,6 @@ function percentValue(value: unknown): string { return typeof value === 'number' ? formatBrokerPercent(value) : '—' } -function toneToColor(tone: MoneyTone): string { - switch (tone) { - case 'positive': - return 'success.main' - case 'negative': - return 'error.main' - case 'planned': - case 'neutral': - return 'text.disabled' - } -} - export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) { const accountName = portfolio.account.name || 'Брокерский счёт' const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent @@ -66,12 +54,12 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer + {percentValue(returnPercent)} } supportingText={ - + За день: {formatBrokerMoney(portfolio.yields.daily)} } @@ -81,7 +69,10 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer + {totalReceivedDisplay} } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index 412fcc4..2859589 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -2,9 +2,15 @@ import { Button, Chip, Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' import { Link } from '@tanstack/react-router' import type { BrokerOperationsPage } from '@/shared/api' -import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' +import { formatBrokerDate } from '@/shared/lib/formatters' import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' +import { + formatDashboardCurrency, + incomeTypeTone, + moneyToneToColor, + type TypeTone, +} from '../lib/dashboardVisual' import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' @@ -13,12 +19,44 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' const INCOME_FILTERS: Array<{ type: DashboardIncomeType label: string - tone: 'success' | 'info' + tone: TypeTone }> = [ { type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'coupon', label: 'Купоны', tone: 'info' }, ] +const TH_SX = { + textAlign: 'left' as const, + borderBottom: '1px solid', + borderColor: 'divider', + px: 1.25, + py: 1, + color: 'text.secondary', + fontWeight: 600, + fontSize: 12, + letterSpacing: 0.2, + textTransform: 'uppercase' as const, + whiteSpace: 'nowrap' as const, +} + +const TD_SX_LEFT = { + px: 1.25, + py: 1, + borderBottom: '1px solid', + borderColor: 'divider', + verticalAlign: 'middle' as const, +} + +const TD_SX_RIGHT = { + ...TD_SX_LEFT, + textAlign: 'right' as const, +} + +const TD_SX_INSTRUMENT = { + ...TD_SX_LEFT, + minWidth: 200, +} + type BrokerDashboardIncomeCardProps = { accountId: string page: BrokerOperationsPage | undefined @@ -111,54 +149,89 @@ export function BrokerDashboardIncomeCard({ ) : ( - - - {rows.map((row) => ( - - - {formatBrokerDate(row.date)} - - - {row.instrument} - - - {row.typeLabel} - - - +{formatBrokerCurrencyValue(row.amount.currency, row.amount.value)} - + + + + + Дата - ))} + + Инструмент + + + Тип + + + Сумма + + + + + {rows.map((row) => { + const formattedAmount = `${row.amount.value >= 0 ? '+' : '−'}${formatDashboardCurrency( + { currency: row.amount.currency, value: Math.abs(row.amount.value) }, + )}` + const amountTone = row.amount.value >= 0 ? 'positive' : 'negative' + return ( + + + {formatBrokerDate(row.date) ?? '—'} + + + + + {row.instrumentMain} + + {row.instrumentSubtitle ? ( + + {row.instrumentSubtitle} + + ) : null} + + + + + + + {formattedAmount} + + + ) + })} Показано: {rows.length} · Итого:{' '} - {total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'} + {total + ? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({ + currency: total.currency, + value: Math.abs(total.value), + })}` + : '—'}