Add bulk actions, multi-currency, expenses, templates, and reports

Schema (migration 0001):
- clients: add currency column (default USD)
- invoices: add currency column (default USD)
- New expenses table: amount, currency, category, billable, reimbursable,
  client/invoice/business relations, notes
- New invoice_templates table: name, type (notes|terms), content, isDefault

API:
- invoices: add bulkUpdateStatus and bulkDelete procedures (ownership-safe)
- invoices: currency field threaded through create/update schemas
- clients: currency field added to create/update schemas
- New expenses router: full CRUD with authorization
- New invoiceTemplates router: full CRUD, isDefault management per type
- Root router: wire in expenses and invoiceTemplates

Currency (src/lib/currency.ts):
- Shared formatCurrency(amount, currency) utility replacing hardcoded USD
- SUPPORTED_CURRENCIES list (17 currencies)
- Invoice form: currency selector in Config card, auto-fills from client
- Client form: currency selector in Billing Information card

Bulk actions (invoices list):
- Checkbox column with select-all support
- Selection toolbar: Mark as Sent/Paid/Draft dropdown, Delete (N) button
- DataTable: new selectionActions prop renders toolbar when rows selected

Notes templates:
- Invoice form: Notes card with textarea in Details tab
- Template dropdown button appears when templates exist
- /dashboard/invoices/templates: full CRUD page for notes and terms templates

New pages:
- /dashboard/expenses: expense list with summary cards, add/edit dialog
- /dashboard/reports: KPI cards, 12-month revenue area chart, top clients
  bar chart, status breakdown, recent activity
- Navigation: Expenses and Reports added to Main section

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
This commit is contained in:
Claude
2026-04-05 02:34:06 +00:00
parent ba14526fc5
commit e6b79ce2c2
19 changed files with 3233 additions and 214 deletions
+4
View File
@@ -4,6 +4,8 @@ import { invoicesRouter } from "~/server/api/routers/invoices";
import { settingsRouter } from "~/server/api/routers/settings";
import { emailRouter } from "~/server/api/routers/email";
import { dashboardRouter } from "~/server/api/routers/dashboard";
import { expensesRouter } from "~/server/api/routers/expenses";
import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
@@ -18,6 +20,8 @@ export const appRouter = createTRPCRouter({
settings: settingsRouter,
email: emailRouter,
dashboard: dashboardRouter,
expenses: expensesRouter,
invoiceTemplates: invoiceTemplatesRouter,
});
// export type definition of API
+1
View File
@@ -43,6 +43,7 @@ const createClientSchema = z.object({
.optional()
.or(z.literal("")),
defaultHourlyRate: z.number().min(0, "Rate must be positive").optional(),
currency: z.string().length(3).default("USD").optional(),
});
const updateClientSchema = createClientSchema.partial().extend({
+163
View File
@@ -0,0 +1,163 @@
import { z } from "zod";
import { eq, and, desc } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
const createExpenseSchema = z.object({
date: z.date(),
description: z.string().min(1, "Description is required"),
amount: z.number().min(0, "Amount must be positive"),
currency: z.string().length(3).default("USD"),
category: z.string().optional().or(z.literal("")),
billable: z.boolean().default(false),
reimbursable: z.boolean().default(false),
notes: z.string().optional().or(z.literal("")),
clientId: z.string().optional().or(z.literal("")),
businessId: z.string().optional().or(z.literal("")),
invoiceId: z.string().optional().or(z.literal("")),
});
const updateExpenseSchema = createExpenseSchema.partial().extend({
id: z.string(),
});
export const expensesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.expenses.findMany({
where: eq(expenses.createdById, ctx.session.user.id),
with: { client: true, business: true, invoice: true },
orderBy: [desc(expenses.date)],
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const expense = await ctx.db.query.expenses.findFirst({
where: and(
eq(expenses.id, input.id),
eq(expenses.createdById, ctx.session.user.id),
),
with: { client: true, business: true, invoice: true },
});
if (!expense) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
}
return expense;
}),
create: protectedProcedure
.input(createExpenseSchema)
.mutation(async ({ ctx, input }) => {
const clean = {
...input,
clientId: input.clientId?.trim() || null,
businessId: input.businessId?.trim() || null,
invoiceId: input.invoiceId?.trim() || null,
category: input.category?.trim() || null,
notes: input.notes?.trim() || null,
};
if (clean.clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(
eq(clients.id, clean.clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
if (clean.businessId) {
const business = await ctx.db.query.businesses.findFirst({
where: and(
eq(businesses.id, clean.businessId),
eq(businesses.createdById, ctx.session.user.id),
),
});
if (!business) throw new TRPCError({ code: "FORBIDDEN", message: "Business not found" });
}
if (clean.invoiceId) {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.id, clean.invoiceId),
eq(invoices.createdById, ctx.session.user.id),
),
});
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
}
const [expense] = await ctx.db
.insert(expenses)
.values({ ...clean, createdById: ctx.session.user.id })
.returning();
return expense;
}),
update: protectedProcedure
.input(updateExpenseSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const existing = await ctx.db.query.expenses.findFirst({
where: and(
eq(expenses.id, id),
eq(expenses.createdById, ctx.session.user.id),
),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
}
const clean = {
...data,
clientId: data.clientId?.trim() || null,
businessId: data.businessId?.trim() || null,
invoiceId: data.invoiceId?.trim() || null,
category: data.category?.trim() || null,
notes: data.notes?.trim() || null,
updatedAt: new Date(),
};
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id));
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.expenses.findFirst({
where: and(
eq(expenses.id, input.id),
eq(expenses.createdById, ctx.session.user.id),
),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
}
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
return { success: true };
}),
});
+120
View File
@@ -0,0 +1,120 @@
import { z } from "zod";
import { eq, and } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { invoiceTemplates } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
const createTemplateSchema = z.object({
name: z.string().min(1, "Name is required").max(255),
type: z.enum(["notes", "terms"]).default("notes"),
content: z.string().min(1, "Content is required"),
isDefault: z.boolean().default(false),
});
const updateTemplateSchema = createTemplateSchema.partial().extend({
id: z.string(),
});
export const invoiceTemplatesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.invoiceTemplates.findMany({
where: eq(invoiceTemplates.createdById, ctx.session.user.id),
orderBy: (t, { asc }) => [asc(t.type), asc(t.name)],
});
}),
getByType: protectedProcedure
.input(z.object({ type: z.enum(["notes", "terms"]) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.invoiceTemplates.findMany({
where: and(
eq(invoiceTemplates.createdById, ctx.session.user.id),
eq(invoiceTemplates.type, input.type),
),
orderBy: (t, { asc }) => [asc(t.name)],
});
}),
create: protectedProcedure
.input(createTemplateSchema)
.mutation(async ({ ctx, input }) => {
// If setting as default, unset others of same type
if (input.isDefault) {
await ctx.db
.update(invoiceTemplates)
.set({ isDefault: false })
.where(
and(
eq(invoiceTemplates.createdById, ctx.session.user.id),
eq(invoiceTemplates.type, input.type),
),
);
}
const [template] = await ctx.db
.insert(invoiceTemplates)
.values({ ...input, createdById: ctx.session.user.id })
.returning();
return template;
}),
update: protectedProcedure
.input(updateTemplateSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const existing = await ctx.db.query.invoiceTemplates.findFirst({
where: and(
eq(invoiceTemplates.id, id),
eq(invoiceTemplates.createdById, ctx.session.user.id),
),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
}
// If setting as default, unset others of same type
if (data.isDefault) {
const type = data.type ?? existing.type;
await ctx.db
.update(invoiceTemplates)
.set({ isDefault: false })
.where(
and(
eq(invoiceTemplates.createdById, ctx.session.user.id),
eq(invoiceTemplates.type, type),
),
);
}
await ctx.db
.update(invoiceTemplates)
.set({ ...data, updatedAt: new Date() })
.where(eq(invoiceTemplates.id, id));
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.invoiceTemplates.findFirst({
where: and(
eq(invoiceTemplates.id, input.id),
eq(invoiceTemplates.createdById, ctx.session.user.id),
),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
}
await ctx.db
.delete(invoiceTemplates)
.where(eq(invoiceTemplates.id, input.id));
return { success: true };
}),
});
+56 -26
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import {
invoices,
@@ -29,6 +29,7 @@ const createInvoiceSchema = z.object({
status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
});
@@ -410,47 +411,76 @@ export const invoicesRouter = createTRPCRouter({
.input(updateStatusSchema)
.mutation(async ({ ctx, input }) => {
try {
// Verify invoice exists and belongs to user
const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id),
});
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
.update(invoices)
.set({
status: input.status,
updatedAt: new Date(),
})
.set({ status: input.status, updatedAt: new Date() })
.where(eq(invoices.id, input.id));
console.log("Status update completed successfully");
return {
success: true,
message: `Invoice status updated to ${input.status}`,
};
return { success: true, message: `Invoice status updated to ${input.status}` };
} catch (error) {
console.error("UpdateStatus error:", 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"]),
}))
.mutation(async ({ ctx, input }) => {
// Only update invoices owned by this user
const owned = await ctx.db.query.invoices.findMany({
where: inArray(invoices.id, input.ids),
columns: { id: true, createdById: true },
});
const ownedIds = owned
.filter((inv) => inv.createdById === ctx.session.user.id)
.map((inv) => inv.id);
if (ownedIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
}
await ctx.db
.update(invoices)
.set({ status: input.status, updatedAt: new Date() })
.where(inArray(invoices.id, ownedIds));
return { success: true, updated: ownedIds.length };
}),
bulkDelete: protectedProcedure
.input(z.object({ ids: z.array(z.string()).min(1) }))
.mutation(async ({ ctx, input }) => {
const owned = await ctx.db.query.invoices.findMany({
where: inArray(invoices.id, input.ids),
columns: { id: true, createdById: true },
});
const ownedIds = owned
.filter((inv) => inv.createdById === ctx.session.user.id)
.map((inv) => inv.id);
if (ownedIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
}
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
return { success: true, deleted: ownedIds.length };
}),
});
+103 -1
View File
@@ -39,7 +39,9 @@ export const usersRelations = relations(users, ({ many }) => ({
clients: many(clients),
businesses: many(businesses),
invoices: many(invoices),
sessions: many(sessions), // Added missing relation
sessions: many(sessions),
expenses: many(expenses),
invoiceTemplates: many(invoiceTemplates),
}));
export const accounts = createTable(
@@ -140,6 +142,7 @@ export const clients = createTable(
postalCode: d.varchar({ length: 20 }),
country: d.varchar({ length: 100 }),
defaultHourlyRate: d.real(),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d
.varchar({ length: 255 })
.notNull()
@@ -238,6 +241,7 @@ export const invoices = createTable(
totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0),
notes: d.varchar({ length: 1000 }),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d
.varchar({ length: 255 })
.notNull()
@@ -309,3 +313,101 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
references: [invoices.id],
}),
}));
export const expenses = createTable(
"expense",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
clientId: d.varchar({ length: 255 }).references(() => clients.id),
invoiceId: d
.varchar({ length: 255 })
.references(() => invoices.id, { onDelete: "set null" }),
date: d.timestamp().notNull(),
description: d.varchar({ length: 500 }).notNull(),
amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
category: d.varchar({ length: 100 }),
billable: d.boolean().default(false).notNull(),
reimbursable: d.boolean().default(false).notNull(),
notes: d.varchar({ length: 500 }),
createdById: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("expense_created_by_idx").on(t.createdById),
index("expense_client_id_idx").on(t.clientId),
index("expense_invoice_id_idx").on(t.invoiceId),
index("expense_date_idx").on(t.date),
index("expense_billable_idx").on(t.billable),
],
);
export const expensesRelations = relations(expenses, ({ one }) => ({
business: one(businesses, {
fields: [expenses.businessId],
references: [businesses.id],
}),
client: one(clients, {
fields: [expenses.clientId],
references: [clients.id],
}),
invoice: one(invoices, {
fields: [expenses.invoiceId],
references: [invoices.id],
}),
createdBy: one(users, {
fields: [expenses.createdById],
references: [users.id],
}),
}));
export const invoiceTemplates = createTable(
"invoice_template",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.varchar({ length: 255 }).notNull(),
type: d.varchar({ length: 50 }).notNull().default("notes"), // "notes" | "terms"
content: d.text().notNull(),
isDefault: d.boolean().default(false).notNull(),
createdById: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("invoice_template_created_by_idx").on(t.createdById),
index("invoice_template_type_idx").on(t.type),
],
);
export const invoiceTemplatesRelations = relations(
invoiceTemplates,
({ one }) => ({
createdBy: one(users, {
fields: [invoiceTemplates.createdById],
references: [users.id],
}),
}),
);