diff --git a/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx b/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx index c9f4607..db56b2e 100644 --- a/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx +++ b/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx @@ -39,7 +39,23 @@ export function PDFDownloadButton({ throw new Error("Invoice not found"); } - await generateInvoicePDF(invoiceData); + // Map invoice to PDF format with currency support + const pdfData = { + invoiceNumber: invoiceData.invoiceNumber, + invoicePrefix: invoiceData.invoicePrefix, + issueDate: new Date(invoiceData.issueDate), + dueDate: new Date(invoiceData.dueDate), + status: invoiceData.status, + totalAmount: invoiceData.totalAmount, + taxRate: invoiceData.taxRate, + currency: invoiceData.currency ?? "USD", + notes: invoiceData.notes, + business: invoiceData.business, + client: invoiceData.client, + items: invoiceData.items, + }; + + await generateInvoicePDF(pdfData); toast.success("PDF downloaded successfully"); } catch (error) { console.error("PDF generation error:", error); diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx index ab48108..719f0e2 100644 --- a/src/components/forms/invoice-form.tsx +++ b/src/components/forms/invoice-form.tsx @@ -15,6 +15,7 @@ import { SelectValue, } from "~/components/ui/select"; import { DatePicker } from "~/components/ui/date-picker"; +import { Input } from "~/components/ui/input"; import { NumberInput } from "~/components/ui/number-input"; import { PageHeader } from "~/components/layout/page-header"; import { InvoiceLineItems } from "./invoice-line-items"; @@ -80,6 +81,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { // State const [formData, setFormData] = useState({ invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, + invoicePrefix: "#", businessId: "", clientId: "", issueDate: new Date(), @@ -150,6 +152,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { ) || []; setFormData({ invoiceNumber: existingInvoice.invoiceNumber, + invoicePrefix: existingInvoice.invoicePrefix ?? "#", businessId: existingInvoice.businessId ?? "", clientId: existingInvoice.clientId, issueDate: new Date(existingInvoice.issueDate), @@ -317,6 +320,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { try { const payload = { invoiceNumber: formData.invoiceNumber, + invoicePrefix: formData.invoicePrefix, businessId: formData.businessId || "", clientId: formData.clientId, issueDate: formData.issueDate, @@ -520,6 +524,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { /> +
+
+ + + updateField("invoicePrefix", e.target.value) + } + placeholder="#" + className="w-full" + /> +
+
diff --git a/src/components/forms/invoice/types.ts b/src/components/forms/invoice/types.ts index 471907c..32b5a9d 100644 --- a/src/components/forms/invoice/types.ts +++ b/src/components/forms/invoice/types.ts @@ -4,30 +4,31 @@ export type ClientType = RouterOutputs["clients"]["getAll"][number]; export type BusinessType = RouterOutputs["businesses"]["getAll"][number]; export interface InvoiceItem { - id: string; - date: Date; - description: string; - hours: number; - rate: number; - amount: number; + id: string; + date: Date; + description: string; + hours: number; + rate: number; + amount: number; } export interface InvoiceFormData { - invoiceNumber: string; - businessId: string; - clientId: string; - issueDate: Date; - dueDate: Date; - status: "draft" | "sent" | "paid"; - notes: string; - taxRate: number; - currency: string; - defaultHourlyRate: number | null; - items: InvoiceItem[]; + invoiceNumber: string; + invoicePrefix: string; + businessId: string; + clientId: string; + issueDate: Date; + dueDate: Date; + status: "draft" | "sent" | "paid"; + notes: string; + taxRate: number; + currency: string; + defaultHourlyRate: number | null; + items: InvoiceItem[]; } export const STATUS_OPTIONS = [ - { value: "draft", label: "Draft" }, - { value: "sent", label: "Sent" }, - { value: "paid", label: "Paid" }, + { value: "draft", label: "Draft" }, + { value: "sent", label: "Sent" }, + { value: "paid", label: "Paid" }, ] as const; diff --git a/src/lib/pdf-export.tsx b/src/lib/pdf-export.tsx index d2210fa..7637192 100644 --- a/src/lib/pdf-export.tsx +++ b/src/lib/pdf-export.tsx @@ -5,11 +5,26 @@ import { View, Image, StyleSheet, + Font, pdf, } from "@react-pdf/renderer"; import { saveAs } from "file-saver"; import React from "react"; +Font.register({ + family: "Frutiger", + fonts: [ + { + src: "/fonts/frutiger/Frutiger.ttf", + fontWeight: "normal", + }, + { + src: "/fonts/frutiger/Frutiger_bold.ttf", + fontWeight: "bold", + }, + ], +}); + // Fallback download function for better browser compatibility function downloadBlob(blob: Blob, filename: string): void { try { @@ -56,11 +71,13 @@ function downloadBlob(blob: Blob, filename: string): void { interface InvoiceData { invoiceNumber: string; + invoicePrefix?: string | null; issueDate: Date; dueDate: Date; status: string; totalAmount: number; taxRate: number; + currency?: string | null; notes?: string | null; business?: { name: string; @@ -100,7 +117,7 @@ const styles = StyleSheet.create({ page: { flexDirection: "column", backgroundColor: "#ffffff", - fontFamily: "Helvetica", + fontFamily: "Frutiger", fontSize: 10, paddingTop: 40, paddingBottom: 80, @@ -127,7 +144,7 @@ const styles = StyleSheet.create({ }, businessName: { - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", fontSize: 18, color: "#0f0f0f", marginBottom: 4, @@ -135,7 +152,7 @@ const styles = StyleSheet.create({ businessInfo: { fontSize: 10, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#6b7280", lineHeight: 1.4, marginBottom: 3, @@ -143,7 +160,7 @@ const styles = StyleSheet.create({ businessAddress: { fontSize: 10, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#6b7280", lineHeight: 1.4, marginTop: 4, @@ -156,14 +173,14 @@ const styles = StyleSheet.create({ invoiceTitle: { fontSize: 28, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#0f0f0f", marginBottom: 8, }, invoiceNumber: { fontSize: 14, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#374151", marginBottom: 4, }, @@ -172,7 +189,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 4, fontSize: 11, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", textAlign: "center", }, @@ -200,13 +217,13 @@ const styles = StyleSheet.create({ sectionTitle: { fontSize: 12, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#0f0f0f", marginBottom: 12, }, clientName: { - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", fontSize: 12, color: "#0f0f0f", marginBottom: 2, @@ -214,7 +231,7 @@ const styles = StyleSheet.create({ clientInfo: { fontSize: 10, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#6b7280", lineHeight: 1.4, marginBottom: 2, @@ -222,7 +239,7 @@ const styles = StyleSheet.create({ clientAddress: { fontSize: 10, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#6b7280", lineHeight: 1.4, marginTop: 4, @@ -236,14 +253,14 @@ const styles = StyleSheet.create({ detailLabel: { fontSize: 11, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#6b7280", flex: 1, }, detailValue: { fontSize: 10, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#0f0f0f", flex: 1, textAlign: "right", @@ -259,21 +276,21 @@ const styles = StyleSheet.create({ notesTitle: { fontSize: 11, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#0f0f0f", marginBottom: 6, }, notesContent: { fontSize: 10, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#374151", lineHeight: 1.4, }, businessContact: { fontSize: 9, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#6b7280", lineHeight: 1.4, }, @@ -297,7 +314,7 @@ const styles = StyleSheet.create({ abridgedBusinessName: { fontSize: 12, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#0f0f0f", }, @@ -309,13 +326,13 @@ const styles = StyleSheet.create({ abridgedInvoiceTitle: { fontSize: 14, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#0f0f0f", }, abridgedInvoiceNumber: { fontSize: 12, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#374151", }, @@ -335,7 +352,7 @@ const styles = StyleSheet.create({ tableHeaderCell: { fontSize: 10, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#374151", paddingHorizontal: 4, }, @@ -380,7 +397,7 @@ const styles = StyleSheet.create({ color: "#0f0f0f", paddingHorizontal: 4, paddingVertical: 2, - fontFamily: "Helvetica", + fontFamily: "Frutiger", }, tableCellDate: { @@ -396,7 +413,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 2, textAlign: "left", flexWrap: "wrap", - fontFamily: "Helvetica", + fontFamily: "Frutiger", }, tableCellHours: { @@ -454,7 +471,7 @@ const styles = StyleSheet.create({ totalLabel: { fontSize: 11, color: "#6b7280", - fontFamily: "Helvetica", + fontFamily: "Frutiger", }, totalAmount: { @@ -472,7 +489,7 @@ const styles = StyleSheet.create({ finalTotalLabel: { fontSize: 12, - fontFamily: "Helvetica-Bold", + fontFamily: "Frutiger-Bold", color: "#0f0f0f", }, @@ -484,7 +501,7 @@ const styles = StyleSheet.create({ itemCount: { fontSize: 9, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#9ca3af", textAlign: "center", marginTop: 6, @@ -511,16 +528,16 @@ const styles = StyleSheet.create({ pageNumber: { fontSize: 10, - fontFamily: "Helvetica", + fontFamily: "Frutiger", color: "#6b7280", }, }); // Helper functions -const formatCurrency = (amount: number) => { +const formatCurrency = (amount: number, currency = "USD") => { return new Intl.NumberFormat("en-US", { style: "currency", - currency: "USD", + currency, }).format(amount); }; @@ -574,7 +591,7 @@ function estimateTextHeight( ): number { if (!text) return fontSize * lineHeight; - // Rough character width estimation for Helvetica at given font size + // Rough character width estimation for Frutiger at given font size const avgCharWidth = fontSize * 0.6; const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth); @@ -807,7 +824,10 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( INVOICE - #{invoice.invoiceNumber} + + {invoice.invoicePrefix ?? "#"} + {invoice.invoiceNumber} + {getStatusLabel(invoice.status)} @@ -873,7 +893,10 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( INVOICE - #{invoice.invoiceNumber} + + {invoice.invoicePrefix ?? "#"} + {invoice.invoiceNumber} + ); @@ -922,7 +945,7 @@ const Footer: React.FC = () => ( [0]>; }> = ({ invoice, items }) => { + const currency = invoice.currency ?? "USD"; const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0); const taxAmount = (subtotal * invoice.taxRate) / 100; + const total = subtotal + taxAmount; return ( @@ -953,7 +978,7 @@ const TotalsSection: React.FC<{ Subtotal: - {formatCurrency(subtotal)} + + {formatCurrency(subtotal, currency)} + {invoice.taxRate > 0 && ( Tax ({invoice.taxRate}%): - {formatCurrency(taxAmount)} + + {formatCurrency(taxAmount, currency)} + )} TOTAL: - {formatCurrency(invoice.totalAmount)} + {formatCurrency(total, currency)} @@ -994,6 +1023,7 @@ const TotalsSection: React.FC<{ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { const items = invoice.items?.filter(Boolean) ?? []; const paginatedItems = paginateItems(items, Boolean(invoice.notes)); + const currency = invoice.currency ?? "USD"; return ( @@ -1040,12 +1070,12 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { {item.hours} - {formatCurrency(item.rate)} + {formatCurrency(item.rate, currency)} - {formatCurrency(item.amount)} + {formatCurrency(item.amount, currency)} ), diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts index 311370c..65e41c3 100644 --- a/src/server/api/routers/invoices.ts +++ b/src/server/api/routers/invoices.ts @@ -18,6 +18,7 @@ const invoiceItemSchema = z.object({ const createInvoiceSchema = z.object({ invoiceNumber: z.string().min(1, "Invoice number is required"), + invoicePrefix: z.string().optional().default("#"), businessId: z .string() .min(1, "Business is required") @@ -416,11 +417,17 @@ export const invoicesRouter = createTRPCRouter({ }); if (!invoice) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" }); + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invoice not found", + }); } if (invoice.createdById !== ctx.session.user.id) { - throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to update this invoice", + }); } await ctx.db @@ -428,18 +435,27 @@ export const invoicesRouter = createTRPCRouter({ .set({ status: input.status, updatedAt: new Date() }) .where(eq(invoices.id, input.id)); - return { success: true, message: `Invoice status updated to ${input.status}` }; + return { + success: true, + message: `Invoice status updated to ${input.status}`, + }; } catch (error) { if (error instanceof TRPCError) throw error; - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to update invoice status", cause: error }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update invoice status", + cause: error, + }); } }), bulkUpdateStatus: protectedProcedure - .input(z.object({ - ids: z.array(z.string()).min(1), - status: z.enum(["draft", "sent", "paid"]), - })) + .input( + z.object({ + ids: z.array(z.string()).min(1), + status: z.enum(["draft", "sent", "paid"]), + }), + ) .mutation(async ({ ctx, input }) => { // Only update invoices owned by this user const owned = await ctx.db.query.invoices.findMany({ @@ -452,7 +468,10 @@ export const invoicesRouter = createTRPCRouter({ .map((inv) => inv.id); if (ownedIds.length === 0) { - throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" }); + throw new TRPCError({ + code: "NOT_FOUND", + message: "No matching invoices found", + }); } await ctx.db @@ -476,7 +495,10 @@ export const invoicesRouter = createTRPCRouter({ .map((inv) => inv.id); if (ownedIds.length === 0) { - throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" }); + throw new TRPCError({ + code: "NOT_FOUND", + message: "No matching invoices found", + }); } await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds)); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index d69fe4e..f4025f3 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,7 +1,6 @@ import { relations, sql } from "drizzle-orm"; import { index, pgTableCreator } from "drizzle-orm/pg-core"; - /** * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same * database instance for multiple projects. @@ -22,7 +21,11 @@ export const users = createTable("user", (d) => ({ emailVerified: d.boolean().default(false).notNull(), image: d.varchar({ length: 255 }), createdAt: d.timestamp().notNull().defaultNow(), - updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), + updatedAt: d + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), password: d.varchar({ length: 255 }), // Matched DB: varchar(255) resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255) resetTokenExpiry: d.timestamp(), @@ -47,7 +50,11 @@ export const usersRelations = relations(users, ({ many }) => ({ export const accounts = createTable( "account", (d) => ({ - id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text + id: d + .text() + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), // Matched DB: text userId: d .varchar({ length: 255 }) .notNull() @@ -62,11 +69,13 @@ export const accounts = createTable( idToken: d.text(), password: d.text(), // Matched DB: text createdAt: d.timestamp().notNull().defaultNow(), - updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), + updatedAt: d + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), }), - (t) => [ - index("account_userId_idx").on(t.userId), - ], + (t) => [index("account_userId_idx").on(t.userId)], ); export const accountsRelations = relations(accounts, ({ one }) => ({ @@ -76,7 +85,11 @@ export const accountsRelations = relations(accounts, ({ one }) => ({ export const sessions = createTable( "session", (d) => ({ - id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text + id: d + .text() + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), // Matched DB: text userId: d .varchar({ length: 255 }) .notNull() @@ -86,7 +99,11 @@ export const sessions = createTable( ipAddress: d.text(), // Matched DB: text userAgent: d.text(), // Matched DB: text createdAt: d.timestamp().notNull().defaultNow(), - updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), + updatedAt: d + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), }), (t) => [index("session_userId_idx").on(t.userId)], ); @@ -98,12 +115,20 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({ export const verificationTokens = createTable( "verification_token", (d) => ({ - id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text + id: d + .text() + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), // Matched DB: text identifier: d.varchar({ length: 255 }).notNull(), value: d.varchar({ length: 255 }).notNull(), expiresAt: d.timestamp().notNull(), createdAt: d.timestamp().notNull().defaultNow(), - updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), + updatedAt: d + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), }), (t) => [index("verification_token_identifier_idx").on(t.identifier)], ); @@ -111,14 +136,25 @@ export const verificationTokens = createTable( export const ssoProviders = createTable( "sso_provider", (d) => ({ - id: d.varchar({ length: 255 }).notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), + id: d + .varchar({ length: 255 }) + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), providerId: d.varchar({ length: 255 }).notNull().unique(), - userId: d.varchar({ length: 255 }).notNull().references(() => users.id), + userId: d + .varchar({ length: 255 }) + .notNull() + .references(() => users.id), redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields oidcConfig: d.text(), samlConfig: d.text(), createdAt: d.timestamp().notNull().defaultNow(), - updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), + updatedAt: d + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), }), (t) => [index("sso_provider_user_id_idx").on(t.userId)], ); @@ -230,6 +266,7 @@ export const invoices = createTable( .primaryKey() .$defaultFn(() => crypto.randomUUID()), invoiceNumber: d.varchar({ length: 100 }).notNull(), + invoicePrefix: d.varchar({ length: 20 }).default("#"), businessId: d.varchar({ length: 255 }).references(() => businesses.id), clientId: d .varchar({ length: 255 }) @@ -411,4 +448,3 @@ export const invoiceTemplatesRelations = relations( }), }), ); -