add receipts support
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
pdfFontFamilySchema,
|
||||
type PdfFontFamily,
|
||||
} from "~/lib/pdf-fonts";
|
||||
|
||||
export const colorModeValues = ["light", "dark", "system"] as const;
|
||||
export const pdfTemplateValues = ["classic", "minimal"] as const;
|
||||
@@ -6,6 +10,8 @@ export const pdfTemplateValues = ["classic", "minimal"] as const;
|
||||
export const colorModeSchema = z.enum(colorModeValues);
|
||||
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
|
||||
|
||||
export { pdfFontFamilySchema, type PdfFontFamily };
|
||||
|
||||
export type ColorMode = z.infer<typeof colorModeSchema>;
|
||||
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
||||
|
||||
@@ -14,6 +20,8 @@ export const defaultColorMode: ColorMode = "system";
|
||||
export const defaultPdfSettings = {
|
||||
pdfTemplate: "classic" as PdfTemplate,
|
||||
pdfAccentColor: "#111827",
|
||||
pdfFontFamily: "sans" as PdfFontFamily,
|
||||
pdfNumericFontFamily: "mono" as PdfFontFamily,
|
||||
pdfFooterText: "Professional Invoicing",
|
||||
pdfShowLogo: true,
|
||||
pdfShowPageNumbers: true,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { db } from "~/server/db";
|
||||
import { auditLog } from "~/server/db/schema";
|
||||
|
||||
export type AuditAction =
|
||||
| "user.profile_updated"
|
||||
| "user.role_updated"
|
||||
| "user.password_reset_sent"
|
||||
| "platform.pdf_settings_updated";
|
||||
|
||||
export type AuditTargetType = "user" | "platform";
|
||||
|
||||
type LogAuditEventInput = {
|
||||
actorUserId: string;
|
||||
action: AuditAction;
|
||||
targetType: AuditTargetType;
|
||||
targetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function logAuditEvent(input: LogAuditEventInput): Promise<void> {
|
||||
await db.insert(auditLog).values({
|
||||
actorUserId: input.actorUserId,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
import { env } from "~/env";
|
||||
import { type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
|
||||
export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
||||
export {
|
||||
colorModeSchema,
|
||||
defaultColorMode,
|
||||
defaultPdfSettings,
|
||||
pdfFontFamilySchema,
|
||||
pdfTemplateSchema,
|
||||
} from "~/lib/appearance";
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export type LineItemBillingType = "hourly" | "fixed";
|
||||
|
||||
export function isFixedLineItem(hours: number): boolean {
|
||||
return hours === 0;
|
||||
}
|
||||
|
||||
export function getLineItemBillingType(hours: number): LineItemBillingType {
|
||||
return isFixedLineItem(hours) ? "fixed" : "hourly";
|
||||
}
|
||||
|
||||
export function calculateLineItemAmount(hours: number, rate: number): number {
|
||||
return isFixedLineItem(hours) ? rate : hours * rate;
|
||||
}
|
||||
|
||||
export function formatLineItemDetail(
|
||||
hours: number,
|
||||
rate: number,
|
||||
formatCurrency: (amount: number) => string,
|
||||
): string {
|
||||
if (isFixedLineItem(hours)) {
|
||||
return "Fixed amount";
|
||||
}
|
||||
return `${hours}h @ ${formatCurrency(rate)}/hr`;
|
||||
}
|
||||
|
||||
export function applyBillingTypeChange(
|
||||
billingType: LineItemBillingType,
|
||||
current: { hours: number; rate: number },
|
||||
): { hours: number; rate: number; amount: number } {
|
||||
if (billingType === "fixed") {
|
||||
const amount = calculateLineItemAmount(current.hours, current.rate);
|
||||
return { hours: 0, rate: amount, amount };
|
||||
}
|
||||
|
||||
const hours = current.hours > 0 ? current.hours : 1;
|
||||
const amount = calculateLineItemAmount(hours, current.rate);
|
||||
return { hours, rate: current.rate, amount };
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
|
||||
pathname.startsWith("/dashboard/businesses")
|
||||
);
|
||||
}
|
||||
if (href === "/dashboard/time-clock") {
|
||||
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
|
||||
}
|
||||
return pathname === href;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import "server-only";
|
||||
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
||||
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
||||
|
||||
function isS3Configured(): boolean {
|
||||
return Boolean(
|
||||
process.env.S3_BUCKET &&
|
||||
process.env.S3_ACCESS_KEY &&
|
||||
process.env.S3_SECRET_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
export function getStorageBackend(): "s3" | "local" {
|
||||
return isS3Configured() ? "s3" : "local";
|
||||
}
|
||||
|
||||
type S3Module = typeof import("@aws-sdk/client-s3");
|
||||
|
||||
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
||||
|
||||
async function getS3() {
|
||||
if (!s3ModulePromise) {
|
||||
s3ModulePromise = import("@aws-sdk/client-s3");
|
||||
}
|
||||
const mod = await s3ModulePromise;
|
||||
if (!s3Client) {
|
||||
s3Client = new mod.S3Client({
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||
},
|
||||
// Required for MinIO and most S3-compatible endpoints.
|
||||
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
|
||||
});
|
||||
}
|
||||
return { client: s3Client, ...mod };
|
||||
}
|
||||
|
||||
function localPathForKey(key: string) {
|
||||
return path.join(LOCAL_RECEIPTS_DIR, key);
|
||||
}
|
||||
|
||||
export async function putObject(
|
||||
key: string,
|
||||
body: Buffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
if (isS3Configured()) {
|
||||
const { client, PutObjectCommand } = await getS3();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET!,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = localPathForKey(key);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, body);
|
||||
}
|
||||
|
||||
export async function getObject(key: string): Promise<Buffer> {
|
||||
if (isS3Configured()) {
|
||||
const { client, GetObjectCommand } = await getS3();
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET!,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
const bytes = await response.Body?.transformToByteArray();
|
||||
if (!bytes) {
|
||||
throw new Error("Empty object body");
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
return readFile(localPathForKey(key));
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
if (isS3Configured()) {
|
||||
const { client, DeleteObjectCommand } = await getS3();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET!,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(localPathForKey(key));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function isAllowedReceiptMime(mimeType: string): boolean {
|
||||
const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
return normalized === "application/pdf" || normalized.startsWith("image/");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import crypto from "crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
export type PasswordResetResult = {
|
||||
success: boolean;
|
||||
emailSent: boolean;
|
||||
userEmail?: string;
|
||||
};
|
||||
|
||||
export async function sendPasswordResetForUser(
|
||||
userId: string,
|
||||
): Promise<PasswordResetResult> {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { id: true, email: true, name: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { success: false, emailSent: false };
|
||||
}
|
||||
|
||||
const resetToken = crypto.randomBytes(32).toString("hex");
|
||||
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ resetToken, resetTokenExpiry })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
if (!env.RESEND_API_KEY) {
|
||||
console.warn(
|
||||
"Password reset requested, but RESEND_API_KEY is not configured.",
|
||||
);
|
||||
return { success: true, emailSent: false, userEmail: user.email };
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = new Resend(env.RESEND_API_KEY);
|
||||
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
|
||||
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? undefined,
|
||||
resetToken,
|
||||
resetUrl,
|
||||
expiryHours: 24,
|
||||
});
|
||||
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
||||
|
||||
await resend.emails.send({
|
||||
from: `beenvoice <noreply@${fromDomain}>`,
|
||||
to: user.email,
|
||||
subject: emailTemplate.subject,
|
||||
html: emailTemplate.html,
|
||||
text: emailTemplate.text,
|
||||
});
|
||||
|
||||
return { success: true, emailSent: true, userEmail: user.email };
|
||||
} catch (emailError) {
|
||||
console.error("Failed to send password reset email:", emailError);
|
||||
return { success: true, emailSent: false, userEmail: user.email };
|
||||
}
|
||||
}
|
||||
+166
-38
@@ -6,9 +6,19 @@ import {
|
||||
Image,
|
||||
StyleSheet,
|
||||
pdf,
|
||||
type Styles,
|
||||
} from "@react-pdf/renderer";
|
||||
import { saveAs } from "file-saver";
|
||||
import {
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
import React from "react";
|
||||
import {
|
||||
type PdfFontFamily,
|
||||
type ResolvedPdfFonts,
|
||||
pdfFontCacheKey,
|
||||
resolvePdfFonts,
|
||||
} from "~/lib/pdf-fonts";
|
||||
|
||||
// Fallback download function for better browser compatibility
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
@@ -101,6 +111,8 @@ export interface InvoiceData {
|
||||
export interface PDFGenerationSettings {
|
||||
pdfTemplate?: "classic" | "minimal";
|
||||
pdfAccentColor?: string;
|
||||
pdfFontFamily?: PdfFontFamily;
|
||||
pdfNumericFontFamily?: PdfFontFamily;
|
||||
pdfFooterText?: string;
|
||||
pdfShowLogo?: boolean;
|
||||
pdfShowPageNumbers?: boolean;
|
||||
@@ -109,6 +121,8 @@ export interface PDFGenerationSettings {
|
||||
const defaultPDFSettings: Required<PDFGenerationSettings> = {
|
||||
pdfTemplate: "classic",
|
||||
pdfAccentColor: "#111827",
|
||||
pdfFontFamily: "sans",
|
||||
pdfNumericFontFamily: "mono",
|
||||
pdfFooterText: "Professional Invoicing",
|
||||
pdfShowLogo: true,
|
||||
pdfShowPageNumbers: true,
|
||||
@@ -118,7 +132,95 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
|
||||
return { ...defaultPDFSettings, ...settings };
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function mapLegacyPdfFont(
|
||||
fontFamily: string,
|
||||
fonts: ResolvedPdfFonts,
|
||||
): string {
|
||||
switch (fontFamily) {
|
||||
case "Helvetica-Bold":
|
||||
return fonts.bold;
|
||||
case "Helvetica":
|
||||
return fonts.regular;
|
||||
case "Courier-Bold":
|
||||
return fonts.monoBold;
|
||||
case "Courier":
|
||||
return fonts.mono;
|
||||
default:
|
||||
return fontFamily;
|
||||
}
|
||||
}
|
||||
|
||||
function remapStyleFontFamilies<T extends Styles>(
|
||||
sheet: T,
|
||||
fonts: ResolvedPdfFonts,
|
||||
): T {
|
||||
const remapped = {} as T;
|
||||
|
||||
for (const [key, style] of Object.entries(sheet)) {
|
||||
const fontFamily = (style as { fontFamily?: string }).fontFamily;
|
||||
remapped[key as keyof T] = {
|
||||
...style,
|
||||
...(fontFamily
|
||||
? { fontFamily: mapLegacyPdfFont(fontFamily, fonts) }
|
||||
: {}),
|
||||
} as T[keyof T];
|
||||
}
|
||||
|
||||
return remapped;
|
||||
}
|
||||
|
||||
type PdfStyleBundle = {
|
||||
styles: typeof baseStyles;
|
||||
minimalStyles: typeof baseMinimalStyles;
|
||||
fonts: ResolvedPdfFonts;
|
||||
getStatusStyle: (
|
||||
status: string,
|
||||
) => Array<Record<string, string | number>>;
|
||||
};
|
||||
|
||||
const pdfStyleCache = new Map<string, PdfStyleBundle>();
|
||||
|
||||
function getPdfStyleBundle(
|
||||
bodyFamily: PdfFontFamily,
|
||||
numericFamily: PdfFontFamily,
|
||||
): PdfStyleBundle {
|
||||
const cacheKey = pdfFontCacheKey(bodyFamily, numericFamily);
|
||||
const cached = pdfStyleCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const fonts = resolvePdfFonts(bodyFamily, numericFamily);
|
||||
const styles = remapStyleFontFamilies(baseStyles, fonts);
|
||||
const bundle: PdfStyleBundle = {
|
||||
styles,
|
||||
minimalStyles: baseMinimalStyles,
|
||||
fonts,
|
||||
getStatusStyle: (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case "paid":
|
||||
return [styles.statusBadge, styles.statusPaid];
|
||||
case "sent":
|
||||
return [styles.statusBadge, styles.statusPaid];
|
||||
case "overdue":
|
||||
return [
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: "#fef2f2", color: "#dc2626" },
|
||||
];
|
||||
case "draft":
|
||||
return [
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
|
||||
];
|
||||
default:
|
||||
return [styles.statusBadge, styles.statusUnpaid];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
pdfStyleCache.set(cacheKey, bundle);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#ffffff",
|
||||
@@ -537,7 +639,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
const minimalStyles = StyleSheet.create({
|
||||
const baseMinimalStyles = StyleSheet.create({
|
||||
page: {
|
||||
fontSize: 9,
|
||||
paddingTop: 28,
|
||||
@@ -729,27 +831,6 @@ const getStatusLabel = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case "paid":
|
||||
return [styles.statusBadge, styles.statusPaid];
|
||||
case "sent":
|
||||
return [styles.statusBadge, styles.statusPaid];
|
||||
case "overdue":
|
||||
return [
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: "#fef2f2", color: "#dc2626" },
|
||||
];
|
||||
case "draft":
|
||||
return [
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
|
||||
];
|
||||
default:
|
||||
return [styles.statusBadge, styles.statusUnpaid];
|
||||
}
|
||||
};
|
||||
|
||||
function getColumnWidths(showRate: boolean) {
|
||||
return showRate
|
||||
? {
|
||||
@@ -766,7 +847,9 @@ function getColumnWidths(showRate: boolean) {
|
||||
const DenseHeader: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, settings }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
|
||||
return (
|
||||
@@ -1029,7 +1112,9 @@ const DenseHeader: React.FC<{
|
||||
const TableHeader: React.FC<{
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
showRate: boolean;
|
||||
}> = ({ settings, showRate }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ settings, showRate, pdfStyles }) => {
|
||||
const { styles, minimalStyles } = pdfStyles;
|
||||
const cols = getColumnWidths(showRate);
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
return (
|
||||
@@ -1094,7 +1179,9 @@ const TableHeader: React.FC<{
|
||||
const NotesSection: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, settings }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles } = pdfStyles;
|
||||
if (!invoice.notes) return null;
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
|
||||
@@ -1129,9 +1216,11 @@ const NotesSection: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
||||
settings,
|
||||
}) => {
|
||||
const Footer: React.FC<{
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles, fonts } = pdfStyles;
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
|
||||
return (
|
||||
@@ -1151,7 +1240,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
||||
<Text
|
||||
style={{
|
||||
fontSize: isMinimal ? 8 : 9,
|
||||
fontFamily: "Helvetica",
|
||||
fontFamily: fonts.regular,
|
||||
color: "#6b7280",
|
||||
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
||||
}}
|
||||
@@ -1176,7 +1265,9 @@ const TotalsSection: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
}> = ({ invoice, items, settings }) => {
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, items, settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles, fonts } = pdfStyles;
|
||||
const currency = invoice.currency ?? "USD";
|
||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
@@ -1206,7 +1297,7 @@ const TotalsSection: React.FC<{
|
||||
<Text
|
||||
style={{
|
||||
fontSize: isMinimal ? 8 : 11,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
fontFamily: fonts.bold,
|
||||
color: "#0f0f0f",
|
||||
textAlign: isMinimal ? "left" : "center",
|
||||
marginBottom: isMinimal ? 5 : 8,
|
||||
@@ -1301,6 +1392,26 @@ export const InvoicePDF: React.FC<{
|
||||
settings?: PDFGenerationSettings;
|
||||
}> = ({ invoice, settings: inputSettings }) => {
|
||||
const settings = resolvePDFSettings(inputSettings);
|
||||
const pdfStyles = getPdfStyleBundle(
|
||||
settings.pdfFontFamily,
|
||||
settings.pdfNumericFontFamily,
|
||||
);
|
||||
|
||||
return (
|
||||
<InvoicePDFDocument
|
||||
invoice={invoice}
|
||||
settings={settings}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InvoicePDFDocument: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, settings, pdfStyles }) => {
|
||||
const { styles, minimalStyles } = pdfStyles;
|
||||
const items = invoice.items?.filter(Boolean) ?? [];
|
||||
const currency = invoice.currency ?? "USD";
|
||||
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
||||
@@ -1313,7 +1424,11 @@ export const InvoicePDF: React.FC<{
|
||||
size="LETTER"
|
||||
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
|
||||
>
|
||||
<DenseHeader invoice={invoice} settings={settings} />
|
||||
<DenseHeader
|
||||
invoice={invoice}
|
||||
settings={settings}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
|
||||
{items.length > 0 && (
|
||||
<View
|
||||
@@ -1322,7 +1437,11 @@ export const InvoicePDF: React.FC<{
|
||||
isMinimal ? minimalStyles.tableContainer : {},
|
||||
]}
|
||||
>
|
||||
<TableHeader settings={settings} showRate={showRate} />
|
||||
<TableHeader
|
||||
settings={settings}
|
||||
showRate={showRate}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
{items.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
@@ -1366,7 +1485,7 @@ export const InvoicePDF: React.FC<{
|
||||
{ width: cols.hours },
|
||||
]}
|
||||
>
|
||||
{item.hours}
|
||||
{isFixedLineItem(item.hours) ? "—" : item.hours}
|
||||
</Text>
|
||||
{showRate && (
|
||||
<Text
|
||||
@@ -1404,12 +1523,21 @@ export const InvoicePDF: React.FC<{
|
||||
wrap={false}
|
||||
>
|
||||
{invoice.notes && (
|
||||
<NotesSection invoice={invoice} settings={settings} />
|
||||
<NotesSection
|
||||
invoice={invoice}
|
||||
settings={settings}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
)}
|
||||
<TotalsSection invoice={invoice} items={items} settings={settings} />
|
||||
<TotalsSection
|
||||
invoice={invoice}
|
||||
items={items}
|
||||
settings={settings}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Footer settings={settings} />
|
||||
<Footer settings={settings} pdfStyles={pdfStyles} />
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Built-in PDF font presets (react-pdf standard fonts, no embedding required). */
|
||||
export const pdfFontFamilyValues = ["sans", "serif", "mono"] as const;
|
||||
|
||||
export const pdfFontFamilySchema = z.enum(pdfFontFamilyValues);
|
||||
|
||||
export type PdfFontFamily = z.infer<typeof pdfFontFamilySchema>;
|
||||
|
||||
export interface ResolvedPdfFonts {
|
||||
regular: string;
|
||||
bold: string;
|
||||
mono: string;
|
||||
monoBold: string;
|
||||
}
|
||||
|
||||
export const pdfFontFamilyOptions: {
|
||||
value: PdfFontFamily;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "sans",
|
||||
label: "Modern",
|
||||
description: "Clean sans-serif (Helvetica).",
|
||||
},
|
||||
{
|
||||
value: "serif",
|
||||
label: "Classic",
|
||||
description: "Traditional serif (Times).",
|
||||
},
|
||||
{
|
||||
value: "mono",
|
||||
label: "Monospace",
|
||||
description: "Fixed-width type (Courier).",
|
||||
},
|
||||
];
|
||||
|
||||
function resolveBodyFonts(family: PdfFontFamily): Pick<ResolvedPdfFonts, "regular" | "bold"> {
|
||||
switch (family) {
|
||||
case "serif":
|
||||
return {
|
||||
regular: "Times-Roman",
|
||||
bold: "Times-Bold",
|
||||
};
|
||||
case "mono":
|
||||
return {
|
||||
regular: "Courier",
|
||||
bold: "Courier-Bold",
|
||||
};
|
||||
case "sans":
|
||||
default:
|
||||
return {
|
||||
regular: "Helvetica",
|
||||
bold: "Helvetica-Bold",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNumericFonts(
|
||||
family: PdfFontFamily,
|
||||
): Pick<ResolvedPdfFonts, "mono" | "monoBold"> {
|
||||
switch (family) {
|
||||
case "serif":
|
||||
return {
|
||||
mono: "Times-Roman",
|
||||
monoBold: "Times-Bold",
|
||||
};
|
||||
case "mono":
|
||||
return {
|
||||
mono: "Courier",
|
||||
monoBold: "Courier-Bold",
|
||||
};
|
||||
case "sans":
|
||||
default:
|
||||
return {
|
||||
mono: "Helvetica",
|
||||
monoBold: "Helvetica-Bold",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePdfFonts(
|
||||
bodyFamily: PdfFontFamily,
|
||||
numericFamily: PdfFontFamily = "mono",
|
||||
): ResolvedPdfFonts {
|
||||
return {
|
||||
...resolveBodyFonts(bodyFamily),
|
||||
...resolveNumericFonts(numericFamily),
|
||||
};
|
||||
}
|
||||
|
||||
export function pdfFontCacheKey(
|
||||
bodyFamily: PdfFontFamily,
|
||||
numericFamily: PdfFontFamily,
|
||||
): string {
|
||||
return `${bodyFamily}:${numericFamily}`;
|
||||
}
|
||||
|
||||
export function isPdfFontFamily(value: unknown): value is PdfFontFamily {
|
||||
return pdfFontFamilySchema.safeParse(value).success;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export function invoiceLabel(inv: {
|
||||
invoicePrefix: string | null;
|
||||
invoiceNumber: string;
|
||||
}) {
|
||||
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||
}
|
||||
|
||||
export function entryHref(entry: {
|
||||
invoiceId: string | null;
|
||||
clientId: string | null;
|
||||
invoice?: { id: string } | null;
|
||||
client?: { id: string } | null;
|
||||
}): string | null {
|
||||
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
|
||||
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
|
||||
const clientId = entry.clientId ?? entry.client?.id;
|
||||
if (clientId) return `/dashboard/clients/${clientId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type TimeEntryListItem = {
|
||||
id: string;
|
||||
description: string | null;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
clientId: string | null;
|
||||
invoiceId: string | null;
|
||||
client?: { id: string; name: string } | null;
|
||||
invoice?: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
invoicePrefix: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function groupEntriesByDate<T extends { startedAt: Date }>(
|
||||
entries: T[],
|
||||
): { dateKey: string; label: string; entries: T[] }[] {
|
||||
const groups = new Map<string, T[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const d = new Date(entry.startedAt);
|
||||
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const existing = groups.get(dateKey);
|
||||
if (existing) {
|
||||
existing.push(entry);
|
||||
} else {
|
||||
groups.set(dateKey, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([dateKey, groupEntries]) => {
|
||||
const sample = new Date(groupEntries[0]!.startedAt);
|
||||
const label = sample.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
return { dateKey, label, entries: groupEntries };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user