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
121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
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 };
|
|
}),
|
|
});
|