mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user