time clock fixes
This commit is contained in:
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user