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 && (
+
+
{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],