1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

feat: update invoice billing components (#10740)

This commit is contained in:
Tymoteusz Czech 2025-10-07 10:35:40 +02:00 committed by GitHub
parent ce1fb144d7
commit 3393bb35e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 238 additions and 77 deletions

View File

@ -0,0 +1,15 @@
import { styled } from '@mui/material';
export const StyledSubgrid = styled('div', {
shouldForwardProp: (prop) => prop !== 'withBackground',
})<{ withBackground?: boolean }>(({ theme, withBackground }) => ({
display: 'grid',
gridTemplateColumns: 'subgrid',
gridColumn: '1 / -1',
background: withBackground
? theme.palette.background.elevation1
: 'transparent',
margin: theme.spacing(0.25, 0),
padding: theme.spacing(0, 2),
borderRadius: theme.shape.borderRadiusLarge,
}));

View File

@ -9,31 +9,10 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { formatCurrency } from './types.ts';
import { Badge } from 'component/common/Badge/Badge.tsx';
import type { FC, ReactNode } from 'react';
import type { DetailedInvoicesResponseSchemaInvoicesItem } from 'openapi/index.ts';
import { BillingInvoiceRow } from './BillingInvoiceRow/BillingInvoiceRow.tsx';
export type BillingInvoiceSectionItem = {
description: string;
quantity?: number;
amount?: number;
quota?: number;
};
type BillingInvoiceSection = {
id: string;
title?: string;
items: BillingInvoiceSectionItem[];
summary?: {
subtotal: number;
taxExemptNote?: string;
total: number;
};
};
type BillingInvoiceProps = {
title: string;
status?: 'estimate' | 'upcoming' | 'invoiced';
sections?: BillingInvoiceSection[];
};
import { BillingInvoiceFooter } from './BillingInvoiceFooter/BillingInvoiceFooter.tsx';
import { StyledSubgrid } from './BillingInvoice.styles.tsx';
const CardLikeAccordion = styled(Accordion)(({ theme }) => ({
background: theme.palette.background.paper,
@ -78,19 +57,6 @@ const StyledInvoiceGrid = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 2, 3),
}));
const StyledSubgrid = styled('div', {
shouldForwardProp: (prop) => prop !== 'withBackground',
})<{ withBackground?: boolean }>(({ theme, withBackground }) => ({
display: 'grid',
gridTemplateColumns: 'subgrid',
gridColumn: '1 / -1',
background: withBackground
? theme.palette.background.elevation1
: 'transparent',
padding: theme.spacing(0.25, 2),
borderRadius: theme.shape.borderRadiusLarge,
}));
const HeaderCell = styled(Typography)(({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
fontWeight: theme.typography.fontWeightMedium,
@ -111,14 +77,14 @@ const StyledSectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
}));
export const StyledTableRow = styled('div')(({ theme }) => ({
const StyledTableRow = styled('div')(({ theme }) => ({
display: 'grid',
gridColumn: '1 / -1',
gridTemplateColumns: 'subgrid',
padding: theme.spacing(1, 0),
}));
const sectionsMock: BillingInvoiceSection[] = [
const sectionsMock = [
{
id: 'seats',
items: [
@ -174,19 +140,20 @@ const sectionsMock: BillingInvoiceSection[] = [
];
export const BillingInvoice = ({
title,
status,
sections = sectionsMock,
}: BillingInvoiceProps) => {
const total = sections.reduce(
(acc, section) =>
acc +
section.items.reduce(
(itemAcc, item) => itemAcc + (item.amount || 0),
0,
),
0,
);
dueDate,
invoiceDate,
invoicePDF,
invoiceURL,
totalAmount,
lines,
}: DetailedInvoicesResponseSchemaInvoicesItem) => {
const title = invoiceDate
? new Date(invoiceDate).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
})
: '';
return (
<CardLikeAccordion defaultExpanded>
@ -214,7 +181,7 @@ export const BillingInvoice = ({
<Badge color='success'>Invoiced</Badge>
) : null}
<Typography variant='body1' sx={{ fontWeight: 700 }}>
{formatCurrency(total)}
{formatCurrency(totalAmount)}
</Typography>
</HeaderRight>
</HeaderRoot>
@ -231,20 +198,29 @@ export const BillingInvoice = ({
<HeaderCell>Quantity</HeaderCell>
<HeaderCell>Amount</HeaderCell>
</StyledSubgrid>
{sections.map((section) => (
<TableBody key={section.id} title={section.title}>
{section.title ? (
{lines.map((line) => (
<TableBody
key={line.description}
// TODO: split into "usage" category
title={line.description}
>
{/* {line.description ? (
<StyledSectionTitle>
{section.title}
{line.description}
</StyledSectionTitle>
) : null}
{section.items.map((item) => (
<StyledTableRow key={item.description}>
<BillingInvoiceRow item={item} />
</StyledTableRow>
))}
) : null} */}
<StyledTableRow key={line.description}>
<BillingInvoiceRow
description={line.description}
quota={line.limit}
quantity={line.quantity}
amount={line.totalAmount}
/>
</StyledTableRow>
</TableBody>
))}
<BillingInvoiceFooter totalAmount={totalAmount} />
</StyledInvoiceGrid>
</AccordionDetails>
</CardLikeAccordion>

View File

@ -0,0 +1,85 @@
import type { FC } from 'react';
import { styled } from '@mui/material';
import { formatCurrency } from '../types.ts';
import { StyledSubgrid } from '../BillingInvoice.styles.tsx';
const StyledTableFooter = styled(StyledSubgrid)(({ theme }) => ({
gridColumn: '3 / -1',
padding: theme.spacing(1, 0, 0),
}));
const StyledTableFooterRow = styled('div')<{ last?: boolean }>(
({ theme, last }) => ({
marginRight: theme.spacing(1),
display: 'grid',
gridColumn: '1 / -1',
gridTemplateColumns: 'subgrid',
...(last
? { fontWeight: theme.typography.fontWeightBold }
: { borderBottom: `1px solid ${theme.palette.divider}` }),
}),
);
const StyledTableFooterCell = styled('div', {
shouldForwardProp: (prop) => prop !== 'colSpan',
})<{ colSpan?: number }>(({ theme, colSpan }) => ({
padding: theme.spacing(1, 0),
...(colSpan ? { gridColumn: `span ${colSpan}` } : {}),
}));
interface BillingInvoiceFooterProps {
subTotal?: number;
taxAmount?: number;
totalAmount: number;
}
const TaxRow: FC<{ value?: number | null }> = ({ value }) => {
if (value === undefined) {
return null;
}
if (value === null) {
return (
<StyledTableFooterCell colSpan={2}>
Customer tax is exempt
</StyledTableFooterCell>
);
}
return (
<>
<StyledTableFooterCell>Tax</StyledTableFooterCell>
<StyledTableFooterCell>
{formatCurrency(value)}
</StyledTableFooterCell>
</>
);
};
export const BillingInvoiceFooter = ({
subTotal,
taxAmount,
totalAmount,
}: BillingInvoiceFooterProps) => {
return (
<StyledTableFooter>
{subTotal ? (
<StyledTableFooterRow>
<StyledTableFooterCell>Sub total</StyledTableFooterCell>
<StyledTableFooterCell>
{formatCurrency(subTotal)}
</StyledTableFooterCell>
</StyledTableFooterRow>
) : null}
<StyledTableFooterRow>
<TaxRow value={taxAmount} />
</StyledTableFooterRow>
<StyledTableFooterRow last>
<StyledTableFooterCell>Total</StyledTableFooterCell>
<StyledTableFooterCell>
{formatCurrency(totalAmount)}
</StyledTableFooterCell>
</StyledTableFooterRow>
</StyledTableFooter>
);
};

View File

@ -1,7 +1,6 @@
import { formatLargeNumbers } from 'component/impact-metrics/metricsFormatters.ts';
import { formatCurrency } from '../types.ts';
import { ConsumptionIndicator } from '../ConsumptionIndicator/ConsumptionIndicator.tsx';
import type { BillingInvoiceSectionItem } from '../BillingInvoice.tsx';
import { styled } from '@mui/material';
const StyledCellWithIndicator = styled('div')(({ theme }) => ({
@ -10,33 +9,39 @@ const StyledCellWithIndicator = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
}));
type BillingInvoiceRowProps = {
description: string;
quantity?: number;
amount?: number;
quota?: number;
};
export const BillingInvoiceRow = ({
item,
}: { item: BillingInvoiceSectionItem }) => {
const usage = item.quantity || 0;
quantity,
amount,
quota,
description,
}: BillingInvoiceRowProps) => {
const usage = quantity || 0;
const percentage =
item.quota && item.quota > 0
? Math.min(100, Math.round((usage / item.quota) * 100))
quota && quota > 0
? Math.min(100, Math.round((usage / quota) * 100))
: undefined;
return (
<>
<div>{item.description}</div>
<div>{description}</div>
<StyledCellWithIndicator>
{percentage !== undefined && (
<ConsumptionIndicator percentage={percentage} />
)}
{item.quota !== undefined
? formatLargeNumbers(item.quota)
: ''}
{quota !== undefined ? formatLargeNumbers(quota) : ''}
{percentage !== undefined ? ` (${percentage}%)` : ''}
</StyledCellWithIndicator>
<div>
{item.quantity !== undefined
? formatLargeNumbers(item.quantity)
: ''}
{quantity !== undefined ? formatLargeNumbers(quantity) : ''}
</div>
<div>{formatCurrency(item.amount || 0)}</div>
<div>{formatCurrency(amount || 0)}</div>
</>
);
};

View File

@ -1,6 +1,7 @@
import { Box, styled, Typography } from '@mui/material';
import type { FC } from 'react';
import { BillingInvoice } from './BillingInvoice/BillingInvoice.tsx';
import { useDetailedInvoices } from 'hooks/api/getters/useDetailedInvoices/useDetailedInvoices.ts';
const StyledContainer = styled(Box)(({ theme }) => ({
display: 'flex',
@ -18,10 +19,14 @@ const StyledHeader = styled(Typography)(({ theme }) => ({
type BillingInvoicesProps = {};
export const BillingInvoices: FC<BillingInvoicesProps> = () => {
const { invoices } = useDetailedInvoices();
return (
<StyledContainer>
<StyledHeader>Usage and invoices</StyledHeader>
<BillingInvoice status='upcoming' title='October 15th' />
{invoices.map((invoice) => (
<BillingInvoice key={invoice.invoiceDate} {...invoice} />
))}
</StyledContainer>
);
};

View File

@ -0,0 +1,75 @@
import useSWR, { type SWRConfiguration } from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js';
import type { DetailedInvoicesResponseSchema } from 'openapi';
const KEY = `api/admin/invoices/list`;
const path = formatApiPath(KEY);
export const useDetailedInvoices = (options: SWRConfiguration = {}) => {
const fetcher = () =>
fetch(path, { method: 'GET' })
.then(handleErrorResponses('Detailed invoices'))
.then((res) => res.json());
const { data, error, isLoading } = useSWR<DetailedInvoicesResponseSchema>(
KEY,
fetcher,
options,
);
const invoices = useMemo(() => data?.invoices ?? [], [data]);
// return { invoices, error, loading: isLoading };
return {
invoices: [
// TODO:MOCK
{
status: 'paid',
dueDate: '2023-09-01',
invoiceDate: '2023-08-01',
invoicePDF: 'https://example.com/invoice/1.pdf',
invoiceURL: 'https://example.com/invoice/1',
totalAmount: 100,
lines: [
{
currency: 'USD',
description: 'Service A',
lookupKey: 'service-a',
quantity: 1,
totalAmount: 100,
},
{
currency: 'USD',
description: 'Service B',
lookupKey: 'service-b',
quantity: 100,
limit: 120,
totalAmount: 200,
},
],
},
{
status: 'unpaid',
dueDate: '2023-09-15',
invoiceDate: '2023-08-15',
invoicePDF: 'https://example.com/invoice/2.pdf',
invoiceURL: 'https://example.com/invoice/2',
totalAmount: 200,
lines: [
{
currency: 'USD',
description: 'Service C',
lookupKey: 'service-c',
quantity: 1,
totalAmount: 200,
},
],
},
],
error,
loading: isLoading,
};
};