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:
parent
fab5dc8725
commit
183d436e59
@ -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),
|
||||
}));
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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 || ''}`;
|
||||
};
|
||||
@ -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')}`;
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user