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} /> | ||||
|                             ) : 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