1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-24 20:06:55 +01:00

feat: invoice sections (#10744)

Currency formatting and cleaner invoice components
This commit is contained in:
Tymoteusz Czech 2025-10-08 09:15:43 +02:00 committed by GitHub
parent fab5dc8725
commit 183d436e59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 159 additions and 179 deletions

View File

@ -10,6 +10,11 @@ export const StyledSubgrid = styled('div', {
? theme.palette.background.elevation1 ? theme.palette.background.elevation1
: 'transparent', : 'transparent',
margin: theme.spacing(0.25, 0), margin: theme.spacing(0.25, 0),
padding: theme.spacing(0, 2), padding: withBackground ? theme.spacing(0, 2, 1) : theme.spacing(0, 2),
borderRadius: theme.shape.borderRadiusLarge, borderRadius: theme.shape.borderRadiusLarge,
})); }));
export const StyledAmountCell = styled('div')(({ theme }) => ({
textAlign: 'right',
paddingRight: theme.spacing(1.5),
}));

View File

@ -1,3 +1,4 @@
import type { FC, ReactNode } from 'react';
import { import {
Typography, Typography,
styled, styled,
@ -6,13 +7,12 @@ import {
AccordionDetails, AccordionDetails,
} from '@mui/material'; } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { formatCurrency } from './types.ts'; import { formatCurrency } from './formatCurrency.ts';
import { Badge } from 'component/common/Badge/Badge.tsx'; import { Badge } from 'component/common/Badge/Badge.tsx';
import type { FC, ReactNode } from 'react';
import type { DetailedInvoicesSchemaInvoicesItem } from 'openapi/index.ts';
import { BillingInvoiceRow } from './BillingInvoiceRow/BillingInvoiceRow.tsx'; import { BillingInvoiceRow } from './BillingInvoiceRow/BillingInvoiceRow.tsx';
import { BillingInvoiceFooter } from './BillingInvoiceFooter/BillingInvoiceFooter.tsx'; import { BillingInvoiceFooter } from './BillingInvoiceFooter/BillingInvoiceFooter.tsx';
import { StyledSubgrid } from './BillingInvoice.styles.tsx'; import { StyledAmountCell, StyledSubgrid } from './BillingInvoice.styles.tsx';
import type { DetailedInvoicesSchemaInvoicesItem } from 'openapi';
const CardLikeAccordion = styled(Accordion)(({ theme }) => ({ const CardLikeAccordion = styled(Accordion)(({ theme }) => ({
background: theme.palette.background.paper, background: theme.palette.background.paper,
@ -73,7 +73,7 @@ const TableBody: FC<{ children: ReactNode; title?: string }> = ({
const StyledSectionTitle = styled(Typography)(({ theme }) => ({ const StyledSectionTitle = styled(Typography)(({ theme }) => ({
gridColumn: '1 / -1', gridColumn: '1 / -1',
padding: theme.spacing(2, 0), padding: theme.spacing(2, 0, 1),
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
})); }));
@ -84,69 +84,14 @@ const StyledTableRow = styled('div')(({ theme }) => ({
padding: theme.spacing(1, 0), padding: theme.spacing(1, 0),
})); }));
const sectionsMock = [
{
id: 'seats',
items: [
{
description: 'Unleash PAYG Seat',
quota: 50,
quantity: 41,
amount: 3_076,
},
],
},
{
id: 'usage',
title: 'Usage: September',
items: [
{
description: 'Frontend traffic',
quota: 10_000_000,
quantity: 1_085_000_000,
amount: 5_425,
},
{
description: 'Service connections',
quota: 7,
quantity: 20,
amount: 0,
},
{
description: 'Release templates',
quota: 5,
quantity: 3,
amount: 0,
},
{
description: 'Edge Frontend Traffic',
quota: 10_000_000,
quantity: 2_000_000,
amount: 0,
},
{
description: 'Edge Service Connections',
quota: 5,
quantity: 5,
amount: 0,
},
],
summary: {
subtotal: 8_500,
taxExemptNote: 'Customer tax is exempt',
total: 8_500,
},
},
];
export const BillingInvoice = ({ export const BillingInvoice = ({
status, status,
dueDate,
invoiceDate, invoiceDate,
invoicePDF, invoicePDF,
invoiceURL, invoiceURL,
totalAmount, totalAmount,
mainLines, mainLines,
usageLines,
}: DetailedInvoicesSchemaInvoicesItem) => { }: DetailedInvoicesSchemaInvoicesItem) => {
const title = invoiceDate const title = invoiceDate
? new Date(invoiceDate).toLocaleDateString(undefined, { ? new Date(invoiceDate).toLocaleDateString(undefined, {
@ -155,6 +100,8 @@ export const BillingInvoice = ({
}) })
: ''; : '';
const currency = mainLines[0]?.currency || usageLines?.[0]?.currency;
return ( return (
<CardLikeAccordion defaultExpanded> <CardLikeAccordion defaultExpanded>
<HeaderRoot <HeaderRoot
@ -181,7 +128,7 @@ export const BillingInvoice = ({
<Badge color='success'>Invoiced</Badge> <Badge color='success'>Invoiced</Badge>
) : null} ) : null}
<Typography variant='body1' sx={{ fontWeight: 700 }}> <Typography variant='body1' sx={{ fontWeight: 700 }}>
{formatCurrency(totalAmount)} {formatCurrency(totalAmount, currency)}
</Typography> </Typography>
</HeaderRight> </HeaderRight>
</HeaderRoot> </HeaderRoot>
@ -196,31 +143,32 @@ export const BillingInvoice = ({
<HeaderCell>Description</HeaderCell> <HeaderCell>Description</HeaderCell>
<HeaderCell>Included</HeaderCell> <HeaderCell>Included</HeaderCell>
<HeaderCell>Quantity</HeaderCell> <HeaderCell>Quantity</HeaderCell>
<HeaderCell>Amount</HeaderCell> <HeaderCell>
<StyledAmountCell>Amount</StyledAmountCell>
</HeaderCell>
</StyledSubgrid> </StyledSubgrid>
{mainLines.map((line) => ( {mainLines.map((line) => (
<TableBody <TableBody key={line.description}>
key={line.description}
// TODO: split into "usage" category
title={line.description}
>
{/* {line.description ? (
<StyledSectionTitle>
{line.description}
</StyledSectionTitle>
) : null} */}
<StyledTableRow key={line.description}> <StyledTableRow key={line.description}>
<BillingInvoiceRow <BillingInvoiceRow {...line} />
description={line.description}
quota={line.limit}
quantity={line.quantity}
amount={line.totalAmount}
/>
</StyledTableRow> </StyledTableRow>
</TableBody> </TableBody>
))} ))}
{usageLines.length ? (
<TableBody key='usage' title='Usage'>
<StyledSectionTitle>Usage</StyledSectionTitle>
{usageLines.map((line) => (
<StyledTableRow key={line.description}>
<BillingInvoiceRow {...line} />
</StyledTableRow>
))}
</TableBody>
) : null}
<BillingInvoiceFooter totalAmount={totalAmount} /> <BillingInvoiceFooter
totalAmount={totalAmount}
currency={currency}
/>
</StyledInvoiceGrid> </StyledInvoiceGrid>
</AccordionDetails> </AccordionDetails>
</CardLikeAccordion> </CardLikeAccordion>

View File

@ -1,11 +1,11 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { formatCurrency } from '../types.ts'; import { formatCurrency } from '../formatCurrency.ts';
import { StyledSubgrid } from '../BillingInvoice.styles.tsx'; import { StyledAmountCell, StyledSubgrid } from '../BillingInvoice.styles.tsx';
const StyledTableFooter = styled(StyledSubgrid)(({ theme }) => ({ const StyledTableFooter = styled(StyledSubgrid)(({ theme }) => ({
gridColumn: '3 / -1', gridColumn: '3 / -1',
padding: theme.spacing(1, 0, 0), padding: theme.spacing(1, 1, 0, 0),
})); }));
const StyledTableFooterRow = styled('div')<{ last?: boolean }>( const StyledTableFooterRow = styled('div')<{ last?: boolean }>(
@ -27,18 +27,8 @@ const StyledTableFooterCell = styled('div', {
...(colSpan ? { gridColumn: `span ${colSpan}` } : {}), ...(colSpan ? { gridColumn: `span ${colSpan}` } : {}),
})); }));
interface BillingInvoiceFooterProps { const TaxRow: FC<{ value?: number }> = ({ value }) => {
subTotal?: number;
taxAmount?: number;
totalAmount: number;
}
const TaxRow: FC<{ value?: number | null }> = ({ value }) => {
if (value === undefined) { if (value === undefined) {
return null;
}
if (value === null) {
return ( return (
<StyledTableFooterCell colSpan={2}> <StyledTableFooterCell colSpan={2}>
Customer tax is exempt Customer tax is exempt
@ -50,24 +40,34 @@ const TaxRow: FC<{ value?: number | null }> = ({ value }) => {
<> <>
<StyledTableFooterCell>Tax</StyledTableFooterCell> <StyledTableFooterCell>Tax</StyledTableFooterCell>
<StyledTableFooterCell> <StyledTableFooterCell>
{formatCurrency(value)} <StyledAmountCell>{formatCurrency(value)}</StyledAmountCell>
</StyledTableFooterCell> </StyledTableFooterCell>
</> </>
); );
}; };
type BillingInvoiceFooterProps = {
subTotal?: number;
taxAmount?: number;
totalAmount: number;
currency?: string;
};
export const BillingInvoiceFooter = ({ export const BillingInvoiceFooter = ({
subTotal, subTotal,
taxAmount, taxAmount,
totalAmount, totalAmount,
currency,
}: BillingInvoiceFooterProps) => { }: BillingInvoiceFooterProps) => {
return ( return (
<StyledTableFooter> <StyledTableFooter>
{subTotal ? ( {subTotal || !taxAmount ? (
<StyledTableFooterRow> <StyledTableFooterRow>
<StyledTableFooterCell>Sub total</StyledTableFooterCell> <StyledTableFooterCell>Sub total</StyledTableFooterCell>
<StyledTableFooterCell> <StyledTableFooterCell>
{formatCurrency(subTotal)} <StyledAmountCell>
{formatCurrency(subTotal || totalAmount, currency)}
</StyledAmountCell>
</StyledTableFooterCell> </StyledTableFooterCell>
</StyledTableFooterRow> </StyledTableFooterRow>
) : null} ) : null}
@ -77,7 +77,9 @@ export const BillingInvoiceFooter = ({
<StyledTableFooterRow last> <StyledTableFooterRow last>
<StyledTableFooterCell>Total</StyledTableFooterCell> <StyledTableFooterCell>Total</StyledTableFooterCell>
<StyledTableFooterCell> <StyledTableFooterCell>
{formatCurrency(totalAmount)} <StyledAmountCell>
{formatCurrency(totalAmount, currency)}
</StyledAmountCell>
</StyledTableFooterCell> </StyledTableFooterCell>
</StyledTableFooterRow> </StyledTableFooterRow>
</StyledTableFooter> </StyledTableFooter>

View File

@ -1,7 +1,9 @@
import { formatLargeNumbers } from 'component/impact-metrics/metricsFormatters.ts'; import { formatLargeNumbers } from 'component/impact-metrics/metricsFormatters.ts';
import { formatCurrency } from '../types.ts'; import { formatCurrency } from '../formatCurrency.ts';
import { ConsumptionIndicator } from '../ConsumptionIndicator/ConsumptionIndicator.tsx'; import { ConsumptionIndicator } from '../ConsumptionIndicator/ConsumptionIndicator.tsx';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import type { DetailedInvoicesLineSchema } from 'openapi';
import { StyledAmountCell } from '../BillingInvoice.styles.tsx';
const StyledCellWithIndicator = styled('div')(({ theme }) => ({ const StyledCellWithIndicator = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -14,34 +16,34 @@ type BillingInvoiceRowProps = {
quantity?: number; quantity?: number;
amount?: number; amount?: number;
quota?: number; quota?: number;
usage?: number;
}; };
export const BillingInvoiceRow = ({ export const BillingInvoiceRow = ({
quantity, quantity,
amount, consumption,
quota, limit,
description, description,
}: BillingInvoiceRowProps) => { currency,
const usage = quantity || 0; totalAmount,
}: DetailedInvoicesLineSchema) => {
const percentage = const percentage =
quota && quota > 0 limit && limit > 0
? Math.min(100, Math.round((usage / quota) * 100)) ? Math.min(100, Math.round(((consumption || 0) / limit) * 100))
: undefined; : undefined;
return ( return (
<> <>
<div>{description}</div> <div>{description}</div>
<StyledCellWithIndicator> <StyledCellWithIndicator>
{percentage !== undefined && ( <ConsumptionIndicator percentage={percentage || 0} />
<ConsumptionIndicator percentage={percentage} /> {limit !== undefined ? formatLargeNumbers(limit) : ''}
)}
{quota !== undefined ? formatLargeNumbers(quota) : ''}
{percentage !== undefined ? ` (${percentage}%)` : ''} {percentage !== undefined ? ` (${percentage}%)` : ''}
</StyledCellWithIndicator> </StyledCellWithIndicator>
<div> <div>{quantity ? formatLargeNumbers(quantity) : ''}</div>
{quantity !== undefined ? formatLargeNumbers(quantity) : ''} <StyledAmountCell>
</div> {formatCurrency(totalAmount || 0, currency)}
<div>{formatCurrency(amount || 0)}</div> </StyledAmountCell>
</> </>
); );
}; };

View File

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './formatCurrency.ts';
describe('formatCurrency', () => {
it('formats USD currency', () => {
expect(formatCurrency(1000, 'USD')).toMatchInlineSnapshot(`"$1,000"`);
expect(formatCurrency(1234.56, 'USD')).toMatchInlineSnapshot(
`"$1,234.56"`,
);
expect(formatCurrency(1000000, 'USD')).toMatchInlineSnapshot(
`"$1,000,000"`,
);
expect(formatCurrency(0, 'USD')).toMatchInlineSnapshot(`"$0"`);
expect(formatCurrency(-500, 'USD')).toMatchInlineSnapshot(`"$-500"`);
});
it('formats EUR currency', () => {
expect(formatCurrency(1000, 'EUR')).toMatchInlineSnapshot(`"€1 000"`);
expect(formatCurrency(1234.56, 'EUR')).toMatchInlineSnapshot(
`"€1 234,56"`,
);
expect(formatCurrency(1000000, 'EUR')).toMatchInlineSnapshot(
`"€1 000 000"`,
);
expect(formatCurrency(0, 'EUR')).toMatchInlineSnapshot(`"€0"`);
expect(formatCurrency(-500, 'EUR')).toMatchInlineSnapshot(`"€500"`);
});
it('formats other currencies', () => {
expect(formatCurrency(1000, 'GBP')).toMatchInlineSnapshot(`"1000 GBP"`);
expect(formatCurrency(100000, 'JPY')).toMatchInlineSnapshot(
`"100000 JPY"`,
);
expect(formatCurrency(500, 'SEK')).toMatchInlineSnapshot(`"500 SEK"`);
expect(formatCurrency(1000, '')).toMatchInlineSnapshot(`"1000"`);
});
it('formats without currency', () => {
expect(formatCurrency(1000)).toMatchInlineSnapshot(`"1000"`);
expect(formatCurrency(1234.56)).toMatchInlineSnapshot(`"1234.56"`);
expect(formatCurrency(0)).toMatchInlineSnapshot(`"0"`);
expect(formatCurrency(-500)).toMatchInlineSnapshot(`"-500"`);
});
it('handles edge cases', () => {
expect(formatCurrency(0.01, 'USD')).toMatchInlineSnapshot(`"$0.01"`);
expect(formatCurrency(999999999, 'EUR')).toMatchInlineSnapshot(
`"€999 999 999"`,
);
expect(formatCurrency(10.999, 'USD')).toMatchInlineSnapshot(
`"$10.999"`,
);
expect(formatCurrency(10.999, 'EUR')).toMatchInlineSnapshot(
`"€10,999"`,
);
});
});

View File

@ -0,0 +1,10 @@
export const formatCurrency = (value: number, currency?: string) => {
if (currency === 'USD') {
return `$${value.toLocaleString('en-US')}`;
}
if (currency === 'EUR') {
return `\u2009${value.toLocaleString('no-NO')}`;
}
return `${value}${currency ? ' ' : ''}${currency || ''}`;
};

View File

@ -1,57 +0,0 @@
export interface UsageMetric {
id: string;
label: string;
includedCurrent: number;
includedMax: number;
includedUnit: string;
actual?: string;
amount: number;
}
export const defaultMetrics: UsageMetric[] = [
{
id: 'frontend-traffic',
label: 'Frontend traffic',
includedCurrent: 10,
includedMax: 10,
includedUnit: 'M requests',
actual: '1,085M requests',
amount: 5425,
},
{
id: 'service-connections',
label: 'Service connections',
includedCurrent: 7,
includedMax: 7,
includedUnit: 'connections',
actual: '20 connections',
amount: 0,
},
{
id: 'release-templates',
label: 'Release templates',
includedCurrent: 3,
includedMax: 5,
includedUnit: 'templates',
amount: 0,
},
{
id: 'edge-frontend-traffic',
label: 'Edge Frontend Traffic',
includedCurrent: 2,
includedMax: 10,
includedUnit: 'M requests',
amount: 0,
},
{
id: 'edge-service-connections',
label: 'Edge Service Connections',
includedCurrent: 5,
includedMax: 5,
includedUnit: 'connections',
amount: 0,
},
];
export const formatCurrency = (value: number) =>
`$${value.toLocaleString('en-US')}`;

View File

@ -25,9 +25,9 @@ export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
return { return {
invoices: [ invoices: [
// TODO:MOCK // FIXME: MOCK
{ {
status: 'paid', status: 'upcoming',
dueDate: '2023-09-01', dueDate: '2023-09-01',
invoiceDate: '2023-08-01', invoiceDate: '2023-08-01',
invoicePDF: 'https://example.com/invoice/1.pdf', invoicePDF: 'https://example.com/invoice/1.pdf',
@ -38,7 +38,9 @@ export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
currency: 'USD', currency: 'USD',
description: 'Service C', description: 'Service C',
lookupKey: 'service-c', lookupKey: 'service-c',
quantity: 1, quantity: 0,
consumption: 100,
limit: 120,
totalAmount: 200, totalAmount: 200,
}, },
], ],
@ -48,20 +50,31 @@ export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
description: 'Service A', description: 'Service A',
lookupKey: 'service-a', lookupKey: 'service-a',
quantity: 1, quantity: 1,
consumption: 100,
totalAmount: 100, totalAmount: 100,
}, },
{ {
currency: 'USD', currency: 'USD',
description: 'Service B', description: 'Backend streaming connections',
lookupKey: 'service-b', lookupKey: 'service-b',
quantity: 100, quantity: 324_000,
limit: 120, limit: 3_000_000,
consumption: 3_000_000,
totalAmount: 200, totalAmount: 200,
}, },
{
currency: 'USD',
description: 'Frontend traffic bundle',
lookupKey: 'frontend-traffic-bundle',
quantity: 0,
consumption: 2_345_239,
limit: 5_000_000,
totalAmount: 0,
},
], ],
}, },
{ {
status: 'unpaid', status: 'invoiced',
dueDate: '2023-09-15', dueDate: '2023-09-15',
invoiceDate: '2023-08-15', invoiceDate: '2023-08-15',
invoicePDF: 'https://example.com/invoice/2.pdf', invoicePDF: 'https://example.com/invoice/2.pdf',
@ -69,7 +82,7 @@ export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
totalAmount: 200, totalAmount: 200,
mainLines: [ mainLines: [
{ {
currency: 'USD', currency: 'EUR',
description: 'Service C', description: 'Service C',
lookupKey: 'service-c', lookupKey: 'service-c',
quantity: 1, quantity: 1,