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,
|
"when": 1780704000000,
|
||||||
"tag": "0010_time_entries",
|
"tag": "0010_time_entries",
|
||||||
"breakpoints": true
|
"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({
|
time_clock_out: defineTool({
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ import {
|
|||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { toast } from "sonner";
|
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 { formatCurrency } from "~/lib/currency";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
function formatElapsed(seconds: number) {
|
function formatElapsed(seconds: number) {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
@@ -111,8 +112,19 @@ export default function TimeClockPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: () => {
|
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");
|
toast.success("Timer stopped");
|
||||||
|
}
|
||||||
void utils.timeEntries.getRunning.invalidate();
|
void utils.timeEntries.getRunning.invalidate();
|
||||||
void utils.timeEntries.getAll.invalidate();
|
void utils.timeEntries.getAll.invalidate();
|
||||||
void utils.timeEntries.getSummary.invalidate();
|
void utils.timeEntries.getSummary.invalidate();
|
||||||
@@ -121,8 +133,19 @@ export default function TimeClockPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const create = api.timeEntries.create.useMutation({
|
const create = api.timeEntries.create.useMutation({
|
||||||
onSuccess: () => {
|
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");
|
toast.success("Entry added");
|
||||||
|
}
|
||||||
void utils.timeEntries.getAll.invalidate();
|
void utils.timeEntries.getAll.invalidate();
|
||||||
void utils.timeEntries.getSummary.invalidate();
|
void utils.timeEntries.getSummary.invalidate();
|
||||||
setManualOpen(false);
|
setManualOpen(false);
|
||||||
@@ -396,6 +419,14 @@ export default function TimeClockPage() {
|
|||||||
{entry.client.name}
|
{entry.client.name}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
{new Intl.DateTimeFormat("en-US", {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { z } from "zod";
|
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 { 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 { TRPCError } from "@trpc/server";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
type Db = typeof db;
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
description: z.string().max(500).default(""),
|
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);
|
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({
|
export const timeEntriesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure
|
getAll: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -40,7 +98,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return ctx.db.query.timeEntries.findMany({
|
return ctx.db.query.timeEntries.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
with: { client: true },
|
with: { client: true, invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } } },
|
||||||
orderBy: [desc(timeEntries.startedAt)],
|
orderBy: [desc(timeEntries.startedAt)],
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -145,7 +203,23 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
.where(eq(timeEntries.id, entry.id))
|
.where(eq(timeEntries.id, entry.id))
|
||||||
.returning();
|
.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
|
create: protectedProcedure
|
||||||
@@ -178,7 +252,23 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.returning();
|
.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
|
update: protectedProcedure
|
||||||
|
|||||||
@@ -696,6 +696,9 @@ export const timeEntries = createTable(
|
|||||||
clientId: d
|
clientId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.references(() => clients.id, { onDelete: "set null" }),
|
.references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
invoiceId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.references(() => invoices.id, { onDelete: "set null" }),
|
||||||
startedAt: d.timestamp().notNull(),
|
startedAt: d.timestamp().notNull(),
|
||||||
endedAt: d.timestamp(), // null = currently running
|
endedAt: d.timestamp(), // null = currently running
|
||||||
hours: d.real(), // stored when stopped
|
hours: d.real(), // stored when stopped
|
||||||
@@ -724,6 +727,10 @@ export const timeEntriesRelations = relations(timeEntries, ({ one }) => ({
|
|||||||
fields: [timeEntries.clientId],
|
fields: [timeEntries.clientId],
|
||||||
references: [clients.id],
|
references: [clients.id],
|
||||||
}),
|
}),
|
||||||
|
invoice: one(invoices, {
|
||||||
|
fields: [timeEntries.invoiceId],
|
||||||
|
references: [invoices.id],
|
||||||
|
}),
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [timeEntries.createdById],
|
fields: [timeEntries.createdById],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
|||||||
Reference in New Issue
Block a user