mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
feat: remove start.sh script and add appearance preferences management
- Deleted the start.sh script for container management. - Added AGENTS.md for project guidelines and development principles. - Introduced new SQL migration files for user appearance preferences and platform settings. - Implemented appearance provider to manage user interface themes and preferences. - Created branding utility to define and manage branding-related constants and types. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
+53
-45
@@ -5,55 +5,63 @@ import { genericOAuth } from "better-auth/plugins";
|
||||
import { db } from "~/server/db";
|
||||
import * as schema from "~/server/db/schema";
|
||||
|
||||
const authentikEnabled = Boolean(
|
||||
process.env.AUTHENTIK_ISSUER &&
|
||||
process.env.AUTHENTIK_CLIENT_ID &&
|
||||
process.env.AUTHENTIK_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
user: schema.users,
|
||||
session: schema.sessions,
|
||||
account: schema.accounts,
|
||||
verification: schema.verificationTokens,
|
||||
ssoProvider: schema.ssoProviders,
|
||||
},
|
||||
}),
|
||||
trustedOrigins: [
|
||||
"https://beenvoice.soconnor.dev",
|
||||
"https://auth.soconnor.dev", // Authentik IdP for OIDC discovery
|
||||
],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
user: schema.users,
|
||||
session: schema.sessions,
|
||||
account: schema.accounts,
|
||||
verification: schema.verificationTokens,
|
||||
ssoProvider: schema.ssoProviders,
|
||||
},
|
||||
}),
|
||||
trustedOrigins: [
|
||||
"https://beenvoice.soconnor.dev",
|
||||
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
|
||||
],
|
||||
...(authentikEnabled && {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["authentik"],
|
||||
enabled: true,
|
||||
trustedProviders: ["authentik"],
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
password: {
|
||||
hash: async (password) => {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.hash(password, 12);
|
||||
},
|
||||
verify: async ({ hash, password }) => {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.compare(password, hash);
|
||||
},
|
||||
},
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
password: {
|
||||
hash: async (password) => {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.hash(password, 12);
|
||||
},
|
||||
verify: async ({ hash, password }) => {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.compare(password, hash);
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
nextCookies(),
|
||||
genericOAuth({
|
||||
},
|
||||
plugins: [
|
||||
nextCookies(),
|
||||
...(authentikEnabled
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
||||
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
|
||||
// Explicit endpoints to ensure correct routing in production
|
||||
authorizationUrl: "https://auth.soconnor.dev/application/o/authorize/",
|
||||
tokenUrl: "https://auth.soconnor.dev/application/o/token/",
|
||||
userInfoUrl: "https://auth.soconnor.dev/application/o/userinfo/",
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
},
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
||||
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { env } from "~/env";
|
||||
|
||||
export type InterfaceTheme = "beenvoice" | "shadcn" | "minimal" | "editorial";
|
||||
export type FontPreference = "brand" | "platform" | "inter" | "serif";
|
||||
export type RadiusPreference = "none" | "sm" | "md" | "lg" | "xl";
|
||||
export type SidebarStyle = "floating" | "docked";
|
||||
export type ColorMode = "light" | "dark" | "system";
|
||||
export type ColorTheme =
|
||||
| "slate"
|
||||
| "blue"
|
||||
| "green"
|
||||
| "rose"
|
||||
| "orange"
|
||||
| "custom";
|
||||
|
||||
export const interfaceThemes: {
|
||||
value: InterfaceTheme;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "beenvoice",
|
||||
label: "beenvoice",
|
||||
description: "Opinionated brand system with expressive headings.",
|
||||
},
|
||||
{
|
||||
value: "shadcn",
|
||||
label: "shadcn/ui",
|
||||
description: "A plain shadcn baseline for white-label starts.",
|
||||
},
|
||||
{
|
||||
value: "minimal",
|
||||
label: "Minimal",
|
||||
description: "Quiet surfaces, lower contrast, and restrained chrome.",
|
||||
},
|
||||
{
|
||||
value: "editorial",
|
||||
label: "Editorial",
|
||||
description: "A warmer presentation style for service-led brands.",
|
||||
},
|
||||
];
|
||||
|
||||
export const themePresets: Record<
|
||||
InterfaceTheme,
|
||||
{
|
||||
interfaceTheme: InterfaceTheme;
|
||||
bodyFontPreference: FontPreference;
|
||||
headingFontPreference: FontPreference;
|
||||
colorTheme: ColorTheme;
|
||||
radiusPreference: RadiusPreference;
|
||||
sidebarStyle: SidebarStyle;
|
||||
}
|
||||
> = {
|
||||
beenvoice: {
|
||||
interfaceTheme: "beenvoice",
|
||||
bodyFontPreference: "brand",
|
||||
headingFontPreference: "brand",
|
||||
colorTheme: "slate",
|
||||
radiusPreference: "xl",
|
||||
sidebarStyle: "floating",
|
||||
},
|
||||
shadcn: {
|
||||
interfaceTheme: "shadcn",
|
||||
bodyFontPreference: "inter",
|
||||
headingFontPreference: "inter",
|
||||
colorTheme: "slate",
|
||||
radiusPreference: "md",
|
||||
sidebarStyle: "docked",
|
||||
},
|
||||
minimal: {
|
||||
interfaceTheme: "minimal",
|
||||
bodyFontPreference: "platform",
|
||||
headingFontPreference: "platform",
|
||||
colorTheme: "slate",
|
||||
radiusPreference: "sm",
|
||||
sidebarStyle: "docked",
|
||||
},
|
||||
editorial: {
|
||||
interfaceTheme: "editorial",
|
||||
bodyFontPreference: "platform",
|
||||
headingFontPreference: "serif",
|
||||
colorTheme: "rose",
|
||||
radiusPreference: "lg",
|
||||
sidebarStyle: "floating",
|
||||
},
|
||||
};
|
||||
|
||||
export const fontPreferences: {
|
||||
value: FontPreference;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "brand",
|
||||
label: "Brand",
|
||||
description: "Inter body with Playfair headings.",
|
||||
},
|
||||
{
|
||||
value: "platform",
|
||||
label: "Platform",
|
||||
description: "Native system fonts for the current OS.",
|
||||
},
|
||||
{
|
||||
value: "inter",
|
||||
label: "Inter",
|
||||
description: "Inter for both body and headings.",
|
||||
},
|
||||
{
|
||||
value: "serif",
|
||||
label: "Editorial",
|
||||
description: "Serif headings with system body text.",
|
||||
},
|
||||
];
|
||||
|
||||
export const bodyFontPreferences: {
|
||||
value: FontPreference;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "brand",
|
||||
label: "Brand Sans",
|
||||
description: "Inter body text for a clean product feel.",
|
||||
},
|
||||
{
|
||||
value: "platform",
|
||||
label: "Platform",
|
||||
description: "Native system body text for the current OS.",
|
||||
},
|
||||
{
|
||||
value: "inter",
|
||||
label: "Inter",
|
||||
description: "Inter body text, explicitly selected.",
|
||||
},
|
||||
{
|
||||
value: "serif",
|
||||
label: "Serif",
|
||||
description: "Georgia-style body text for editorial deployments.",
|
||||
},
|
||||
];
|
||||
|
||||
export const headingFontPreferences: {
|
||||
value: FontPreference;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "brand",
|
||||
label: "Brand Serif",
|
||||
description: "Playfair headings for the BeenVoice identity.",
|
||||
},
|
||||
{
|
||||
value: "platform",
|
||||
label: "Platform",
|
||||
description: "Native system headings for a neutral app feel.",
|
||||
},
|
||||
{
|
||||
value: "inter",
|
||||
label: "Inter",
|
||||
description: "Inter headings for a plain shadcn-style baseline.",
|
||||
},
|
||||
{
|
||||
value: "serif",
|
||||
label: "Editorial",
|
||||
description: "Playfair headings with a stronger editorial tone.",
|
||||
},
|
||||
];
|
||||
|
||||
export const radiusPreferences: {
|
||||
value: RadiusPreference;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ value: "none", label: "Square", description: "No rounded corners." },
|
||||
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
|
||||
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
|
||||
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
|
||||
{
|
||||
value: "xl",
|
||||
label: "Extra Large",
|
||||
description: "Expressive 16px rounding.",
|
||||
},
|
||||
];
|
||||
|
||||
export const sidebarStyles: {
|
||||
value: SidebarStyle;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "floating",
|
||||
label: "Floating",
|
||||
description: "Inset navigation with rounded edges and elevation.",
|
||||
},
|
||||
{
|
||||
value: "docked",
|
||||
label: "Flush",
|
||||
description: "Full-height navigation aligned to the viewport edge.",
|
||||
},
|
||||
];
|
||||
|
||||
export const colorThemes: {
|
||||
value: ColorTheme;
|
||||
label: string;
|
||||
swatch: string;
|
||||
}[] = [
|
||||
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
|
||||
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
|
||||
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
|
||||
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
|
||||
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
|
||||
];
|
||||
|
||||
export const colorModes: {
|
||||
value: ColorMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", description: "Follow device setting." },
|
||||
{ value: "light", label: "Light", description: "Always use light mode." },
|
||||
{ value: "dark", label: "Dark", description: "Always use dark mode." },
|
||||
];
|
||||
|
||||
export const defaultInterfaceTheme: InterfaceTheme =
|
||||
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice";
|
||||
|
||||
export const defaultFontPreference: FontPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand";
|
||||
|
||||
export const defaultBodyFontPreference: FontPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
|
||||
|
||||
export const defaultHeadingFontPreference: FontPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
|
||||
|
||||
export const defaultRadiusPreference: RadiusPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? "xl";
|
||||
|
||||
export const defaultSidebarStyle: SidebarStyle =
|
||||
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating";
|
||||
|
||||
export const brand = {
|
||||
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
|
||||
tagline:
|
||||
env.NEXT_PUBLIC_BRAND_TAGLINE ??
|
||||
"Simple and efficient invoicing for freelancers and small businesses",
|
||||
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
|
||||
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
|
||||
};
|
||||
+148
-42
@@ -118,6 +118,26 @@ interface InvoiceData {
|
||||
} | null> | null;
|
||||
}
|
||||
|
||||
export interface PDFGenerationSettings {
|
||||
pdfTemplate?: "classic" | "minimal";
|
||||
pdfAccentColor?: string;
|
||||
pdfFooterText?: string;
|
||||
pdfShowLogo?: boolean;
|
||||
pdfShowPageNumbers?: boolean;
|
||||
}
|
||||
|
||||
const defaultPDFSettings: Required<PDFGenerationSettings> = {
|
||||
pdfTemplate: "classic",
|
||||
pdfAccentColor: "#111827",
|
||||
pdfFooterText: "Professional Invoicing",
|
||||
pdfShowLogo: true,
|
||||
pdfShowPageNumbers: true,
|
||||
};
|
||||
|
||||
function resolvePDFSettings(settings?: PDFGenerationSettings) {
|
||||
return { ...defaultPDFSettings, ...settings };
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "column",
|
||||
@@ -668,11 +688,14 @@ function getColumnWidths(showRate: boolean) {
|
||||
}
|
||||
|
||||
// Dense header component (first page)
|
||||
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||
const DenseHeader: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, settings }) => (
|
||||
<View style={styles.denseHeader}>
|
||||
<View style={styles.headerTop}>
|
||||
<View style={styles.businessSection}>
|
||||
<Text style={styles.businessName}>
|
||||
<Text style={[styles.businessName, { color: settings.pdfAccentColor }]}>
|
||||
{invoice.business?.name ?? "Your Business Name"}
|
||||
</Text>
|
||||
{invoice.business?.email && (
|
||||
@@ -708,7 +731,9 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||
</View>
|
||||
|
||||
<View style={styles.invoiceSection}>
|
||||
<Text style={styles.invoiceTitle}>INVOICE</Text>
|
||||
<Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}>
|
||||
INVOICE
|
||||
</Text>
|
||||
<Text style={styles.invoiceNumber}>
|
||||
{invoice.invoicePrefix ?? "#"}
|
||||
{invoice.invoiceNumber}
|
||||
@@ -771,9 +796,14 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||
);
|
||||
|
||||
// Abridged header component (other pages)
|
||||
const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||
const AbridgedHeader: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, settings }) => (
|
||||
<View style={styles.abridgedHeader}>
|
||||
<Text style={styles.abridgedBusinessName}>
|
||||
<Text
|
||||
style={[styles.abridgedBusinessName, { color: settings.pdfAccentColor }]}
|
||||
>
|
||||
{invoice.business?.name ?? "Your Business Name"}
|
||||
</Text>
|
||||
<View style={styles.abridgedInvoiceInfo}>
|
||||
@@ -787,19 +817,49 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||
);
|
||||
|
||||
// Table header component
|
||||
const TableHeader: React.FC<{ showRate: boolean }> = ({ showRate }) => {
|
||||
const TableHeader: React.FC<{
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
showRate: boolean;
|
||||
}> = ({ settings, showRate }) => {
|
||||
const cols = getColumnWidths(showRate);
|
||||
return (
|
||||
<View style={styles.tableHeader}>
|
||||
<View
|
||||
style={[
|
||||
styles.tableHeader,
|
||||
settings.pdfTemplate === "minimal" ? { backgroundColor: "#ffffff" } : {},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
|
||||
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
|
||||
Description
|
||||
</Text>
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours, { width: cols.hours }]}>Hours</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableHeaderCell,
|
||||
styles.tableHeaderHours,
|
||||
{ width: cols.hours },
|
||||
]}
|
||||
>
|
||||
Hours
|
||||
</Text>
|
||||
{showRate && (
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableHeaderCell,
|
||||
styles.tableHeaderRate,
|
||||
{ width: cols.rate },
|
||||
]}
|
||||
>
|
||||
Rate
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount, { width: cols.amount }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableHeaderCell,
|
||||
styles.tableHeaderAmount,
|
||||
{ width: cols.amount },
|
||||
]}
|
||||
>
|
||||
Amount
|
||||
</Text>
|
||||
</View>
|
||||
@@ -820,35 +880,40 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Footer: React.FC = () => (
|
||||
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
||||
settings,
|
||||
}) => (
|
||||
<View style={styles.footer} fixed>
|
||||
<View style={styles.footerLogo}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<Image
|
||||
src="/beenvoice-logo.png"
|
||||
style={{
|
||||
width: 120,
|
||||
height: 18,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{settings.pdfShowLogo && (
|
||||
<Image
|
||||
src="/beenvoice-logo.png"
|
||||
style={{
|
||||
width: 120,
|
||||
height: 18,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontFamily: "Frutiger",
|
||||
color: "#6b7280",
|
||||
marginLeft: 8,
|
||||
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
Professional Invoicing
|
||||
{settings.pdfFooterText}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`Page ${pageNumber} of ${totalPages}`
|
||||
}
|
||||
/>
|
||||
{settings.pdfShowPageNumbers && (
|
||||
<Text
|
||||
style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`Page ${pageNumber} of ${totalPages}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -856,7 +921,8 @@ const Footer: React.FC = () => (
|
||||
const TotalsSection: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||
}> = ({ invoice, items }) => {
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, items, settings }) => {
|
||||
const currency = invoice.currency ?? "USD";
|
||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
@@ -864,7 +930,18 @@ const TotalsSection: React.FC<{
|
||||
|
||||
return (
|
||||
<View style={styles.totalsContainer}>
|
||||
<View style={styles.totalsBox}>
|
||||
<View
|
||||
style={[
|
||||
styles.totalsBox,
|
||||
settings.pdfTemplate === "minimal"
|
||||
? {
|
||||
backgroundColor: "#ffffff",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
paddingHorizontal: 0,
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
@@ -896,7 +973,12 @@ const TotalsSection: React.FC<{
|
||||
|
||||
<View style={styles.finalTotalRow}>
|
||||
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
||||
<Text style={styles.finalTotalAmount}>
|
||||
<Text
|
||||
style={[
|
||||
styles.finalTotalAmount,
|
||||
{ color: settings.pdfAccentColor },
|
||||
]}
|
||||
>
|
||||
{formatCurrency(total, currency)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -910,7 +992,11 @@ const TotalsSection: React.FC<{
|
||||
};
|
||||
|
||||
// Main PDF component
|
||||
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
const InvoicePDF: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings?: PDFGenerationSettings;
|
||||
}> = ({ invoice, settings: inputSettings }) => {
|
||||
const settings = resolvePDFSettings(inputSettings);
|
||||
const items = invoice.items?.filter(Boolean) ?? [];
|
||||
const currency = invoice.currency ?? "USD";
|
||||
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
||||
@@ -928,15 +1014,15 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
|
||||
{/* Header */}
|
||||
{isFirstPage ? (
|
||||
<DenseHeader invoice={invoice} />
|
||||
<DenseHeader invoice={invoice} settings={settings} />
|
||||
) : (
|
||||
<AbridgedHeader invoice={invoice} />
|
||||
<AbridgedHeader invoice={invoice} settings={settings} />
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{hasItems && (
|
||||
<View style={styles.tableContainer}>
|
||||
<TableHeader showRate={showRate} />
|
||||
<TableHeader settings={settings} showRate={showRate} />
|
||||
{pageItems.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
@@ -944,7 +1030,9 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
key={`${pageIndex}-${index}`}
|
||||
style={[
|
||||
styles.tableRow,
|
||||
index % 2 === 0 ? styles.tableRowAlt : {},
|
||||
settings.pdfTemplate === "classic" && index % 2 === 0
|
||||
? styles.tableRowAlt
|
||||
: {},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}>
|
||||
@@ -963,7 +1051,13 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
{item.hours}
|
||||
</Text>
|
||||
{showRate && (
|
||||
<Text style={[styles.tableCell, styles.tableCellRate]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableCell,
|
||||
styles.tableCellRate,
|
||||
{ width: cols.rate },
|
||||
]}
|
||||
>
|
||||
{formatCurrency(item.rate, currency)}
|
||||
</Text>
|
||||
)}
|
||||
@@ -982,12 +1076,16 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
{isLastPage && (
|
||||
<View style={styles.bottomSection}>
|
||||
{invoice.notes && <NotesSection invoice={invoice} />}
|
||||
<TotalsSection invoice={invoice} items={items} />
|
||||
<TotalsSection
|
||||
invoice={invoice}
|
||||
items={items}
|
||||
settings={settings}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
<Footer settings={settings} />
|
||||
</Page>
|
||||
);
|
||||
})}
|
||||
@@ -996,7 +1094,10 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
};
|
||||
|
||||
// Export functions
|
||||
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
export async function generateInvoicePDF(
|
||||
invoice: InvoiceData,
|
||||
settings?: PDFGenerationSettings,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Validate invoice data
|
||||
if (!invoice) {
|
||||
@@ -1012,7 +1113,9 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
}
|
||||
|
||||
// Generate PDF blob
|
||||
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
||||
const originalBlob = await pdf(
|
||||
<InvoicePDF invoice={invoice} settings={settings} />,
|
||||
).toBlob();
|
||||
|
||||
// Validate blob
|
||||
if (!originalBlob || originalBlob.size === 0) {
|
||||
@@ -1038,6 +1141,7 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
// Additional utility function for generating PDF without downloading
|
||||
export async function generateInvoicePDFBlob(
|
||||
invoice: InvoiceData,
|
||||
settings?: PDFGenerationSettings,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
// Validate invoice data
|
||||
@@ -1054,7 +1158,9 @@ export async function generateInvoicePDFBlob(
|
||||
}
|
||||
|
||||
// Generate PDF blob
|
||||
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
||||
const originalBlob = await pdf(
|
||||
<InvoicePDF invoice={invoice} settings={settings} />,
|
||||
).toBlob();
|
||||
|
||||
// Validate blob
|
||||
if (!originalBlob || originalBlob.size === 0) {
|
||||
|
||||
Reference in New Issue
Block a user