From feb8f36ce7b98431c53afbfeb44b8de7ece3c359 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 00:23:50 +0000 Subject: [PATCH] Integrate time clock directly into invoices, remove standalone page - Remove "Time Clock" from sidebar navigation - Redirect /dashboard/time-clock to /dashboard/invoices - Add InvoiceTimerCard to invoice detail page (shown for non-paid invoices) - Timer started from an invoice is explicitly linked to that invoice at clock-in - On clock-out, time is added directly to the linked invoice as a line item - Active timer widget on dashboard now shows which invoice is being tracked - Backend: clockIn accepts invoiceId; clockOut prefers explicit invoiceId over searching for the latest draft invoice for the client https://claude.ai/code/session_014126WHVRT8mftmqkU6dajG --- .../_components/active-timer-widget.tsx | 49 +- .../[id]/_components/invoice-timer-card.tsx | 178 +++++ src/app/dashboard/invoices/[id]/page.tsx | 9 + src/app/dashboard/time-clock/page.tsx | 619 +----------------- src/lib/navigation.ts | 2 - src/server/api/routers/time-entries.ts | 116 +++- 6 files changed, 307 insertions(+), 666 deletions(-) create mode 100644 src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx diff --git a/src/app/dashboard/_components/active-timer-widget.tsx b/src/app/dashboard/_components/active-timer-widget.tsx index 843b04f..1c2cd20 100644 --- a/src/app/dashboard/_components/active-timer-widget.tsx +++ b/src/app/dashboard/_components/active-timer-widget.tsx @@ -58,6 +58,10 @@ export function ActiveTimerWidget() { if (isLoading || !running) return null; + const invoiceLabel = running.invoice + ? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` + : null; + return ( @@ -77,11 +81,23 @@ export function ActiveTimerWidget() { )}

- Started{" "} - {new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", - }).format(new Date(running.startedAt))} + {invoiceLabel ? ( + <>Tracking for{" "} + + {invoiceLabel} + + + ) : ( + <>Started{" "} + {new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }).format(new Date(running.startedAt))} + + )}

@@ -89,20 +105,15 @@ export function ActiveTimerWidget() { {formatElapsed(elapsed)} -
- - -
+
); diff --git a/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx b/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx new file mode 100644 index 0000000..00b033b --- /dev/null +++ b/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { api } from "~/trpc/react"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { NumberInput } from "~/components/ui/number-input"; +import { Label } from "~/components/ui/label"; +import { Clock, Play, Square } from "lucide-react"; +import { toast } from "sonner"; + +function formatElapsed(seconds: number) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":"); +} + +interface InvoiceTimerCardProps { + invoiceId: string; + clientId: string; + defaultRate?: number | null; +} + +export function InvoiceTimerCard({ invoiceId, clientId, defaultRate }: InvoiceTimerCardProps) { + const utils = api.useUtils(); + const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, { + refetchInterval: 30_000, + }); + + const [description, setDescription] = useState(""); + const [rate, setRate] = useState(defaultRate ?? 0); + const [elapsed, setElapsed] = useState(0); + const intervalRef = useRef | null>(null); + + const isThisInvoice = running?.invoiceId === invoiceId; + + useEffect(() => { + if (intervalRef.current) clearInterval(intervalRef.current); + if (isThisInvoice && running) { + const tick = () => + setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); + tick(); + intervalRef.current = setInterval(tick, 1000); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [isThisInvoice, running]); + + const clockIn = api.timeEntries.clockIn.useMutation({ + onSuccess: () => { + void utils.timeEntries.getRunning.invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + + const clockOut = api.timeEntries.clockOut.useMutation({ + onSuccess: (data) => { + if (data.invoice) { + toast.success("Time added to invoice"); + } else { + toast.success("Timer stopped"); + } + void utils.timeEntries.getRunning.invalidate(); + void utils.invoices.getById.invalidate({ id: invoiceId }); + }, + onError: (e) => toast.error(e.message), + }); + + if (isLoading) return null; + + // Another timer is running for a different invoice + if (running && !isThisInvoice) { + return ( + + + + + Timer + + + +

+ A timer is already running + {running.invoice + ? ` for ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` + : ""} + . Stop it before starting a new one. +

+
+
+ ); + } + + if (isThisInvoice && running) { + return ( + + + + + + + + Timer Running + + + +
+
+

{running.description || No description}

+
+ + {formatElapsed(elapsed)} + +
+ +
+
+ ); + } + + return ( + + + + + Track Time + + + +
+ + setDescription(e.target.value)} + placeholder="e.g. Frontend development" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + clockIn.mutate({ description, clientId, invoiceId, rate: rate || undefined }); + } + }} + /> +
+
+ + setRate(v)} + min={0} + step={0.01} + placeholder="0.00" + /> +
+ +
+
+ ); +} diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index 1c8005f..992fb44 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -61,6 +61,7 @@ import type { StoredInvoiceStatus } from "~/types/invoice"; import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton"; import { PDFDownloadButton } from "./_components/pdf-download-button"; import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button"; +import { InvoiceTimerCard } from "./_components/invoice-timer-card"; const PAYMENT_METHODS = [ { value: "cash", label: "Cash" }, @@ -521,6 +522,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { {/* Right Column - Actions */}
+ {effectiveStatus !== "paid" && ( + + )} + diff --git a/src/app/dashboard/time-clock/page.tsx b/src/app/dashboard/time-clock/page.tsx index 47117ba..9363815 100644 --- a/src/app/dashboard/time-clock/page.tsx +++ b/src/app/dashboard/time-clock/page.tsx @@ -1,620 +1,5 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { api } from "~/trpc/react"; -import { PageHeader } from "~/components/layout/page-header"; -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Badge } from "~/components/ui/badge"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "~/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; -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, FileText } from "lucide-react"; -import { formatCurrency } from "~/lib/currency"; -import Link from "next/link"; - -function formatElapsed(seconds: number) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":"); -} - -function formatDuration(hours: number | null | undefined) { - if (!hours) return "—"; - const h = Math.floor(hours); - const m = Math.round((hours - h) * 60); - if (h === 0) return `${m}m`; - if (m === 0) return `${h}h`; - return `${h}h ${m}m`; -} - -interface ManualEntryForm { - description: string; - clientId: string; - startedAt: Date; - endedAt: Date | undefined; - rate: number; - notes: string; -} - -const defaultManualForm: ManualEntryForm = { - description: "", - clientId: "", - startedAt: new Date(), - endedAt: undefined, - rate: 0, - notes: "", -}; +import { redirect } from "next/navigation"; export default function TimeClockPage() { - const utils = api.useUtils(); - - const { data: running, isLoading: runningLoading } = - api.timeEntries.getRunning.useQuery(undefined, { refetchInterval: 30_000 }); - const { data: entries = [], isLoading: entriesLoading } = - api.timeEntries.getAll.useQuery(); - const { data: summary } = api.timeEntries.getSummary.useQuery(); - const { data: clients = [] } = api.clients.getAll.useQuery(); - - const [clockInDesc, setClockInDesc] = useState(""); - const [clockInClientId, setClockInClientId] = useState(""); - const [clockInRate, setClockInRate] = useState(0); - const [elapsed, setElapsed] = useState(0); - - const [manualOpen, setManualOpen] = useState(false); - const [editId, setEditId] = useState(null); - const [manualForm, setManualForm] = useState(defaultManualForm); - const [deleteId, setDeleteId] = useState(null); - - const intervalRef = useRef | null>(null); - - useEffect(() => { - if (running) { - const tick = () => { - setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); - }; - tick(); - intervalRef.current = setInterval(tick, 1000); - } - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [running]); - - const clockIn = api.timeEntries.clockIn.useMutation({ - onSuccess: () => { - toast.success("Timer started"); - void utils.timeEntries.getRunning.invalidate(); - void utils.timeEntries.getAll.invalidate(); - setClockInDesc(""); - setClockInClientId(""); - setClockInRate(0); - }, - onError: (e) => toast.error(e.message), - }); - - const clockOut = api.timeEntries.clockOut.useMutation({ - 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(); - }, - onError: (e) => toast.error(e.message), - }); - - const create = api.timeEntries.create.useMutation({ - 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); - setManualForm(defaultManualForm); - setEditId(null); - }, - onError: (e) => toast.error(e.message), - }); - - const update = api.timeEntries.update.useMutation({ - onSuccess: () => { - toast.success("Entry updated"); - void utils.timeEntries.getAll.invalidate(); - void utils.timeEntries.getSummary.invalidate(); - setManualOpen(false); - setManualForm(defaultManualForm); - setEditId(null); - }, - onError: (e) => toast.error(e.message), - }); - - const del = api.timeEntries.delete.useMutation({ - onSuccess: () => { - toast.success("Entry deleted"); - void utils.timeEntries.getAll.invalidate(); - void utils.timeEntries.getSummary.invalidate(); - setDeleteId(null); - }, - onError: (e) => toast.error(e.message), - }); - - function handleEdit(entry: (typeof entries)[0]) { - setEditId(entry.id); - setManualForm({ - description: entry.description, - clientId: entry.clientId ?? "", - startedAt: new Date(entry.startedAt), - endedAt: entry.endedAt ? new Date(entry.endedAt) : undefined, - rate: entry.rate ?? 0, - notes: entry.notes ?? "", - }); - setManualOpen(true); - } - - function handleManualSubmit() { - if (!manualForm.description.trim()) { - toast.error("Description is required"); - return; - } - const payload = { - description: manualForm.description, - clientId: manualForm.clientId || undefined, - startedAt: manualForm.startedAt, - endedAt: manualForm.endedAt, - rate: manualForm.rate || undefined, - notes: manualForm.notes || undefined, - }; - if (editId) update.mutate({ id: editId, ...payload }); - else create.mutate(payload); - } - - const completedEntries = entries.filter((e) => e.endedAt !== null); - - // Live estimate for running timer, rounded up to 15-min increments (no minimum while running) - const estimatedHours = running ? Math.ceil(elapsed / 900) * 0.25 : 0; - const estimatedEarnings = running ? estimatedHours * (running.rate ?? 0) : 0; - const displayHours = (summary?.totalHours ?? 0) + estimatedHours; - const displayEarnings = (summary?.totalEarnings ?? 0) + estimatedEarnings; - - return ( -
- - - - - {/* Summary cards */} -
- - -

- Total Hours -

-

- {formatDuration(displayHours || undefined)} -

- {running && estimatedHours > 0 && ( -

- +{formatDuration(estimatedHours)} est. -

- )} -
-
- - -

- Earnings -

-

- {formatCurrency(displayEarnings)} -

- {running && estimatedEarnings > 0 && ( -

- +{formatCurrency(estimatedEarnings)} est. -

- )} -
-
- - -

- Entries -

-

{summary?.count ?? 0}

-
-
-
- - {/* Active timer */} - - - - - {running ? "Timer Running" : "Start Timer"} - - - - {runningLoading ? ( -
Loading…
- ) : running ? ( -
-
- - {formatElapsed(elapsed)} - -
- {running.description && ( -

{running.description}

- )} - {running.client && ( -

{running.client.name}

- )} -

- Started{" "} - {new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", - }).format(new Date(running.startedAt))} -

-
- -
-
- ) : ( -
-
-
- - setClockInDesc(e.target.value)} - placeholder="e.g. Frontend development" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - clockIn.mutate({ - description: clockInDesc, - clientId: clockInClientId || undefined, - rate: clockInRate || undefined, - }); - } - }} - /> -
-
- - -
-
-
-
- - setClockInRate(v)} - min={0} - step={0.01} - placeholder="0.00" - /> -
- -
-
- )} -
-
- - {/* Entry list */} - - - - Time Entries - - - - {entriesLoading ? ( -
Loading…
- ) : completedEntries.length === 0 ? ( -
- -

- No completed entries yet. Start a timer or add a manual entry. -

-
- ) : ( -
- {completedEntries.map((entry) => ( -
-
-
-

- {entry.description || ( - No description - )} -

- {entry.client && ( - - {entry.client.name} - - )} - {entry.invoice && ( - - - - {entry.invoice.invoicePrefix}{entry.invoice.invoiceNumber} - - - )} -
-

- {new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(entry.startedAt))} - {entry.endedAt - ? ` → ${new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }).format(new Date(entry.endedAt))}` - : ""} -

- {entry.notes && ( -

{entry.notes}

- )} -
-
-
-

- {formatDuration(entry.hours)} -

- {entry.rate && entry.hours ? ( -

- {formatCurrency(entry.hours * entry.rate)} -

- ) : null} -
- - -
-
- ))} -
- )} -
-
- - {/* Manual entry dialog */} - - - - {editId ? "Edit Entry" : "Add Manual Entry"} - -
-
- - - setManualForm((p) => ({ ...p, description: e.target.value })) - } - placeholder="What did you work on?" - /> -
-
- - -
-
-
- - - setManualForm((p) => ({ ...p, startedAt: d ?? new Date() })) - } - className="w-full" - /> -
-
- - - setManualForm((p) => ({ ...p, endedAt: d ?? undefined })) - } - className="w-full" - /> -
-
-
- - setManualForm((p) => ({ ...p, rate: v }))} - min={0} - step={0.01} - placeholder="0.00" - /> -
-
- - - setManualForm((p) => ({ ...p, notes: e.target.value })) - } - placeholder="Additional details…" - /> -
-
- - - - -
-
- - {/* Delete dialog */} - !o && setDeleteId(null)}> - - - Delete Entry - This action cannot be undone. - - - - - - - -
- ); + redirect("/dashboard/invoices"); } diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 98bfdc1..17b6497 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -8,7 +8,6 @@ import { BarChart2, Shield, RefreshCw, - Clock, } from "lucide-react"; export interface NavLink { @@ -32,7 +31,6 @@ export const navigationConfig: NavSection[] = [ { name: "Invoices", href: "/dashboard/invoices", icon: FileText }, { name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw }, { name: "Expenses", href: "/dashboard/expenses", icon: Receipt }, - { name: "Time Clock", href: "/dashboard/time-clock", icon: Clock }, { name: "Reports", href: "/dashboard/reports", icon: BarChart2 }, ], }, diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts index 4af57a3..dfad47a 100644 --- a/src/server/api/routers/time-entries.ts +++ b/src/server/api/routers/time-entries.ts @@ -24,28 +24,15 @@ function computeHours(startedAt: Date, endedAt: Date): number { return Math.max(0.25, Math.ceil(seconds / 900) * 0.25); } -async function addEntryToLatestInvoice( +async function addEntryToInvoice( database: Db, - userId: string, - clientId: string, + invoice: { id: string; invoiceNumber: string; invoicePrefix: string | null; taxRate: number; items: { amount: number; position: number }[] }, 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; - +): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> { const amount = hours * rate; const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1); @@ -79,6 +66,53 @@ async function addEntryToLatestInvoice( }; } +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; + return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date); +} + +async function addEntryToSpecificInvoice( + database: Db, + userId: string, + invoiceId: 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.id, invoiceId), + eq(invoices.createdById, userId), + or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), + ), + with: { items: true }, + }); + + if (!invoice) return null; + return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date); +} + export const timeEntriesRouter = createTRPCRouter({ getAll: protectedProcedure .input( @@ -123,7 +157,10 @@ export const timeEntriesRouter = createTRPCRouter({ eq(timeEntries.createdById, ctx.session.user.id), isNull(timeEntries.endedAt), ), - with: { client: true }, + with: { + client: true, + invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } }, + }, }); }), @@ -132,6 +169,7 @@ export const timeEntriesRouter = createTRPCRouter({ z.object({ description: z.string().max(500).default(""), clientId: z.string().optional().or(z.literal("")), + invoiceId: z.string().optional(), rate: z.number().min(0).optional(), startedAt: z.date().optional(), }), @@ -158,6 +196,14 @@ export const timeEntriesRouter = createTRPCRouter({ if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" }); } + const invoiceId = input.invoiceId ?? null; + if (invoiceId) { + const invoice = await ctx.db.query.invoices.findFirst({ + where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id)), + }); + if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" }); + } + const startedAt = input.startedAt ?? new Date(); if (startedAt > new Date()) { throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" }); @@ -168,6 +214,7 @@ export const timeEntriesRouter = createTRPCRouter({ .values({ description: input.description, clientId, + invoiceId, startedAt, rate: input.rate ?? null, createdById: ctx.session.user.id, @@ -212,17 +259,30 @@ export const timeEntriesRouter = createTRPCRouter({ 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, - ); + if (hours > 0) { + if (entry.invoiceId) { + linkedInvoice = await addEntryToSpecificInvoice( + ctx.db, + ctx.session.user.id, + entry.invoiceId, + updated.id, + description, + hours, + entry.rate ?? 0, + endedAt, + ); + } else if (entry.clientId) { + linkedInvoice = await addEntryToLatestInvoice( + ctx.db, + ctx.session.user.id, + entry.clientId, + updated.id, + description, + hours, + entry.rate ?? 0, + endedAt, + ); + } } return { entry: updated, invoice: linkedInvoice };