mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-16 12:50:38 +00:00
feat: render server-side invoice pdf template using React server
This commit is contained in:
343
shared/pdf-templates/src/components/InvoicePaperTemplate.tsx
Normal file
343
shared/pdf-templates/src/components/InvoicePaperTemplate.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
242
shared/pdf-templates/src/components/PaperTemplate.tsx
Normal file
242
shared/pdf-templates/src/components/PaperTemplate.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
shared/pdf-templates/src/components/PaperTemplateLayout.tsx
Normal file
25
shared/pdf-templates/src/components/PaperTemplateLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
shared/pdf-templates/src/components/_constants.ts
Normal file
25
shared/pdf-templates/src/components/_constants.ts
Normal 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
|
||||
`;
|
||||
Reference in New Issue
Block a user