1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-10 01:19:53 +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
: 'transparent',
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,
}));
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 {
Typography,
styled,
@ -6,13 +7,12 @@ import {
AccordionDetails,
} from '@mui/material';
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 type { FC, ReactNode } from 'react';
import type { DetailedInvoicesSchemaInvoicesItem } from 'openapi/index.ts';
import { BillingInvoiceRow } from './BillingInvoiceRow/BillingInvoiceRow.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 }) => ({
background: theme.palette.background.paper,
@ -73,7 +73,7 @@ const TableBody: FC<{ children: ReactNode; title?: string }> = ({
const StyledSectionTitle = styled(Typography)(({ theme }) => ({
gridColumn: '1 / -1',
padding: theme.spacing(2, 0),
padding: theme.spacing(2, 0, 1),
fontWeight: theme.fontWeight.bold,
}));
@ -84,69 +84,14 @@ const StyledTableRow = styled('div')(({ theme }) => ({
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 = ({
status,
dueDate,
invoiceDate,
invoicePDF,
invoiceURL,
totalAmount,
mainLines,
usageLines,
}: DetailedInvoicesSchemaInvoicesItem) => {
const title = invoiceDate
? new Date(invoiceDate).toLocaleDateString(undefined, {
@ -155,6 +100,8 @@ export const BillingInvoice = ({
})
: '';
const currency = mainLines[0]?.currency || usageLines?.[0]?.currency;
return (
<CardLikeAccordion defaultExpanded>
<HeaderRoot
@ -181,7 +128,7 @@ export const BillingInvoice = ({
<Badge color='success'>Invoiced</Badge>
) : null}
<Typography variant='body1' sx={{ fontWeight: 700 }}>
{formatCurrency(totalAmount)}
{formatCurrency(totalAmount, currency)}
</Typography>
</HeaderRight>
</HeaderRoot>
@ -196,31 +143,32 @@ export const BillingInvoice = ({
<HeaderCell>Description</HeaderCell>
<HeaderCell>Included</HeaderCell>
<HeaderCell>Quantity</HeaderCell>
<HeaderCell>Amount</HeaderCell>
<HeaderCell>
<StyledAmountCell>Amount</StyledAmountCell>
</HeaderCell>
</StyledSubgrid>
{mainLines.map((line) => (
<TableBody
key={line.description}
// TODO: split into "usage" category
title={line.description}
>
{/* {line.description ? (
<StyledSectionTitle>
{line.description}
</StyledSectionTitle>
) : null} */}
<TableBody key={line.description}>
<StyledTableRow key={line.description}>
<BillingInvoiceRow
description={line.description}
quota={line.limit}
quantity={line.quantity}
amount={line.totalAmount}
/>
<BillingInvoiceRow {...line} />
</StyledTableRow>
</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>
</AccordionDetails>
</CardLikeAccordion>

View File

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

View File

@ -1,7 +1,9 @@
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 { styled } from '@mui/material';
import type { DetailedInvoicesLineSchema } from 'openapi';
import { StyledAmountCell } from '../BillingInvoice.styles.tsx';
const StyledCellWithIndicator = styled('div')(({ theme }) => ({
display: 'flex',
@ -14,34 +16,34 @@ type BillingInvoiceRowProps = {
quantity?: number;
amount?: number;
quota?: number;
usage?: number;
};
export const BillingInvoiceRow = ({
quantity,
amount,
quota,
consumption,
limit,
description,
}: BillingInvoiceRowProps) => {
const usage = quantity || 0;
currency,
totalAmount,
}: DetailedInvoicesLineSchema) => {
const percentage =
quota && quota > 0
? Math.min(100, Math.round((usage / quota) * 100))
limit && limit > 0
? Math.min(100, Math.round(((consumption || 0) / limit) * 100))
: undefined;
return (
<>
<div>{description}</div>
<StyledCellWithIndicator>
{percentage !== undefined && (
<ConsumptionIndicator percentage={percentage} />
)}
{quota !== undefined ? formatLargeNumbers(quota) : ''}
<ConsumptionIndicator percentage={percentage || 0} />
{limit !== undefined ? formatLargeNumbers(limit) : ''}
{percentage !== undefined ? ` (${percentage}%)` : ''}
</StyledCellWithIndicator>
<div>
{quantity !== undefined ? formatLargeNumbers(quantity) : ''}
</div>
<div>{formatCurrency(amount || 0)}</div>
<div>{quantity ? formatLargeNumbers(quantity) : ''}</div>
<StyledAmountCell>
{formatCurrency(totalAmount || 0, currency)}
</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 {
invoices: [
// TODO:MOCK
// FIXME: MOCK
{
status: 'paid',
status: 'upcoming',
dueDate: '2023-09-01',
invoiceDate: '2023-08-01',
invoicePDF: 'https://example.com/invoice/1.pdf',
@ -38,7 +38,9 @@ export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
currency: 'USD',
description: 'Service C',
lookupKey: 'service-c',
quantity: 1,
quantity: 0,
consumption: 100,
limit: 120,
totalAmount: 200,
},
],
@ -48,20 +50,31 @@ export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
description: 'Service A',
lookupKey: 'service-a',
quantity: 1,
consumption: 100,
totalAmount: 100,
},
{
currency: 'USD',
description: 'Service B',
description: 'Backend streaming connections',
lookupKey: 'service-b',
quantity: 100,
limit: 120,
quantity: 324_000,
limit: 3_000_000,
consumption: 3_000_000,
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',
invoiceDate: '2023-08-15',
invoicePDF: 'https://example.com/invoice/2.pdf',
@ -69,7 +82,7 @@ export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
totalAmount: 200,
mainLines: [
{
currency: 'USD',
currency: 'EUR',
description: 'Service C',
lookupKey: 'service-c',
quantity: 1,