From 61733979cbb18c6861a87ad05fcee656c828e2a5 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 15 Jul 2025 20:24:43 -0400 Subject: [PATCH] Add default hourly rate to client model The changes add a new defaultHourlyRate field to clients, including form updates and automatic rate propagation to invoices. Add default hourly rate for clients The subject line you provided already clearly expresses the changes, and no additional context is needed in the body, so I'll keep just the subject line. --- src/components/forms/client-form.tsx | 66 ++++++++++++++++-- src/components/forms/invoice-form.tsx | 26 ++++++- src/server/api/routers/clients.ts | 99 ++++++++++++++++++--------- src/server/db/schema.ts | 37 +++++++--- 4 files changed, 176 insertions(+), 52 deletions(-) diff --git a/src/components/forms/client-form.tsx b/src/components/forms/client-form.tsx index b00beda..3f9a63b 100644 --- a/src/components/forms/client-form.tsx +++ b/src/components/forms/client-form.tsx @@ -1,7 +1,7 @@ "use client"; -import { UserPlus, Mail, Phone, Save, Loader2, ArrowLeft } from "lucide-react"; -import Link from "next/link"; +import { UserPlus, Save, Loader2, ArrowLeft, DollarSign } from "lucide-react"; + import { useRouter } from "next/navigation"; import { useEffect, useState, useRef } from "react"; import { toast } from "sonner"; @@ -12,6 +12,7 @@ import { Label } from "~/components/ui/label"; import { FormSkeleton } from "~/components/ui/skeleton"; import { AddressForm } from "~/components/forms/address-form"; import { FloatingActionBar } from "~/components/layout/floating-action-bar"; +import { NumberInput } from "~/components/ui/number-input"; import { api } from "~/trpc/react"; import { formatPhoneNumber, @@ -35,6 +36,7 @@ interface FormData { state: string; postalCode: string; country: string; + defaultHourlyRate: number; } interface FormErrors { @@ -46,6 +48,7 @@ interface FormErrors { state?: string; postalCode?: string; country?: string; + defaultHourlyRate?: string; } const initialFormData: FormData = { @@ -58,6 +61,7 @@ const initialFormData: FormData = { state: "", postalCode: "", country: "United States", + defaultHourlyRate: 100, }; export function ClientForm({ clientId, mode }: ClientFormProps) { @@ -108,11 +112,12 @@ export function ClientForm({ clientId, mode }: ClientFormProps) { state: client.state ?? "", postalCode: client.postalCode ?? "", country: client.country ?? "United States", + defaultHourlyRate: client.defaultHourlyRate ?? 100, }); } }, [client, mode]); - const handleInputChange = (field: string, value: string) => { + const handleInputChange = (field: string, value: string | number) => { setFormData((prev) => ({ ...prev, [field]: value })); setIsDirty(true); @@ -225,7 +230,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
Basic Information

- Enter the client's primary details + Enter the client's primary details

@@ -322,7 +327,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
Address

- Client's physical location + Client's physical location

@@ -341,6 +346,49 @@ export function ClientForm({ clientId, mode }: ClientFormProps) { /> + + {/* Billing Information */} + + +
+
+ +
+
+ Billing Information +

+ Default billing rates for this client +

+
+
+
+ +
+ + + handleInputChange("defaultHourlyRate", value) + } + min={0} + step={1} + prefix="$" + width="full" + disabled={isSubmitting} + /> + {errors.defaultHourlyRate && ( +

+ {errors.defaultHourlyRate} +

+ )} +
+
+
{/* Form Actions - original position */} @@ -411,12 +459,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) { {isSubmitting ? ( <> - {mode === "create" ? "Creating..." : "Saving..."} + + {mode === "create" ? "Creating..." : "Saving..."} + ) : ( <> - {mode === "create" ? "Create Client" : "Save Changes"} + + {mode === "create" ? "Create Client" : "Save Changes"} + )} diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx index 5648134..926b0e2 100644 --- a/src/components/forms/invoice-form.tsx +++ b/src/components/forms/invoice-form.tsx @@ -281,6 +281,20 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) { } }, [businesses, formData.businessId, invoiceId]); + // Update default hourly rate when client changes + React.useEffect(() => { + if (formData.clientId && clients) { + const selectedClient = clients.find((c) => c.id === formData.clientId); + if (selectedClient?.defaultHourlyRate) { + setFormData((prev) => ({ + ...prev, + defaultHourlyRate: selectedClient.defaultHourlyRate, + })); + } + } + + }, [formData.clientId, clients]); + // Calculate totals const totals = React.useMemo(() => { const subtotal = formData.items.reduce( @@ -339,7 +353,11 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) { if (idx === 0) return; // Already at top setFormData((prev) => { const newItems = [...prev.items]; - [newItems[idx - 1], newItems[idx]] = [newItems[idx], newItems[idx - 1]]; + if (idx > 0 && idx < newItems.length) { + const temp = newItems[idx - 1]!; + newItems[idx - 1] = newItems[idx]!; + newItems[idx] = temp; + } return { ...prev, items: newItems }; }); }; @@ -349,7 +367,11 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) { if (idx === formData.items.length - 1) return; // Already at bottom setFormData((prev) => { const newItems = [...prev.items]; - [newItems[idx], newItems[idx + 1]] = [newItems[idx + 1], newItems[idx]]; + if (idx >= 0 && idx < newItems.length - 1) { + const temp = newItems[idx]!; + newItems[idx] = newItems[idx + 1]!; + newItems[idx + 1] = temp; + } return { ...prev, items: newItems }; }); }; diff --git a/src/server/api/routers/clients.ts b/src/server/api/routers/clients.ts index 1a1a316..a9b0cf9 100644 --- a/src/server/api/routers/clients.ts +++ b/src/server/api/routers/clients.ts @@ -7,13 +7,42 @@ import { TRPCError } from "@trpc/server"; const createClientSchema = z.object({ name: z.string().min(1, "Name is required").max(255, "Name is too long"), email: z.string().email("Invalid email").optional().or(z.literal("")), - phone: z.string().max(50, "Phone number is too long").optional().or(z.literal("")), - addressLine1: z.string().max(255, "Address is too long").optional().or(z.literal("")), - addressLine2: z.string().max(255, "Address is too long").optional().or(z.literal("")), - city: z.string().max(100, "City name is too long").optional().or(z.literal("")), - state: z.string().max(50, "State name is too long").optional().or(z.literal("")), - postalCode: z.string().max(20, "Postal code is too long").optional().or(z.literal("")), - country: z.string().max(100, "Country name is too long").optional().or(z.literal("")), + phone: z + .string() + .max(50, "Phone number is too long") + .optional() + .or(z.literal("")), + addressLine1: z + .string() + .max(255, "Address is too long") + .optional() + .or(z.literal("")), + addressLine2: z + .string() + .max(255, "Address is too long") + .optional() + .or(z.literal("")), + city: z + .string() + .max(100, "City name is too long") + .optional() + .or(z.literal("")), + state: z + .string() + .max(50, "State name is too long") + .optional() + .or(z.literal("")), + postalCode: z + .string() + .max(20, "Postal code is too long") + .optional() + .or(z.literal("")), + country: z + .string() + .max(100, "Country name is too long") + .optional() + .or(z.literal("")), + defaultHourlyRate: z.number().min(0, "Rate must be positive").default(100), }); const updateClientSchema = createClientSchema.partial().extend({ @@ -23,10 +52,10 @@ const updateClientSchema = createClientSchema.partial().extend({ export const clientsRouter = createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) => { try { - return await ctx.db.query.clients.findMany({ - where: eq(clients.createdById, ctx.session.user.id), - orderBy: (clients, { desc }) => [desc(clients.createdAt)], - }); + return await ctx.db.query.clients.findMany({ + where: eq(clients.createdById, ctx.session.user.id), + orderBy: (clients, { desc }) => [desc(clients.createdAt)], + }); } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", @@ -41,13 +70,13 @@ export const clientsRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { try { const client = await ctx.db.query.clients.findFirst({ - where: eq(clients.id, input.id), - with: { - invoices: { - orderBy: (invoices, { desc }) => [desc(invoices.createdAt)], + where: eq(clients.id, input.id), + with: { + invoices: { + orderBy: (invoices, { desc }) => [desc(invoices.createdAt)], + }, }, - }, - }); + }); if (!client) { throw new TRPCError({ @@ -84,14 +113,17 @@ export const clientsRouter = createTRPCRouter({ Object.entries(input).map(([key, value]) => [ key, value === "" ? null : value, - ]) + ]), ); - const [client] = await ctx.db.insert(clients).values({ - name: input.name, // Ensure name is included - ...cleanInput, - createdById: ctx.session.user.id, - }).returning(); + const [client] = await ctx.db + .insert(clients) + .values({ + name: input.name, // Ensure name is included + ...cleanInput, + createdById: ctx.session.user.id, + }) + .returning(); if (!client) { throw new TRPCError({ @@ -107,7 +139,7 @@ export const clientsRouter = createTRPCRouter({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create client", cause: error, - }); + }); } }), @@ -115,7 +147,7 @@ export const clientsRouter = createTRPCRouter({ .input(updateClientSchema) .mutation(async ({ ctx, input }) => { try { - const { id, ...data } = input; + const { id, ...data } = input; // Verify client exists and belongs to user const existingClient = await ctx.db.query.clients.findFirst({ @@ -141,15 +173,15 @@ export const clientsRouter = createTRPCRouter({ Object.entries(data).map(([key, value]) => [ key, value === "" ? null : value, - ]) + ]), ); const [updatedClient] = await ctx.db - .update(clients) - .set({ + .update(clients) + .set({ ...cleanData, - updatedAt: new Date(), - }) + updatedAt: new Date(), + }) .where(eq(clients.id, id)) .returning(); @@ -202,12 +234,13 @@ export const clientsRouter = createTRPCRouter({ if (clientInvoices.length > 0) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Cannot delete client with existing invoices. Please delete or reassign the invoices first.", + message: + "Cannot delete client with existing invoices. Please delete or reassign the invoices first.", }); } await ctx.db.delete(clients).where(eq(clients.id, input.id)); - + return { success: true }; } catch (error) { if (error instanceof TRPCError) throw error; @@ -218,4 +251,4 @@ export const clientsRouter = createTRPCRouter({ }); } }), -}); \ No newline at end of file +}); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index ec142aa..28d0980 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -106,6 +106,7 @@ export const clients = createTable( state: d.text({ length: 50 }), postalCode: d.text({ length: 20 }), country: d.text({ length: 100 }), + defaultHourlyRate: d.real().notNull().default(100.0), createdById: d .text({ length: 255 }) .notNull() @@ -124,7 +125,10 @@ export const clients = createTable( ); export const clientsRelations = relations(clients, ({ one, many }) => ({ - createdBy: one(users, { fields: [clients.createdById], references: [users.id] }), + createdBy: one(users, { + fields: [clients.createdById], + references: [users.id], + }), invoices: many(invoices), })); @@ -168,7 +172,10 @@ export const businesses = createTable( ); export const businessesRelations = relations(businesses, ({ one, many }) => ({ - createdBy: one(users, { fields: [businesses.createdById], references: [users.id] }), + createdBy: one(users, { + fields: [businesses.createdById], + references: [users.id], + }), invoices: many(invoices), })); @@ -181,9 +188,7 @@ export const invoices = createTable( .primaryKey() .$defaultFn(() => crypto.randomUUID()), invoiceNumber: d.text({ length: 100 }).notNull(), - businessId: d - .text({ length: 255 }) - .references(() => businesses.id), + businessId: d.text({ length: 255 }).references(() => businesses.id), clientId: d .text({ length: 255 }) .notNull() @@ -192,7 +197,7 @@ export const invoices = createTable( dueDate: d.integer({ mode: "timestamp" }).notNull(), status: d.text({ length: 50 }).notNull().default("draft"), // draft, sent, paid, overdue totalAmount: d.real().notNull().default(0), - taxRate: d.real().notNull().default(0.00), + taxRate: d.real().notNull().default(0.0), notes: d.text({ length: 1000 }), createdById: d .text({ length: 255 }) @@ -214,9 +219,18 @@ export const invoices = createTable( ); export const invoicesRelations = relations(invoices, ({ one, many }) => ({ - business: one(businesses, { fields: [invoices.businessId], references: [businesses.id] }), - client: one(clients, { fields: [invoices.clientId], references: [clients.id] }), - createdBy: one(users, { fields: [invoices.createdById], references: [users.id] }), + business: one(businesses, { + fields: [invoices.businessId], + references: [businesses.id], + }), + client: one(clients, { + fields: [invoices.clientId], + references: [clients.id], + }), + createdBy: one(users, { + fields: [invoices.createdById], + references: [users.id], + }), items: many(invoiceItems), })); @@ -251,5 +265,8 @@ export const invoiceItems = createTable( ); export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({ - invoice: one(invoices, { fields: [invoiceItems.invoiceId], references: [invoices.id] }), + invoice: one(invoices, { + fields: [invoiceItems.invoiceId], + references: [invoices.id], + }), }));