feat: render server-side invoice pdf template using React server

This commit is contained in:
Ahmed Bouhuolia
2024-11-04 12:55:12 +02:00
parent 6687db4085
commit 51aec8d8b3
17 changed files with 787 additions and 70 deletions

View File

@@ -0,0 +1,343 @@
import { renderToString } from 'react-dom/server';
import {
PaperTemplate,
PaperTemplateProps,
PaperTemplateTotalBorder,
} from './PaperTemplate';
import { x } from '@xstyled/emotion';
import { Box } from '../lib/layout/Box';
import { Text } from '../lib/text/Text';
import { Stack } from '../lib/layout/Stack';
import { Group } from '../lib/layout/Group';
import {
DefaultPdfTemplateTerms,
DefaultPdfTemplateItemDescription,
DefaultPdfTemplateStatement,
DefaultPdfTemplateItemName,
DefaultPdfTemplateAddressBilledTo,
DefaultPdfTemplateAddressBilledFrom,
} from './_constants';
import { PaperTemplateLayout } from './PaperTemplateLayout';
import createCache from '@emotion/cache';
interface PapaerLine {
item?: string;
description?: string;
quantity?: string;
rate?: string;
total?: string;
}
interface PaperTax {
label: string;
amount: string;
}
export interface InvoicePaperTemplateProps extends PaperTemplateProps {
primaryColor?: string;
secondaryColor?: string;
showCompanyLogo?: boolean;
companyLogoUri?: string;
showInvoiceNumber?: boolean;
invoiceNumber?: string;
invoiceNumberLabel?: string;
showDateIssue?: boolean;
dateIssue?: string;
dateIssueLabel?: string;
showDueDate?: boolean;
dueDate?: string;
dueDateLabel?: string;
companyName?: string;
bigtitle?: string;
// Address
showCustomerAddress?: boolean;
customerAddress?: string;
showCompanyAddress?: boolean;
companyAddress?: string;
billedToLabel?: string;
// Entries
lineItemLabel?: string;
lineQuantityLabel?: string;
lineRateLabel?: string;
lineTotalLabel?: string;
// Totals
showTotal?: boolean;
totalLabel?: string;
total?: string;
showDiscount?: boolean;
discountLabel?: string;
discount?: string;
showSubtotal?: boolean;
subtotalLabel?: string;
subtotal?: string;
showPaymentMade?: boolean;
paymentMadeLabel?: string;
paymentMade?: string;
showTaxes?: boolean;
showDueAmount?: boolean;
showBalanceDue?: boolean;
balanceDueLabel?: string;
balanceDue?: string;
// Footer
termsConditionsLabel?: string;
showTermsConditions?: boolean;
termsConditions?: string;
statementLabel?: string;
showStatement?: boolean;
statement?: string;
lines?: Array<PapaerLine>;
taxes?: Array<PaperTax>;
}
export function InvoicePaperTemplate({
primaryColor,
secondaryColor,
companyName = 'Bigcapital Technology, Inc.',
showCompanyLogo = true,
companyLogoUri = '',
dueDate = 'September 3, 2024',
dueDateLabel = 'Date due',
showDueDate = true,
dateIssue = 'September 3, 2024',
dateIssueLabel = 'Date of issue',
showDateIssue = true,
// dateIssue,
invoiceNumberLabel = 'Invoice number',
invoiceNumber = '346D3D40-0001',
showInvoiceNumber = true,
// Address
showCustomerAddress = true,
customerAddress = DefaultPdfTemplateAddressBilledTo,
showCompanyAddress = true,
companyAddress = DefaultPdfTemplateAddressBilledFrom,
billedToLabel = 'Billed To',
// Entries
lineItemLabel = 'Item',
lineQuantityLabel = 'Qty',
lineRateLabel = 'Rate',
lineTotalLabel = 'Total',
totalLabel = 'Total',
subtotalLabel = 'Subtotal',
discountLabel = 'Discount',
paymentMadeLabel = 'Payment Made',
balanceDueLabel = 'Balance Due',
// Totals
showTotal = true,
showSubtotal = true,
showDiscount = true,
showTaxes = true,
showPaymentMade = true,
showDueAmount = true,
showBalanceDue = true,
total = '$662.75',
subtotal = '630.00',
discount = '0.00',
paymentMade = '100.00',
balanceDue = '$562.75',
// Footer paragraphs.
termsConditionsLabel = 'Terms & Conditions',
showTermsConditions = true,
termsConditions = DefaultPdfTemplateTerms,
lines = [
{
item: DefaultPdfTemplateItemName,
description: DefaultPdfTemplateItemDescription,
rate: '1',
quantity: '1000',
total: '$1000.00',
},
],
taxes = [
{ label: 'Sample Tax1 (4.70%)', amount: '11.75' },
{ label: 'Sample Tax2 (7.00%)', amount: '21.74' },
],
statementLabel = 'Statement',
showStatement = true,
statement = DefaultPdfTemplateStatement,
...props
}: InvoicePaperTemplateProps) {
return (
<PaperTemplate
primaryColor={primaryColor}
secondaryColor={secondaryColor}
{...props}
>
<Stack spacing={24}>
<Group align="start" spacing={10}>
<Stack flex={1}>
<PaperTemplate.BigTitle title={'Invoice'} />
<PaperTemplate.TermsList>
{showInvoiceNumber && (
<PaperTemplate.TermsItem label={invoiceNumberLabel}>
{invoiceNumber}
</PaperTemplate.TermsItem>
)}
{showDateIssue && (
<PaperTemplate.TermsItem label={dateIssueLabel}>
{dateIssue}
</PaperTemplate.TermsItem>
)}
{showDueDate && (
<PaperTemplate.TermsItem label={dueDateLabel}>
{dueDate}
</PaperTemplate.TermsItem>
)}
</PaperTemplate.TermsList>
</Stack>
{companyLogoUri && showCompanyLogo && (
<PaperTemplate.Logo logoUri={companyLogoUri} />
)}
</Group>
<PaperTemplate.AddressesGroup>
{showCompanyAddress && (
<PaperTemplate.Address>
<Box dangerouslySetInnerHTML={{ __html: companyAddress }} />
</PaperTemplate.Address>
)}
{showCustomerAddress && (
<PaperTemplate.Address>
<strong>{billedToLabel}</strong>
<Box dangerouslySetInnerHTML={{ __html: customerAddress }} />
</PaperTemplate.Address>
)}
</PaperTemplate.AddressesGroup>
<Stack spacing={0}>
<PaperTemplate.Table
columns={[
{
label: lineItemLabel,
accessor: (data) => (
<Stack spacing={2}>
<Text>{data.item}</Text>
<Text
// variant={'muted'}
// style={{ fontSize: 12 }}
>
{data.description}
</Text>
</Stack>
),
},
{ label: lineQuantityLabel, accessor: 'quantity' },
{ label: lineRateLabel, accessor: 'rate', align: 'right' },
{ label: lineTotalLabel, accessor: 'total', align: 'right' },
]}
data={lines}
/>
<PaperTemplate.Totals>
{showSubtotal && (
<PaperTemplate.TotalLine
label={subtotalLabel}
amount={subtotal}
border={PaperTemplateTotalBorder.Gray}
/>
)}
{showDiscount && (
<PaperTemplate.TotalLine
label={discountLabel}
amount={discount}
/>
)}
{showTaxes && (
<>
{taxes.map((tax, index) => (
<PaperTemplate.TotalLine
key={index}
label={tax.label}
amount={tax.amount}
/>
))}
</>
)}
{showTotal && (
<PaperTemplate.TotalLine
label={totalLabel}
amount={total}
border={PaperTemplateTotalBorder.Dark}
style={{ fontWeight: 500 }}
/>
)}
{showPaymentMade && (
<PaperTemplate.TotalLine
label={paymentMadeLabel}
amount={paymentMade}
/>
)}
{showBalanceDue && (
<PaperTemplate.TotalLine
label={balanceDueLabel}
amount={balanceDue}
border={PaperTemplateTotalBorder.Dark}
style={{ fontWeight: 500 }}
/>
)}
</PaperTemplate.Totals>
</Stack>
<Stack spacing={0}>
{showTermsConditions && termsConditions && (
<PaperTemplate.Statement label={termsConditionsLabel}>
{termsConditions}
</PaperTemplate.Statement>
)}
{showStatement && statement && (
<PaperTemplate.Statement label={statementLabel}>
{statement}
</PaperTemplate.Statement>
)}
</Stack>
</Stack>
</PaperTemplate>
);
}
export const renderInvoicePaperTemplateHtml = (
props: InvoicePaperTemplateProps
) => {
const key = 'custom';
const cache = createCache({ key });
return renderToString(
<PaperTemplateLayout cache={cache}>
<InvoicePaperTemplate {...props} />
</PaperTemplateLayout>
);
};

View File

@@ -0,0 +1,242 @@
import React from 'react';
import clsx from 'classnames';
import { get, isFunction } from 'lodash';
import { x } from '@xstyled/emotion';
import { Box, BoxProps } from '../lib/layout/Box';
import { Group, GroupProps } from '../lib/layout/Group';
const styles = {
root: 'root',
bigTitle: 'bigTitle',
logoWrap: 'logoWrap',
logoImg: 'logoImg',
table: 'table',
tableBody: 'tableBody',
totals: 'totals',
totalsItem: 'totalsItem',
totalBottomBordered: 'totalBottomBordered',
totalBottomGrayBordered: 'totalBottomGrayBordered',
totalsItemLabel: 'totalsItemLabel',
totalsItemAmount: 'totalsItemAmount',
addressRoot: 'addressRoot',
paragraph: 'paragraph',
paragraphLabel: 'paragraphLabel',
details: 'details',
detail: 'detail',
detailLabel: 'detailLabel',
};
export interface PaperTemplateProps extends BoxProps {
primaryColor?: string;
secondaryColor?: string;
children?: React.ReactNode;
}
export function PaperTemplate({
primaryColor,
secondaryColor,
children,
...restProps
}: PaperTemplateProps) {
return (
<Box
borderRadius="5px"
backgroundColor="#fff"
color="#111"
boxShadow="inset 0 4px 0px 0 var(--invoice-primary-color), 0 10px 15px rgba(0, 0, 0, 0.05)"
padding="30px 30px"
fontSize="12px"
position="relative"
m="0 auto"
h="1123px"
w="794px"
{...restProps}
className={clsx(styles.root, restProps?.className)}
>
<style>{`:root { --invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor}; }`}</style>
{children}
</Box>
);
}
interface PaperTemplateTableProps {
columns: Array<{
accessor: string | ((data: Record<string, any>) => JSX.Element);
label: string;
value?: JSX.Element;
align?: 'left' | 'center' | 'right';
}>;
data: Array<Record<string, any>>;
}
interface PaperTemplateBigTitleProps {
title: string;
}
PaperTemplate.BigTitle = ({ title }: PaperTemplateBigTitleProps) => {
return (
<x.h1
style={{
fontSize: '30px',
margin: 0,
lineHeight: 1,
fontWeight: 500,
color: '#333',
}}
className={styles.bigTitle}
>
{title}
</x.h1>
);
};
interface PaperTemplateLogoProps {
logoUri: string;
}
PaperTemplate.Logo = ({ logoUri }: PaperTemplateLogoProps) => {
return (
<div className={styles.logoWrap}>
<img className={styles.logoImg} alt="" src={logoUri} />
</div>
);
};
PaperTemplate.Table = ({ columns, data }: PaperTemplateTableProps) => {
return (
<table className={styles.table}>
<thead>
<tr>
{columns.map((col, index) => (
<th key={index} align={col.align}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className={styles.tableBody}>
{data.map((_data: any) => (
<tr>
{columns.map((column, index) => (
<td align={column.align} key={index}>
{isFunction(column?.accessor)
? column?.accessor(_data)
: get(_data, column.accessor)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
};
export enum PaperTemplateTotalBorder {
Gray = 'gray',
Dark = 'dark',
}
PaperTemplate.Totals = ({ children }: { children: React.ReactNode }) => {
return (
<x.div
style={{
display: 'flex',
flexDirection: 'column',
marginLeft: 'auto',
width: '300px',
}}
>
{children}
</x.div>);
};
PaperTemplate.TotalLine = ({
label,
amount,
border,
style,
}: {
label: string;
amount: string;
border?: PaperTemplateTotalBorder;
style?: any;
}) => {
return (
<x.div
display={'flex'}
padding={'4px 0'}
className={clsx(styles.totalsItem, {
[styles.totalBottomBordered]: border === PaperTemplateTotalBorder.Dark,
[styles.totalBottomGrayBordered]:
border === PaperTemplateTotalBorder.Gray,
})}
style={style}
>
<x.div min-w="160px">{label}</x.div>
<x.div flex={'1 1 auto'} textAlign={'right'}>{amount}</x.div>
</x.div>
);
};
PaperTemplate.MutedText = () => { };
PaperTemplate.Text = () => { };
PaperTemplate.AddressesGroup = (props: GroupProps) => {
return (
<Group
spacing={10}
align={'flex-start'}
{...props}
className={styles.addressRoot}
/>
);
};
PaperTemplate.Address = ({ children }: { children: React.ReactNode }) => {
return <Box>{children}</Box>;
};
PaperTemplate.Statement = ({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) => {
return (
<div className={styles.paragraph}>
{label && <div className={styles.paragraphLabel}>{label}</div>}
<div>{children}</div>
</div>
);
};
PaperTemplate.TermsList = ({ children }: { children: React.ReactNode }) => {
return (
<x.div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
{children}
</x.div>
);
};
PaperTemplate.TermsItem = ({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) => {
return (
<x.div style={{ display: 'flex', flexDirection: 'row', gap: '12px' }}>
<x.div style={{ minWidth: '120px', color: '#333' }}>{label}</x.div>
<x.div>{children}</x.div>
</x.div>
);
};

View File

@@ -0,0 +1,25 @@
import { CacheProvider, ThemeProvider } from '@emotion/react';
import { EmotionCache } from '@emotion/cache';
import { defaultTheme } from '@xstyled/system';
const theme = {
...defaultTheme,
};
export function PaperTemplateLayout({ cache, children }: {
children: React.ReactNode;
cache: EmotionCache;
}) {
const html = (
<CacheProvider value={cache}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</CacheProvider>
);
return (
<html lang="en">
<body>
<div id="root">{html}</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,25 @@
export const DefaultPdfTemplateTerms =
'All services provided are non-refundable. For any disputes, please contact us within 7 days of receiving this invoice.';
export const DefaultPdfTemplateStatement =
'Thank you for your business. We look forward to working with you again!';
export const DefaultPdfTemplateItemName = 'Web development';
export const DefaultPdfTemplateItemDescription =
'Website development with content and SEO optimization';
export const DefaultPdfTemplateAddressBilledTo = `Bigcapital Technology, Inc.<br />
131 Continental Dr, <br />
Suite 305, <br />
Newark, Delaware 19713, <br />
United States,<br />
+1 762-339-5634
`;
export const DefaultPdfTemplateAddressBilledFrom = `131 Continental Dr Suite 305 Newark, <br />
Delaware 19713,<br />
United States, <br />
+1 762-339-5634, <br />
ahmed@bigcapital.app
`;