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)}
-
- clockOut.mutate({})}
- disabled={clockOut.isPending}
- >
-
- {clockOut.isPending ? "Stopping…" : "Stop"}
-
-
- Details
-
-
+ clockOut.mutate({})}
+ disabled={clockOut.isPending}
+ >
+
+ {clockOut.isPending ? "Stopping…" : "Stop"}
+
);
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)}
+
+
+ clockOut.mutate({})}
+ disabled={clockOut.isPending}
+ >
+
+ {clockOut.isPending ? "Stopping…" : "Stop & Add to Invoice"}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Track Time
+
+
+
+
+ What are you working on?
+ 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 });
+ }
+ }}
+ />
+
+
+ Hourly rate
+ setRate(v)}
+ min={0}
+ step={0.01}
+ placeholder="0.00"
+ />
+
+
+ clockIn.mutate({ description, clientId, invoiceId, rate: rate || undefined })
+ }
+ disabled={clockIn.isPending}
+ >
+
+ {clockIn.isPending ? "Starting…" : "Start Timer"}
+
+
+
+ );
+}
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 (
-
-
- {
- setEditId(null);
- setManualForm(defaultManualForm);
- setManualOpen(true);
- }}
- variant="outline"
- >
- Add Manual Entry
-
-
-
- {/* 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))}
-
-
-
clockOut.mutate({})}
- disabled={clockOut.isPending}
- >
-
- {clockOut.isPending ? "Stopping…" : "Stop Timer"}
-
-
-
- ) : (
-
-
-
- What are you working on?
- 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,
- });
- }
- }}
- />
-
-
- Client (optional)
- {
- const id = v === "none" ? "" : v;
- setClockInClientId(id);
- if (id) {
- const client = clients.find((c) => c.id === id);
- if (client?.defaultHourlyRate && clockInRate === 0) {
- setClockInRate(client.defaultHourlyRate);
- }
- }
- }}
- >
-
-
-
-
- No client
- {clients.map((c) => (
-
- {c.name}
-
- ))}
-
-
-
-
-
-
- Hourly Rate (optional)
- setClockInRate(v)}
- min={0}
- step={0.01}
- placeholder="0.00"
- />
-
-
- clockIn.mutate({
- description: clockInDesc,
- clientId: clockInClientId || undefined,
- rate: clockInRate || undefined,
- })
- }
- disabled={clockIn.isPending}
- className="hover-lift shadow-md"
- >
-
- {clockIn.isPending ? "Starting…" : "Start Timer"}
-
-
-
- )}
-
-
-
- {/* 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}
-
-
handleEdit(entry)}
- >
-
-
-
setDeleteId(entry.id)}
- >
-
-
-
-
- ))}
-
- )}
-
-
-
- {/* Manual entry dialog */}
-
-
-
- {editId ? "Edit Entry" : "Add Manual Entry"}
-
-
-
- Description
-
- setManualForm((p) => ({ ...p, description: e.target.value }))
- }
- placeholder="What did you work on?"
- />
-
-
- Client (optional)
-
- setManualForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
- }
- >
-
-
-
-
- No client
- {clients.map((c) => (
-
- {c.name}
-
- ))}
-
-
-
-
-
- Start Date
-
- setManualForm((p) => ({ ...p, startedAt: d ?? new Date() }))
- }
- className="w-full"
- />
-
-
- End Date (optional)
-
- setManualForm((p) => ({ ...p, endedAt: d ?? undefined }))
- }
- className="w-full"
- />
-
-
-
- Hourly Rate (optional)
- setManualForm((p) => ({ ...p, rate: v }))}
- min={0}
- step={0.01}
- placeholder="0.00"
- />
-
-
- Notes (optional)
-
- setManualForm((p) => ({ ...p, notes: e.target.value }))
- }
- placeholder="Additional details…"
- />
-
-
-
- setManualOpen(false)}>
- Cancel
-
-
- {create.isPending || update.isPending
- ? "Saving…"
- : editId
- ? "Update"
- : "Add Entry"}
-
-
-
-
-
- {/* Delete dialog */}
-
!o && setDeleteId(null)}>
-
-
- Delete Entry
- This action cannot be undone.
-
-
- setDeleteId(null)}>
- Cancel
-
- deleteId && del.mutate({ id: deleteId })}
- disabled={del.isPending}
- >
- {del.isPending ? "Deleting…" : "Delete"}
-
-
-
-
-
- );
+ 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 };