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