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:
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user