time clock fixes

This commit is contained in:
2026-06-06 17:18:32 -04:00
parent b0e026c963
commit 3c708b7914
6 changed files with 157 additions and 11 deletions
+11
View File
@@ -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");
+7
View File
@@ -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
}
]
}
+1 -1
View File
@@ -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: {
+36 -5
View File
@@ -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}
</Badge>
)}
{entry.invoice && (
<Link href={`/dashboard/invoices/${entry.invoice.id}`}>
<Badge variant="outline" className="gap-1 text-xs hover:bg-accent">
<FileText className="h-3 w-3" />
{entry.invoice.invoicePrefix}{entry.invoice.invoiceNumber}
</Badge>
</Link>
)}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", {
+95 -5
View File
@@ -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
+7
View File
@@ -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],