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 { formatCurrency } from './types.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 { FC, ReactNode } from 'react'; | ||||||
|  | import type { DetailedInvoicesResponseSchemaInvoicesItem } from 'openapi/index.ts'; | ||||||
| import { BillingInvoiceRow } from './BillingInvoiceRow/BillingInvoiceRow.tsx'; | import { BillingInvoiceRow } from './BillingInvoiceRow/BillingInvoiceRow.tsx'; | ||||||
| 
 | import { BillingInvoiceFooter } from './BillingInvoiceFooter/BillingInvoiceFooter.tsx'; | ||||||
| export type BillingInvoiceSectionItem = { | import { StyledSubgrid } from './BillingInvoice.styles.tsx'; | ||||||
|     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[]; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const CardLikeAccordion = styled(Accordion)(({ theme }) => ({ | const CardLikeAccordion = styled(Accordion)(({ theme }) => ({ | ||||||
|     background: theme.palette.background.paper, |     background: theme.palette.background.paper, | ||||||
| @ -78,19 +57,6 @@ const StyledInvoiceGrid = styled('div')(({ theme }) => ({ | |||||||
|     padding: theme.spacing(0, 2, 3), |     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 }) => ({ | const HeaderCell = styled(Typography)(({ theme }) => ({ | ||||||
|     fontSize: theme.typography.body2.fontSize, |     fontSize: theme.typography.body2.fontSize, | ||||||
|     fontWeight: theme.typography.fontWeightMedium, |     fontWeight: theme.typography.fontWeightMedium, | ||||||
| @ -111,14 +77,14 @@ const StyledSectionTitle = styled(Typography)(({ theme }) => ({ | |||||||
|     fontWeight: theme.fontWeight.bold, |     fontWeight: theme.fontWeight.bold, | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| export const StyledTableRow = styled('div')(({ theme }) => ({ | const StyledTableRow = styled('div')(({ theme }) => ({ | ||||||
|     display: 'grid', |     display: 'grid', | ||||||
|     gridColumn: '1 / -1', |     gridColumn: '1 / -1', | ||||||
|     gridTemplateColumns: 'subgrid', |     gridTemplateColumns: 'subgrid', | ||||||
|     padding: theme.spacing(1, 0), |     padding: theme.spacing(1, 0), | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| const sectionsMock: BillingInvoiceSection[] = [ | const sectionsMock = [ | ||||||
|     { |     { | ||||||
|         id: 'seats', |         id: 'seats', | ||||||
|         items: [ |         items: [ | ||||||
| @ -174,19 +140,20 @@ const sectionsMock: BillingInvoiceSection[] = [ | |||||||
| ]; | ]; | ||||||
| 
 | 
 | ||||||
| export const BillingInvoice = ({ | export const BillingInvoice = ({ | ||||||
|     title, |  | ||||||
|     status, |     status, | ||||||
|     sections = sectionsMock, |     dueDate, | ||||||
| }: BillingInvoiceProps) => { |     invoiceDate, | ||||||
|     const total = sections.reduce( |     invoicePDF, | ||||||
|         (acc, section) => |     invoiceURL, | ||||||
|             acc + |     totalAmount, | ||||||
|             section.items.reduce( |     lines, | ||||||
|                 (itemAcc, item) => itemAcc + (item.amount || 0), | }: DetailedInvoicesResponseSchemaInvoicesItem) => { | ||||||
|                 0, |     const title = invoiceDate | ||||||
|             ), |         ? new Date(invoiceDate).toLocaleDateString(undefined, { | ||||||
|         0, |               month: 'long', | ||||||
|     ); |               day: 'numeric', | ||||||
|  |           }) | ||||||
|  |         : ''; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <CardLikeAccordion defaultExpanded> |         <CardLikeAccordion defaultExpanded> | ||||||
| @ -214,7 +181,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(total)} |                         {formatCurrency(totalAmount)} | ||||||
|                     </Typography> |                     </Typography> | ||||||
|                 </HeaderRight> |                 </HeaderRight> | ||||||
|             </HeaderRoot> |             </HeaderRoot> | ||||||
| @ -231,20 +198,29 @@ export const BillingInvoice = ({ | |||||||
|                         <HeaderCell>Quantity</HeaderCell> |                         <HeaderCell>Quantity</HeaderCell> | ||||||
|                         <HeaderCell>Amount</HeaderCell> |                         <HeaderCell>Amount</HeaderCell> | ||||||
|                     </StyledSubgrid> |                     </StyledSubgrid> | ||||||
|                     {sections.map((section) => ( |                     {lines.map((line) => ( | ||||||
|                         <TableBody key={section.id} title={section.title}> |                         <TableBody | ||||||
|                             {section.title ? ( |                             key={line.description} | ||||||
|  |                             // TODO: split into "usage" category
 | ||||||
|  |                             title={line.description} | ||||||
|  |                         > | ||||||
|  |                             {/* {line.description ? ( | ||||||
|                                 <StyledSectionTitle> |                                 <StyledSectionTitle> | ||||||
|                                     {section.title} |                                     {line.description} | ||||||
|                                 </StyledSectionTitle> |                                 </StyledSectionTitle> | ||||||
|                             ) : null} |                             ) : null} */} | ||||||
|                             {section.items.map((item) => ( |                             <StyledTableRow key={line.description}> | ||||||
|                                 <StyledTableRow key={item.description}> |                                 <BillingInvoiceRow | ||||||
|                                     <BillingInvoiceRow item={item} /> |                                     description={line.description} | ||||||
|  |                                     quota={line.limit} | ||||||
|  |                                     quantity={line.quantity} | ||||||
|  |                                     amount={line.totalAmount} | ||||||
|  |                                 /> | ||||||
|                             </StyledTableRow> |                             </StyledTableRow> | ||||||
|                             ))} |  | ||||||
|                         </TableBody> |                         </TableBody> | ||||||
|                     ))} |                     ))} | ||||||
|  | 
 | ||||||
|  |                     <BillingInvoiceFooter totalAmount={totalAmount} /> | ||||||
|                 </StyledInvoiceGrid> |                 </StyledInvoiceGrid> | ||||||
|             </AccordionDetails> |             </AccordionDetails> | ||||||
|         </CardLikeAccordion> |         </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 { formatLargeNumbers } from 'component/impact-metrics/metricsFormatters.ts'; | ||||||
| import { formatCurrency } from '../types.ts'; | import { formatCurrency } from '../types.ts'; | ||||||
| import { ConsumptionIndicator } from '../ConsumptionIndicator/ConsumptionIndicator.tsx'; | import { ConsumptionIndicator } from '../ConsumptionIndicator/ConsumptionIndicator.tsx'; | ||||||
| import type { BillingInvoiceSectionItem } from '../BillingInvoice.tsx'; |  | ||||||
| import { styled } from '@mui/material'; | import { styled } from '@mui/material'; | ||||||
| 
 | 
 | ||||||
| const StyledCellWithIndicator = styled('div')(({ theme }) => ({ | const StyledCellWithIndicator = styled('div')(({ theme }) => ({ | ||||||
| @ -10,33 +9,39 @@ const StyledCellWithIndicator = styled('div')(({ theme }) => ({ | |||||||
|     gap: theme.spacing(1), |     gap: theme.spacing(1), | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
|  | type BillingInvoiceRowProps = { | ||||||
|  |     description: string; | ||||||
|  |     quantity?: number; | ||||||
|  |     amount?: number; | ||||||
|  |     quota?: number; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export const BillingInvoiceRow = ({ | export const BillingInvoiceRow = ({ | ||||||
|     item, |     quantity, | ||||||
| }: { item: BillingInvoiceSectionItem }) => { |     amount, | ||||||
|     const usage = item.quantity || 0; |     quota, | ||||||
|  |     description, | ||||||
|  | }: BillingInvoiceRowProps) => { | ||||||
|  |     const usage = quantity || 0; | ||||||
|     const percentage = |     const percentage = | ||||||
|         item.quota && item.quota > 0 |         quota && quota > 0 | ||||||
|             ? Math.min(100, Math.round((usage / item.quota) * 100)) |             ? Math.min(100, Math.round((usage / quota) * 100)) | ||||||
|             : undefined; |             : undefined; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             <div>{item.description}</div> |             <div>{description}</div> | ||||||
|             <StyledCellWithIndicator> |             <StyledCellWithIndicator> | ||||||
|                 {percentage !== undefined && ( |                 {percentage !== undefined && ( | ||||||
|                     <ConsumptionIndicator percentage={percentage} /> |                     <ConsumptionIndicator percentage={percentage} /> | ||||||
|                 )} |                 )} | ||||||
|                 {item.quota !== undefined |                 {quota !== undefined ? formatLargeNumbers(quota) : '–'} | ||||||
|                     ? formatLargeNumbers(item.quota) |  | ||||||
|                     : '–'} |  | ||||||
|                 {percentage !== undefined ? ` (${percentage}%)` : ''} |                 {percentage !== undefined ? ` (${percentage}%)` : ''} | ||||||
|             </StyledCellWithIndicator> |             </StyledCellWithIndicator> | ||||||
|             <div> |             <div> | ||||||
|                 {item.quantity !== undefined |                 {quantity !== undefined ? formatLargeNumbers(quantity) : '–'} | ||||||
|                     ? formatLargeNumbers(item.quantity) |  | ||||||
|                     : '–'} |  | ||||||
|             </div> |             </div> | ||||||
|             <div>{formatCurrency(item.amount || 0)}</div> |             <div>{formatCurrency(amount || 0)}</div> | ||||||
|         </> |         </> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| import { Box, styled, Typography } from '@mui/material'; | import { Box, styled, Typography } from '@mui/material'; | ||||||
| import type { FC } from 'react'; | import type { FC } from 'react'; | ||||||
| import { BillingInvoice } from './BillingInvoice/BillingInvoice.tsx'; | import { BillingInvoice } from './BillingInvoice/BillingInvoice.tsx'; | ||||||
|  | import { useDetailedInvoices } from 'hooks/api/getters/useDetailedInvoices/useDetailedInvoices.ts'; | ||||||
| 
 | 
 | ||||||
| const StyledContainer = styled(Box)(({ theme }) => ({ | const StyledContainer = styled(Box)(({ theme }) => ({ | ||||||
|     display: 'flex', |     display: 'flex', | ||||||
| @ -18,10 +19,14 @@ const StyledHeader = styled(Typography)(({ theme }) => ({ | |||||||
| type BillingInvoicesProps = {}; | type BillingInvoicesProps = {}; | ||||||
| 
 | 
 | ||||||
| export const BillingInvoices: FC<BillingInvoicesProps> = () => { | export const BillingInvoices: FC<BillingInvoicesProps> = () => { | ||||||
|  |     const { invoices } = useDetailedInvoices(); | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|         <StyledContainer> |         <StyledContainer> | ||||||
|             <StyledHeader>Usage and invoices</StyledHeader> |             <StyledHeader>Usage and invoices</StyledHeader> | ||||||
|             <BillingInvoice status='upcoming' title='October 15th' /> |             {invoices.map((invoice) => ( | ||||||
|  |                 <BillingInvoice key={invoice.invoiceDate} {...invoice} /> | ||||||
|  |             ))} | ||||||
|         </StyledContainer> |         </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