mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-14 01:54:43 -05:00
- Introduce new 'xs' screen size in Tailwind configuration for better mobile support - Update dashboard layout to use a cosmic gradient background for a modern look - Refactor Quick Actions component for improved styling and layout consistency - Add Current Open Invoice Card for quick access to ongoing invoices - Standardize button sizes across various components for a cohesive user experience - Implement responsive design adjustments in invoice forms and data tables This update enhances the overall user experience by improving responsiveness and visual appeal across the dashboard and related components.
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
import { z } from "zod";
|
|
import { eq } from "drizzle-orm";
|
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
|
import {
|
|
invoices,
|
|
invoiceItems,
|
|
clients,
|
|
businesses,
|
|
} from "~/server/db/schema";
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
const invoiceItemSchema = z.object({
|
|
date: z.date(),
|
|
description: z.string().min(1, "Description is required"),
|
|
hours: z.number().min(0, "Hours must be positive"),
|
|
rate: z.number().min(0, "Rate must be positive"),
|
|
});
|
|
|
|
const createInvoiceSchema = z.object({
|
|
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
|
businessId: z.string().min(1, "Business is required").optional(),
|
|
clientId: z.string().min(1, "Client is required"),
|
|
issueDate: z.date(),
|
|
dueDate: z.date(),
|
|
status: z.enum(["draft", "sent", "paid", "overdue"]).default("draft"),
|
|
notes: z.string().optional(),
|
|
taxRate: z.number().min(0).max(100).default(0),
|
|
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
|
});
|
|
|
|
const updateInvoiceSchema = createInvoiceSchema.partial().extend({
|
|
id: z.string(),
|
|
});
|
|
|
|
const updateStatusSchema = z.object({
|
|
id: z.string(),
|
|
status: z.enum(["draft", "sent", "paid", "overdue"]),
|
|
});
|
|
|
|
export const invoicesRouter = createTRPCRouter({
|
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
|
try {
|
|
return await ctx.db.query.invoices.findMany({
|
|
where: eq(invoices.createdById, ctx.session.user.id),
|
|
with: {
|
|
business: true,
|
|
client: true,
|
|
items: true,
|
|
},
|
|
orderBy: (invoices, { desc }) => [desc(invoices.issueDate)],
|
|
});
|
|
} catch (error) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to fetch invoices",
|
|
cause: error,
|
|
});
|
|
}
|
|
}),
|
|
|
|
getCurrentOpen: protectedProcedure.query(async ({ ctx }) => {
|
|
try {
|
|
// Get the most recent draft invoice
|
|
const currentInvoice = await ctx.db.query.invoices.findFirst({
|
|
where: eq(invoices.createdById, ctx.session.user.id),
|
|
with: {
|
|
business: true,
|
|
client: true,
|
|
items: {
|
|
orderBy: (items, { asc }) => [asc(items.position)],
|
|
},
|
|
},
|
|
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
|
|
});
|
|
|
|
// Return null if no draft invoice exists
|
|
if (!currentInvoice || currentInvoice.status !== "draft") {
|
|
return null;
|
|
}
|
|
|
|
return currentInvoice;
|
|
} catch (error) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to fetch current open invoice",
|
|
cause: error,
|
|
});
|
|
}
|
|
}),
|
|
|
|
getById: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
try {
|
|
const invoice = await ctx.db.query.invoices.findFirst({
|
|
where: eq(invoices.id, input.id),
|
|
with: {
|
|
business: true,
|
|
client: true,
|
|
items: {
|
|
orderBy: (items, { asc }) => [asc(items.position)],
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!invoice) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Invoice not found",
|
|
});
|
|
}
|
|
|
|
// Check if user owns this invoice
|
|
if (invoice.createdById !== ctx.session.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You don't have permission to view this invoice",
|
|
});
|
|
}
|
|
|
|
return invoice;
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) throw error;
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to fetch invoice",
|
|
cause: error,
|
|
});
|
|
}
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(createInvoiceSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
try {
|
|
const { items, ...invoiceData } = input;
|
|
|
|
// Verify business exists and belongs to user (if provided)
|
|
if (invoiceData.businessId) {
|
|
const business = await ctx.db.query.businesses.findFirst({
|
|
where: eq(businesses.id, invoiceData.businessId),
|
|
});
|
|
|
|
if (!business) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Business not found",
|
|
});
|
|
}
|
|
|
|
if (business.createdById !== ctx.session.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message:
|
|
"You don't have permission to create invoices for this business",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Verify client exists and belongs to user
|
|
const client = await ctx.db.query.clients.findFirst({
|
|
where: eq(clients.id, invoiceData.clientId),
|
|
});
|
|
|
|
if (!client) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Client not found",
|
|
});
|
|
}
|
|
|
|
if (client.createdById !== ctx.session.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message:
|
|
"You don't have permission to create invoices for this client",
|
|
});
|
|
}
|
|
|
|
// Calculate subtotal and tax
|
|
const subtotal = items.reduce(
|
|
(sum, item) => sum + item.hours * item.rate,
|
|
0,
|
|
);
|
|
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
|
|
const totalAmount = subtotal + taxAmount;
|
|
|
|
// Create invoice
|
|
const [invoice] = await ctx.db
|
|
.insert(invoices)
|
|
.values({
|
|
...invoiceData,
|
|
totalAmount,
|
|
createdById: ctx.session.user.id,
|
|
})
|
|
.returning();
|
|
|
|
if (!invoice) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to create invoice",
|
|
});
|
|
}
|
|
|
|
// Create invoice items
|
|
const itemsToInsert = items.map((item, idx) => ({
|
|
...item,
|
|
invoiceId: invoice.id,
|
|
amount: item.hours * item.rate,
|
|
position: idx,
|
|
}));
|
|
|
|
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
|
|
|
return invoice;
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) throw error;
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to create invoice",
|
|
cause: error,
|
|
});
|
|
}
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(updateInvoiceSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
try {
|
|
const { id, items, ...invoiceData } = input;
|
|
|
|
// Verify invoice exists and belongs to user
|
|
const existingInvoice = await ctx.db.query.invoices.findFirst({
|
|
where: eq(invoices.id, id),
|
|
});
|
|
|
|
if (!existingInvoice) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Invoice not found",
|
|
});
|
|
}
|
|
|
|
if (existingInvoice.createdById !== ctx.session.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You don't have permission to update this invoice",
|
|
});
|
|
}
|
|
|
|
// If business is being updated, verify it belongs to user
|
|
if (invoiceData.businessId) {
|
|
const business = await ctx.db.query.businesses.findFirst({
|
|
where: eq(businesses.id, invoiceData.businessId),
|
|
});
|
|
|
|
if (!business || business.createdById !== ctx.session.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You don't have permission to use this business",
|
|
});
|
|
}
|
|
}
|
|
|
|
// If client is being updated, verify it belongs to user
|
|
if (invoiceData.clientId) {
|
|
const client = await ctx.db.query.clients.findFirst({
|
|
where: eq(clients.id, invoiceData.clientId),
|
|
});
|
|
|
|
if (!client || client.createdById !== ctx.session.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You don't have permission to use this client",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (items) {
|
|
// Calculate subtotal and tax
|
|
const subtotal = items.reduce(
|
|
(sum, item) => sum + item.hours * item.rate,
|
|
0,
|
|
);
|
|
const taxAmount =
|
|
(subtotal * (invoiceData.taxRate ?? existingInvoice.taxRate)) / 100;
|
|
const totalAmount = subtotal + taxAmount;
|
|
|
|
// Update invoice
|
|
await ctx.db
|
|
.update(invoices)
|
|
.set({
|
|
...invoiceData,
|
|
totalAmount,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(invoices.id, id));
|
|
|
|
// Delete existing items and create new ones
|
|
await ctx.db
|
|
.delete(invoiceItems)
|
|
.where(eq(invoiceItems.invoiceId, id));
|
|
|
|
const itemsToInsert = items.map((item, idx) => ({
|
|
...item,
|
|
invoiceId: id,
|
|
amount: item.hours * item.rate,
|
|
position: idx,
|
|
}));
|
|
|
|
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
|
} else {
|
|
// Update invoice without items
|
|
await ctx.db
|
|
.update(invoices)
|
|
.set({
|
|
...invoiceData,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(invoices.id, id));
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) throw error;
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to update invoice",
|
|
cause: error,
|
|
});
|
|
}
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.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",
|
|
});
|
|
}
|
|
|
|
if (invoice.createdById !== ctx.session.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You don't have permission to delete this invoice",
|
|
});
|
|
}
|
|
|
|
// Items will be deleted automatically due to cascade
|
|
await ctx.db.delete(invoices).where(eq(invoices.id, input.id));
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) throw error;
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to delete invoice",
|
|
cause: error,
|
|
});
|
|
}
|
|
}),
|
|
|
|
updateStatus: protectedProcedure
|
|
.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",
|
|
});
|
|
}
|
|
|
|
if (invoice.createdById !== ctx.session.user.id) {
|
|
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(),
|
|
})
|
|
.where(eq(invoices.id, input.id));
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) throw error;
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to update invoice status",
|
|
cause: error,
|
|
});
|
|
}
|
|
}),
|
|
});
|