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.
This commit is contained in:
2025-07-15 20:24:43 -04:00
parent 0d2b5de740
commit 61733979cb
4 changed files with 176 additions and 52 deletions

View File

@@ -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({
});
}
}),
});
});

View File

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