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:
parent
ce1fb144d7
commit
3393bb35e5
@ -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,
|
||||
}));
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user