From 3c708b7914f63f7b01d929e5f14e2be01639dc2b Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Sat, 6 Jun 2026 17:18:32 -0400 Subject: [PATCH] time clock fixes --- drizzle/0011_time_entry_invoice_id.sql | 11 +++ drizzle/meta/_journal.json | 7 ++ src/app/api/mcp/route.ts | 2 +- src/app/dashboard/time-clock/page.tsx | 41 ++++++++-- src/server/api/routers/time-entries.ts | 100 +++++++++++++++++++++++-- src/server/db/schema.ts | 7 ++ 6 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 drizzle/0011_time_entry_invoice_id.sql diff --git a/drizzle/0011_time_entry_invoice_id.sql b/drizzle/0011_time_entry_invoice_id.sql new file mode 100644 index 0000000..1ab47b5 --- /dev/null +++ b/drizzle/0011_time_entry_invoice_id.sql @@ -0,0 +1,11 @@ +ALTER TABLE "beenvoice_time_entry" ADD COLUMN IF NOT EXISTS "invoiceId" varchar(255); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_time_entry_invoiceId_beenvoice_invoice_id_fk' + ) THEN + ALTER TABLE "beenvoice_time_entry" ADD CONSTRAINT "beenvoice_time_entry_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "time_entry_invoice_id_idx" ON "beenvoice_time_entry" USING btree ("invoiceId"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6b0aa49..5b5e08a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1780704000000, "tag": "0010_time_entries", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1749254400000, + "tag": "0011_time_entry_invoice_id", + "breakpoints": true } ] } diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 43cd483..37c94b0 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -428,7 +428,7 @@ const tools = { }), time_clock_out: defineTool({ description: - "Stop the currently running timer for the authenticated user. Returns the completed time entry with computed hours.", + "Stop the currently running timer for the authenticated user. Returns the completed time entry with computed hours. If the entry has a client, a line item is automatically added to their latest open invoice and the invoice is returned in the 'invoice' field.", inputSchema: { type: "object", properties: { diff --git a/src/app/dashboard/time-clock/page.tsx b/src/app/dashboard/time-clock/page.tsx index 1a07cb6..e37e555 100644 --- a/src/app/dashboard/time-clock/page.tsx +++ b/src/app/dashboard/time-clock/page.tsx @@ -26,8 +26,9 @@ import { import { NumberInput } from "~/components/ui/number-input"; import { DatePicker } from "~/components/ui/date-picker"; import { toast } from "sonner"; -import { Clock, Play, Square, Plus, Pencil, Trash2 } from "lucide-react"; +import { Clock, Play, Square, Plus, Pencil, Trash2, FileText } from "lucide-react"; import { formatCurrency } from "~/lib/currency"; +import Link from "next/link"; function formatElapsed(seconds: number) { const h = Math.floor(seconds / 3600); @@ -111,8 +112,19 @@ export default function TimeClockPage() { }); const clockOut = api.timeEntries.clockOut.useMutation({ - onSuccess: () => { - toast.success("Timer stopped"); + onSuccess: (data) => { + if (data.invoice) { + const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`; + toast.success("Timer stopped", { + description: `Added to invoice ${label}`, + action: { + label: "View Invoice", + onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), + }, + }); + } else { + toast.success("Timer stopped"); + } void utils.timeEntries.getRunning.invalidate(); void utils.timeEntries.getAll.invalidate(); void utils.timeEntries.getSummary.invalidate(); @@ -121,8 +133,19 @@ export default function TimeClockPage() { }); const create = api.timeEntries.create.useMutation({ - onSuccess: () => { - toast.success("Entry added"); + onSuccess: (data) => { + if (data.invoice) { + const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`; + toast.success("Entry added", { + description: `Added to invoice ${label}`, + action: { + label: "View Invoice", + onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), + }, + }); + } else { + toast.success("Entry added"); + } void utils.timeEntries.getAll.invalidate(); void utils.timeEntries.getSummary.invalidate(); setManualOpen(false); @@ -396,6 +419,14 @@ export default function TimeClockPage() { {entry.client.name} )} + {entry.invoice && ( + + + + {entry.invoice.invoicePrefix}{entry.invoice.invoiceNumber} + + + )}

{new Intl.DateTimeFormat("en-US", { diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts index 5d7b8fc..91239ff 100644 --- a/src/server/api/routers/time-entries.ts +++ b/src/server/api/routers/time-entries.ts @@ -1,8 +1,11 @@ import { z } from "zod"; -import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm"; +import { eq, and, desc, isNull, isNotNull, gte, lte, or } from "drizzle-orm"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { timeEntries, clients } from "~/server/db/schema"; +import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema"; import { TRPCError } from "@trpc/server"; +import { db } from "~/server/db"; + +type Db = typeof db; const createSchema = z.object({ description: z.string().max(500).default(""), @@ -21,6 +24,61 @@ function computeHours(startedAt: Date, endedAt: Date): number { return Math.max(0.25, Math.ceil(seconds / 900) * 0.25); } +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; + + const amount = hours * rate; + const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1); + + await database.insert(invoiceItems).values({ + invoiceId: invoice.id, + date, + description, + hours, + rate, + amount, + position: maxPosition + 1, + }); + + const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0) + amount; + const newTotal = subtotal + (subtotal * invoice.taxRate) / 100; + + await database + .update(invoices) + .set({ totalAmount: newTotal, updatedAt: new Date() }) + .where(eq(invoices.id, invoice.id)); + + await database + .update(timeEntries) + .set({ invoiceId: invoice.id, updatedAt: new Date() }) + .where(eq(timeEntries.id, entryId)); + + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + invoicePrefix: invoice.invoicePrefix ?? "#", + }; +} + export const timeEntriesRouter = createTRPCRouter({ getAll: protectedProcedure .input( @@ -40,7 +98,7 @@ export const timeEntriesRouter = createTRPCRouter({ return ctx.db.query.timeEntries.findMany({ where: and(...conditions), - with: { client: true }, + with: { client: true, invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } } }, orderBy: [desc(timeEntries.startedAt)], }); }), @@ -145,7 +203,23 @@ export const timeEntriesRouter = createTRPCRouter({ .where(eq(timeEntries.id, entry.id)) .returning(); - return updated; + 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, + ); + } + + return { entry: updated, invoice: linkedInvoice }; }), create: protectedProcedure @@ -178,7 +252,23 @@ export const timeEntriesRouter = createTRPCRouter({ }) .returning(); - return entry; + if (!entry) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Create failed" }); + + let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null; + if (clientId && hours && input.endedAt) { + linkedInvoice = await addEntryToLatestInvoice( + ctx.db, + ctx.session.user.id, + clientId, + entry.id, + input.description, + hours, + input.rate ?? 0, + input.endedAt, + ); + } + + return { entry, invoice: linkedInvoice }; }), update: protectedProcedure diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 6e62995..8ee1bd2 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -696,6 +696,9 @@ export const timeEntries = createTable( clientId: d .varchar({ length: 255 }) .references(() => clients.id, { onDelete: "set null" }), + invoiceId: d + .varchar({ length: 255 }) + .references(() => invoices.id, { onDelete: "set null" }), startedAt: d.timestamp().notNull(), endedAt: d.timestamp(), // null = currently running hours: d.real(), // stored when stopped @@ -724,6 +727,10 @@ export const timeEntriesRelations = relations(timeEntries, ({ one }) => ({ fields: [timeEntries.clientId], references: [clients.id], }), + invoice: one(invoices, { + fields: [timeEntries.invoiceId], + references: [invoices.id], + }), createdBy: one(users, { fields: [timeEntries.createdById], references: [users.id],