Integrate time clock directly into invoices, remove standalone page

- Remove "Time Clock" from sidebar navigation
- Redirect /dashboard/time-clock to /dashboard/invoices
- Add InvoiceTimerCard to invoice detail page (shown for non-paid invoices)
- Timer started from an invoice is explicitly linked to that invoice at clock-in
- On clock-out, time is added directly to the linked invoice as a line item
- Active timer widget on dashboard now shows which invoice is being tracked
- Backend: clockIn accepts invoiceId; clockOut prefers explicit invoiceId over
  searching for the latest draft invoice for the client

https://claude.ai/code/session_014126WHVRT8mftmqkU6dajG
This commit is contained in:
Claude
2026-06-11 00:23:50 +00:00
parent 0d8efb0c5f
commit feb8f36ce7
6 changed files with 307 additions and 666 deletions
+88 -28
View File
@@ -24,28 +24,15 @@ function computeHours(startedAt: Date, endedAt: Date): number {
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
}
async function addEntryToLatestInvoice(
async function addEntryToInvoice(
database: Db,
userId: string,
clientId: string,
invoice: { id: string; invoiceNumber: string; invoicePrefix: string | null; taxRate: number; items: { amount: number; position: number }[] },
entryId: string,
description: string,
hours: number,
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.clientId, clientId),
eq(invoices.createdById, userId),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
),
with: { items: true },
orderBy: [desc(invoices.createdAt)],
});
if (!invoice) return null;
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
const amount = hours * rate;
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
@@ -79,6 +66,53 @@ async function addEntryToLatestInvoice(
};
}
async function addEntryToLatestInvoice(
database: Db,
userId: string,
clientId: string,
entryId: string,
description: string,
hours: number,
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.clientId, clientId),
eq(invoices.createdById, userId),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
),
with: { items: true },
orderBy: [desc(invoices.createdAt)],
});
if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
}
async function addEntryToSpecificInvoice(
database: Db,
userId: string,
invoiceId: string,
entryId: string,
description: string,
hours: number,
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, userId),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
),
with: { items: true },
});
if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
}
export const timeEntriesRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
@@ -123,7 +157,10 @@ export const timeEntriesRouter = createTRPCRouter({
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
with: { client: true },
with: {
client: true,
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } },
},
});
}),
@@ -132,6 +169,7 @@ export const timeEntriesRouter = createTRPCRouter({
z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
invoiceId: z.string().optional(),
rate: z.number().min(0).optional(),
startedAt: z.date().optional(),
}),
@@ -158,6 +196,14 @@ export const timeEntriesRouter = createTRPCRouter({
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
const invoiceId = input.invoiceId ?? null;
if (invoiceId) {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id)),
});
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
}
const startedAt = input.startedAt ?? new Date();
if (startedAt > new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" });
@@ -168,6 +214,7 @@ export const timeEntriesRouter = createTRPCRouter({
.values({
description: input.description,
clientId,
invoiceId,
startedAt,
rate: input.rate ?? null,
createdById: ctx.session.user.id,
@@ -212,17 +259,30 @@ export const timeEntriesRouter = createTRPCRouter({
if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" });
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
if (entry.clientId && hours > 0) {
linkedInvoice = await addEntryToLatestInvoice(
ctx.db,
ctx.session.user.id,
entry.clientId,
updated.id,
description,
hours,
entry.rate ?? 0,
endedAt,
);
if (hours > 0) {
if (entry.invoiceId) {
linkedInvoice = await addEntryToSpecificInvoice(
ctx.db,
ctx.session.user.id,
entry.invoiceId,
updated.id,
description,
hours,
entry.rate ?? 0,
endedAt,
);
} else if (entry.clientId) {
linkedInvoice = await addEntryToLatestInvoice(
ctx.db,
ctx.session.user.id,
entry.clientId,
updated.id,
description,
hours,
entry.rate ?? 0,
endedAt,
);
}
}
return { entry: updated, invoice: linkedInvoice };