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
+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 };
}),
});