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