feat: add administration page and account role management

- Implemented `AdministrationContent` component for managing account roles.
- Created `AdministrationPage` to serve as the main entry point for administration tasks.
- Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation.
- Introduced `InputColor` component for advanced color selection with various formats.
- Established color conversion utilities in `color-converter.ts` for handling color formats.
- Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
This commit is contained in:
2026-04-30 10:50:50 -04:00
parent ddc2b42672
commit 0e46fdafb2
87 changed files with 4566 additions and 2425 deletions
+126
View File
@@ -0,0 +1,126 @@
import { z } from "zod";
export const interfaceThemeValues = [
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
] as const;
export const fontPreferenceValues = [
"brand",
"frutiger",
"platform",
"inter",
"serif",
] as const;
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
export const sidebarStyleValues = ["floating", "docked"] as const;
export const colorModeValues = ["light", "dark", "system"] as const;
export const colorThemeValues = [
"slate",
"blue",
"green",
"rose",
"orange",
"custom",
] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const;
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
export const colorModeSchema = z.enum(colorModeValues);
export const colorThemeSchema = z.enum(colorThemeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export const hslChannelsSchema = z
.string()
.trim()
.regex(
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
"Use HSL channels like 142.1 76.2% 36.3%",
);
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
export type ColorMode = z.infer<typeof colorModeSchema>;
export type ColorTheme = z.infer<typeof colorThemeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
export const fallbackAppearance = {
interfaceTheme: "beenvoice",
fontPreference: "brand",
bodyFontPreference: "brand",
headingFontPreference: "brand",
radiusPreference: "xl",
sidebarStyle: "floating",
colorMode: "system",
colorTheme: "slate",
customColor: undefined,
brandName: "beenvoice",
brandTagline:
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: "beenvoice",
brandIcon: "$",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
} satisfies {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return interfaceThemeSchema.safeParse(value).success;
}
export function isFontPreference(value: unknown): value is FontPreference {
return fontPreferenceSchema.safeParse(value).success;
}
export function isColorMode(value: unknown): value is ColorMode {
return colorModeSchema.safeParse(value).success;
}
export function isColorTheme(value: unknown): value is ColorTheme {
return colorThemeSchema.safeParse(value).success;
}
export function isRadiusPreference(value: unknown): value is RadiusPreference {
return radiusPreferenceSchema.safeParse(value).success;
}
export function isSidebarStyle(value: unknown): value is SidebarStyle {
return sidebarStyleSchema.safeParse(value).success;
}
export function isPdfTemplate(value: unknown): value is PdfTemplate {
return pdfTemplateSchema.safeParse(value).success;
}
export function isHslChannels(value: unknown): value is string {
return hslChannelsSchema.safeParse(value).success;
}
+2 -2
View File
@@ -7,6 +7,6 @@ import { genericOAuthClient } from "better-auth/client/plugins";
* Auth client configuration
*/
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [genericOAuthClient()],
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [genericOAuthClient()],
});
+101 -58
View File
@@ -1,17 +1,36 @@
import { env } from "~/env";
import {
fallbackAppearance,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type PdfTemplate,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/appearance";
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 type {
ColorMode,
ColorTheme,
FontPreference,
InterfaceTheme,
PdfTemplate,
RadiusPreference,
SidebarStyle,
} from "~/lib/appearance";
export {
colorModeSchema,
colorThemeSchema,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
} from "~/lib/appearance";
export const interfaceThemes: {
value: InterfaceTheme;
@@ -21,7 +40,20 @@ export const interfaceThemes: {
{
value: "beenvoice",
label: "beenvoice",
description: "Opinionated brand system with expressive headings.",
description:
"Playfair Display headings, Geist body text, and soft product chrome.",
},
{
value: "frutiger",
label: "Frutiger Airport",
description:
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
},
{
value: "frutiger-aero",
label: "Frutiger Aero",
description:
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
},
{
value: "shadcn",
@@ -49,6 +81,8 @@ export const themePresets: Record<
colorTheme: ColorTheme;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
}
> = {
beenvoice: {
@@ -58,6 +92,28 @@ export const themePresets: Record<
colorTheme: "slate",
radiusPreference: "xl",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
frutiger: {
interfaceTheme: "frutiger",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "none",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#003b5c",
},
"frutiger-aero": {
interfaceTheme: "frutiger-aero",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#0077be",
},
shadcn: {
interfaceTheme: "shadcn",
@@ -66,6 +122,8 @@ export const themePresets: Record<
colorTheme: "slate",
radiusPreference: "md",
sidebarStyle: "docked",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
minimal: {
interfaceTheme: "minimal",
@@ -74,6 +132,8 @@ export const themePresets: Record<
colorTheme: "slate",
radiusPreference: "sm",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#111827",
},
editorial: {
interfaceTheme: "editorial",
@@ -82,36 +142,11 @@ export const themePresets: Record<
colorTheme: "rose",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#be123c",
},
};
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;
@@ -119,8 +154,13 @@ export const bodyFontPreferences: {
}[] = [
{
value: "brand",
label: "Brand Sans",
description: "Inter body text for a clean product feel.",
label: "Geist",
description: "Geist body text for the core beenvoice product feel.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger body text for signage-like operational screens.",
},
{
value: "platform",
@@ -129,8 +169,8 @@ export const bodyFontPreferences: {
},
{
value: "inter",
label: "Inter",
description: "Inter body text, explicitly selected.",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
@@ -146,8 +186,13 @@ export const headingFontPreferences: {
}[] = [
{
value: "brand",
label: "Brand Serif",
description: "Playfair headings for the BeenVoice identity.",
label: "Playfair Display",
description: "Playfair Display headings for the beenvoice identity.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger headings for airport-inspired wayfinding.",
},
{
value: "platform",
@@ -156,8 +201,8 @@ export const headingFontPreferences: {
},
{
value: "inter",
label: "Inter",
description: "Inter headings for a plain shadcn-style baseline.",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
@@ -222,10 +267,10 @@ export const colorModes: {
];
export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice";
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand";
env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
@@ -234,16 +279,14 @@ export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? "xl";
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating";
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
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 ?? "$",
name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName,
tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline,
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText,
icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon,
};
+113
View File
@@ -0,0 +1,113 @@
export function hexToRgb(hex: string) {
const normalized = normalizeHex(hex).slice(1, 7);
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
export function rgbToHex(r: number, g: number, b: number) {
return `#${[r, g, b]
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
.join("")}`.toUpperCase();
}
export function rgbToHsl(r: number, g: number, b: number) {
const red = clamp(r, 0, 255) / 255;
const green = clamp(g, 0, 255) / 255;
const blue = clamp(b, 0, 255) / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(lightness * 100) };
}
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
const hue =
max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: Math.round(saturation * 100),
l: Math.round(lightness * 100),
};
}
export function hslToRgb(h: number, s: number, l: number) {
const hue = clamp(h, 0, 360);
const saturation = clamp(s, 0, 100) / 100;
const lightness = clamp(l, 0, 100) / 100;
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = lightness - c / 2;
const [red, green, blue] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return {
r: Math.round((red + m) * 255),
g: Math.round((green + m) * 255),
b: Math.round((blue + m) * 255),
};
}
export function hexToRgba(hex: string) {
const normalized = normalizeHex(hex, true);
const rgb = hexToRgb(normalized);
const alphaHex = normalized.length === 9 ? normalized.slice(7, 9) : "ff";
return {
...rgb,
a: Number((parseInt(alphaHex, 16) / 255).toFixed(2)),
};
}
export function rgbaToHex(r: number, g: number, b: number, a: number) {
const alpha = clamp(Math.round(clampAlpha(a) * 255), 0, 255)
.toString(16)
.padStart(2, "0");
return `${rgbToHex(r, g, b)}${alpha}`.toUpperCase();
}
export function rgbaToHsla(r: number, g: number, b: number, a: number) {
return { ...rgbToHsl(r, g, b), a: clampAlpha(a) };
}
export function hslaToRgba(h: number, s: number, l: number, a: number) {
return { ...hslToRgb(h, s, l), a: clampAlpha(a) };
}
function normalizeHex(hex: string, alpha = false) {
const fallback = alpha ? "#FFFFFFff" : "#FFFFFF";
const withHash = hex.startsWith("#") ? hex : `#${hex}`;
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) return withHash;
if (alpha && /^#[0-9A-Fa-f]{8}$/.test(withHash)) return withHash;
return fallback;
}
function clamp(value: number, min: number, max: number) {
return Math.max(
min,
Math.min(max, Math.floor(Number.isFinite(value) ? value : min)),
);
}
function clampAlpha(value: number) {
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1));
}
+15 -10
View File
@@ -36,8 +36,9 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--primary": `oklch(0.6 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
@@ -56,8 +57,9 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
@@ -75,11 +77,13 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--primary": `oklch(0.7 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
@@ -96,8 +100,9 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
+3 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from "crypto";
export function getGravatarUrl(email: string, size = 200) {
const trimmedEmail = email.trim().toLowerCase();
const hash = createHash("sha256").update(trimmedEmail).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
const trimmedEmail = email.trim().toLowerCase();
const hash = createHash("sha256").update(trimmedEmail).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
}
+6
View File
@@ -6,6 +6,7 @@ import {
Building,
Receipt,
BarChart2,
Shield,
} from "lucide-react";
export interface NavLink {
@@ -35,6 +36,11 @@ export const navigationConfig: NavSection[] = [
title: "Account",
links: [
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
{
name: "Administration",
href: "/dashboard/administration",
icon: Shield,
},
],
},
];
+595 -160
View File
@@ -54,7 +54,7 @@ function downloadBlob(blob: Blob, filename: string): void {
}
}
interface InvoiceData {
export interface InvoiceData {
invoiceNumber: string;
invoicePrefix?: string | null;
issueDate: Date;
@@ -537,6 +537,170 @@ const styles = StyleSheet.create({
},
});
const minimalStyles = StyleSheet.create({
page: {
fontSize: 9,
paddingTop: 28,
paddingBottom: 48,
paddingHorizontal: 32,
},
denseHeader: {
marginBottom: 16,
paddingBottom: 12,
},
headerTop: {
marginBottom: 10,
},
businessName: {
fontSize: 14,
marginBottom: 2,
},
businessInfo: {
fontSize: 8,
lineHeight: 1.25,
marginBottom: 1,
},
businessAddress: {
fontSize: 8,
lineHeight: 1.25,
marginTop: 2,
},
invoiceTitle: {
fontSize: 18,
marginBottom: 3,
},
invoiceNumber: {
fontSize: 10,
marginBottom: 2,
},
statusBadge: {
paddingHorizontal: 0,
paddingVertical: 0,
backgroundColor: "#ffffff",
fontSize: 8,
},
headerSeparator: {
marginVertical: 4,
},
detailsSection: {
marginBottom: 10,
},
detailsColumn: {
marginRight: 14,
},
sectionTitle: {
fontSize: 8,
marginBottom: 5,
},
clientName: {
fontSize: 9,
marginBottom: 1,
},
clientInfo: {
fontSize: 8,
lineHeight: 1.25,
marginBottom: 1,
},
clientAddress: {
fontSize: 8,
lineHeight: 1.25,
marginTop: 2,
},
detailRow: {
marginBottom: 2,
},
detailLabel: {
fontSize: 8,
},
detailValue: {
fontSize: 8,
},
tableContainer: {
marginBottom: 10,
},
tableHeader: {
backgroundColor: "#ffffff",
paddingVertical: 4,
paddingHorizontal: 0,
},
tableHeaderCell: {
fontSize: 8,
paddingHorizontal: 2,
},
tableRow: {
paddingVertical: 3,
minHeight: 16,
},
tableCell: {
fontSize: 8,
paddingHorizontal: 2,
paddingVertical: 1,
},
tableCellDescription: {
lineHeight: 1.2,
paddingVertical: 1,
paddingHorizontal: 2,
},
bottomSection: {
marginTop: 10,
},
notesContainer: {
width: 260,
},
notesSection: {
padding: 0,
backgroundColor: "#ffffff",
},
notesTitle: {
fontSize: 8,
marginBottom: 4,
},
notesContent: {
fontSize: 8,
lineHeight: 1.25,
},
totalsContainer: {
width: 190,
},
totalsBox: {
padding: 0,
backgroundColor: "#ffffff",
},
totalRow: {
marginBottom: 2,
paddingVertical: 0,
},
totalLabel: {
fontSize: 8,
},
totalAmount: {
fontSize: 8,
},
finalTotalRow: {
marginTop: 5,
paddingTop: 5,
},
finalTotalLabel: {
fontSize: 9,
},
finalTotalAmount: {
fontSize: 10,
},
itemCount: {
fontSize: 7,
marginTop: 4,
},
footer: {
bottom: 20,
left: 32,
right: 32,
paddingTop: 7,
},
pageNumber: {
fontSize: 8,
},
});
// Helper functions
const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", {
@@ -602,109 +766,264 @@ function getColumnWidths(showRate: boolean) {
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, { color: settings.pdfAccentColor }]}>
{invoice.business?.name ?? "Your Business Name"}
</Text>
{invoice.business?.email && (
<Text style={styles.businessInfo}>{invoice.business.email}</Text>
)}
{invoice.business?.phone && (
<Text style={styles.businessInfo}>{invoice.business.phone}</Text>
)}
{(invoice.business?.addressLine1 ??
invoice.business?.city ??
invoice.business?.state) && (
<Text style={styles.businessAddress}>
{[
invoice.business?.addressLine1,
invoice.business?.addressLine2,
invoice.business?.city &&
invoice.business?.state &&
invoice.business?.postalCode
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}`
: [
invoice.business?.city,
invoice.business?.state,
invoice.business?.postalCode,
]
.filter(Boolean)
.join(", "),
invoice.business?.country,
]
.filter(Boolean)
.join("\n")}
}> = ({ invoice, settings }) => {
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View
style={[styles.denseHeader, isMinimal ? minimalStyles.denseHeader : {}]}
>
<View
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
>
<View style={styles.businessSection}>
<Text
style={[
styles.businessName,
isMinimal ? minimalStyles.businessName : {},
{ color: settings.pdfAccentColor },
]}
>
{invoice.business?.name ?? "Your Business Name"}
</Text>
)}
{invoice.business?.email && (
<Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.email}
</Text>
)}
{invoice.business?.phone && (
<Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.phone}
</Text>
)}
{(invoice.business?.addressLine1 ??
invoice.business?.city ??
invoice.business?.state) && (
<Text
style={[
styles.businessAddress,
isMinimal ? minimalStyles.businessAddress : {},
]}
>
{[
invoice.business?.addressLine1,
invoice.business?.addressLine2,
invoice.business?.city &&
invoice.business?.state &&
invoice.business?.postalCode
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}`
: [
invoice.business?.city,
invoice.business?.state,
invoice.business?.postalCode,
]
.filter(Boolean)
.join(", "),
invoice.business?.country,
]
.filter(Boolean)
.join("\n")}
</Text>
)}
</View>
<View style={styles.invoiceSection}>
<Text
style={[
styles.invoiceTitle,
isMinimal ? minimalStyles.invoiceTitle : {},
{ color: settings.pdfAccentColor },
]}
>
INVOICE
</Text>
<Text
style={[
styles.invoiceNumber,
isMinimal ? minimalStyles.invoiceNumber : {},
]}
>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
<View
style={[
...getStatusStyle(invoice.status),
isMinimal ? minimalStyles.statusBadge : {},
]}
>
<Text>{getStatusLabel(invoice.status)}</Text>
</View>
</View>
</View>
<View style={styles.invoiceSection}>
<Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}>
INVOICE
</Text>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
<View style={getStatusStyle(invoice.status)}>
<Text>{getStatusLabel(invoice.status)}</Text>
<View
style={[
styles.headerSeparator,
isMinimal ? minimalStyles.headerSeparator : {},
]}
/>
<View
style={[
styles.detailsSection,
isMinimal ? minimalStyles.detailsSection : {},
]}
>
<View
style={[
styles.detailsColumn,
isMinimal ? minimalStyles.detailsColumn : {},
]}
>
<Text
style={[
styles.sectionTitle,
isMinimal ? minimalStyles.sectionTitle : {},
]}
>
BILL TO:
</Text>
<Text
style={[
styles.clientName,
isMinimal ? minimalStyles.clientName : {},
]}
>
{invoice.client?.name ?? "N/A"}
</Text>
{invoice.client?.email && (
<Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.email}
</Text>
)}
{invoice.client?.phone && (
<Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.phone}
</Text>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<Text
style={[
styles.clientAddress,
isMinimal ? minimalStyles.clientAddress : {},
]}
>
{[
invoice.client?.addressLine1,
invoice.client?.addressLine2,
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
{invoice.client?.country ? "\n" + invoice.client.country : ""}
</Text>
)}
</View>
<View
style={[
styles.detailsColumn,
isMinimal ? minimalStyles.detailsColumn : {},
]}
>
<Text
style={[
styles.sectionTitle,
isMinimal ? minimalStyles.sectionTitle : {},
]}
>
INVOICE DETAILS:
</Text>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Issue Date:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{formatDate(invoice.issueDate)}
</Text>
</View>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Due Date:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{formatDate(invoice.dueDate)}
</Text>
</View>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Invoice #:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{invoice.invoiceNumber}
</Text>
</View>
</View>
</View>
</View>
<View style={styles.headerSeparator} />
<View style={styles.detailsSection}>
<View style={styles.detailsColumn}>
<Text style={styles.sectionTitle}>BILL TO:</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "N/A"}</Text>
{invoice.client?.email && (
<Text style={styles.clientInfo}>{invoice.client.email}</Text>
)}
{invoice.client?.phone && (
<Text style={styles.clientInfo}>{invoice.client.phone}</Text>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<Text style={styles.clientAddress}>
{[
invoice.client?.addressLine1,
invoice.client?.addressLine2,
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
{invoice.client?.country ? "\n" + invoice.client.country : ""}
</Text>
)}
</View>
<View style={styles.detailsColumn}>
<Text style={styles.sectionTitle}>INVOICE DETAILS:</Text>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Issue Date:</Text>
<Text style={styles.detailValue}>
{formatDate(invoice.issueDate)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Due Date:</Text>
<Text style={styles.detailValue}>{formatDate(invoice.dueDate)}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Invoice #:</Text>
<Text style={styles.detailValue}>{invoice.invoiceNumber}</Text>
</View>
</View>
</View>
</View>
);
);
};
// Table header component
const TableHeader: React.FC<{
@@ -712,22 +1031,33 @@ const TableHeader: React.FC<{
showRate: boolean;
}> = ({ settings, showRate }) => {
const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View
style={[
styles.tableHeader,
settings.pdfTemplate === "minimal"
? { backgroundColor: "#ffffff" }
: {},
]}
style={[styles.tableHeader, isMinimal ? minimalStyles.tableHeader : {}]}
>
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
{ width: cols.date },
]}
>
Date
</Text>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
{ width: cols.description },
]}
>
Description
</Text>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderHours,
{ width: cols.hours },
]}
@@ -738,6 +1068,7 @@ const TableHeader: React.FC<{
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderRate,
{ width: cols.rate },
]}
@@ -748,6 +1079,7 @@ const TableHeader: React.FC<{
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderAmount,
{ width: cols.amount },
]}
@@ -759,14 +1091,39 @@ const TableHeader: React.FC<{
};
// Footer component
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const NotesSection: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
if (!invoice.notes) return null;
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View style={styles.notesContainer}>
<View style={styles.notesSection}>
<Text style={styles.notesTitle}>NOTES</Text>
<Text style={styles.notesContent}>{invoice.notes}</Text>
<View
style={[
styles.notesContainer,
isMinimal ? minimalStyles.notesContainer : {},
]}
>
<View
style={[
styles.notesSection,
isMinimal ? minimalStyles.notesSection : {},
]}
>
<Text
style={[styles.notesTitle, isMinimal ? minimalStyles.notesTitle : {}]}
>
NOTES
</Text>
<Text
style={[
styles.notesContent,
isMinimal ? minimalStyles.notesContent : {},
]}
>
{invoice.notes}
</Text>
</View>
</View>
);
@@ -774,41 +1131,45 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings,
}) => (
<View style={styles.footer} fixed>
<View style={styles.footerLogo}>
{settings.pdfShowLogo && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
<Image
src="/beenvoice-logo.png"
}) => {
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View style={[styles.footer, isMinimal ? minimalStyles.footer : {}]} fixed>
<View style={styles.footerLogo}>
{settings.pdfShowLogo && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
<Image
src="/beenvoice-logo.png"
style={{
width: 120,
height: 18,
marginRight: 8,
}}
/>
)}
<Text
style={{
width: 120,
height: 18,
marginRight: 8,
fontSize: isMinimal ? 8 : 9,
fontFamily: "Helvetica",
color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0,
}}
>
{settings.pdfFooterText}
</Text>
</View>
{settings.pdfShowPageNumbers && (
<Text
style={[styles.pageNumber, isMinimal ? minimalStyles.pageNumber : {}]}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/>
)}
<Text
style={{
fontSize: 9,
fontFamily: "Helvetica",
color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0,
}}
>
{settings.pdfFooterText}
</Text>
</View>
{settings.pdfShowPageNumbers && (
<Text
style={styles.pageNumber}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/>
)}
</View>
);
);
};
// Enhanced totals section component
const TotalsSection: React.FC<{
@@ -820,14 +1181,21 @@ const TotalsSection: React.FC<{
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount;
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View style={styles.totalsContainer}>
<View
style={[
styles.totalsContainer,
isMinimal ? minimalStyles.totalsContainer : {},
]}
>
<View
style={[
styles.totalsBox,
settings.pdfTemplate === "minimal"
isMinimal
? {
...minimalStyles.totalsBox,
backgroundColor: "#ffffff",
borderTop: "1px solid #e5e7eb",
paddingHorizontal: 0,
@@ -837,38 +1205,79 @@ const TotalsSection: React.FC<{
>
<Text
style={{
fontSize: 11,
fontSize: isMinimal ? 8 : 11,
fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
textAlign: "center",
marginBottom: 8,
paddingBottom: 6,
textAlign: isMinimal ? "left" : "center",
marginBottom: isMinimal ? 5 : 8,
paddingBottom: isMinimal ? 3 : 6,
}}
>
INVOICE SUMMARY
</Text>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalAmount}>
<View
style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
>
<Text
style={[
styles.totalLabel,
isMinimal ? minimalStyles.totalLabel : {},
]}
>
Subtotal:
</Text>
<Text
style={[
styles.totalAmount,
isMinimal ? minimalStyles.totalAmount : {},
]}
>
{formatCurrency(subtotal, currency)}
</Text>
</View>
{invoice.taxRate > 0 && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
<Text style={styles.totalAmount}>
<View
style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
>
<Text
style={[
styles.totalLabel,
isMinimal ? minimalStyles.totalLabel : {},
]}
>
Tax ({invoice.taxRate}%):
</Text>
<Text
style={[
styles.totalAmount,
isMinimal ? minimalStyles.totalAmount : {},
]}
>
{formatCurrency(taxAmount, currency)}
</Text>
</View>
)}
<View style={styles.finalTotalRow}>
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
<View
style={[
styles.finalTotalRow,
isMinimal ? minimalStyles.finalTotalRow : {},
]}
>
<Text
style={[
styles.finalTotalLabel,
isMinimal ? minimalStyles.finalTotalLabel : {},
]}
>
TOTAL:
</Text>
<Text
style={[
styles.finalTotalAmount,
isMinimal ? minimalStyles.finalTotalAmount : {},
{ color: settings.pdfAccentColor },
]}
>
@@ -876,7 +1285,9 @@ const TotalsSection: React.FC<{
</Text>
</View>
<Text style={styles.itemCount}>
<Text
style={[styles.itemCount, isMinimal ? minimalStyles.itemCount : {}]}
>
{items.length} line item{items.length !== 1 ? "s" : ""}
</Text>
</View>
@@ -894,14 +1305,23 @@ export const InvoicePDF: React.FC<{
const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal";
return (
<Document>
<Page size="LETTER" style={styles.page}>
<Page
size="LETTER"
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
>
<DenseHeader invoice={invoice} settings={settings} />
{items.length > 0 && (
<View style={styles.tableContainer}>
<View
style={[
styles.tableContainer,
isMinimal ? minimalStyles.tableContainer : {},
]}
>
<TableHeader settings={settings} showRate={showRate} />
{items.map(
(item, index) =>
@@ -911,6 +1331,7 @@ export const InvoicePDF: React.FC<{
wrap={false}
style={[
styles.tableRow,
isMinimal ? minimalStyles.tableRow : {},
settings.pdfTemplate === "classic" && index % 2 === 0
? styles.tableRowAlt
: {},
@@ -919,6 +1340,7 @@ export const InvoicePDF: React.FC<{
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellDate,
{ width: cols.date },
]}
@@ -928,7 +1350,9 @@ export const InvoicePDF: React.FC<{
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellDescription,
isMinimal ? minimalStyles.tableCellDescription : {},
{ width: cols.description },
]}
>
@@ -937,6 +1361,7 @@ export const InvoicePDF: React.FC<{
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellHours,
{ width: cols.hours },
]}
@@ -947,6 +1372,7 @@ export const InvoicePDF: React.FC<{
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellRate,
{ width: cols.rate },
]}
@@ -957,6 +1383,7 @@ export const InvoicePDF: React.FC<{
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellAmount,
{ width: cols.amount },
]}
@@ -969,8 +1396,16 @@ export const InvoicePDF: React.FC<{
</View>
)}
<View style={styles.bottomSection} wrap={false}>
{invoice.notes && <NotesSection invoice={invoice} />}
<View
style={[
styles.bottomSection,
isMinimal ? minimalStyles.bottomSection : {},
]}
wrap={false}
>
{invoice.notes && (
<NotesSection invoice={invoice} settings={settings} />
)}
<TotalsSection invoice={invoice} items={items} settings={settings} />
</View>
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}