Add time clock feature with MCP access

- New beenvoice_time_entry DB table with migration (startedAt/endedAt, hours, rate, clientId)
- tRPC router with clockIn, clockOut, getRunning, getAll, getSummary, create, update, delete
- Dashboard page at /dashboard/time-clock with live elapsed timer, entry list, and manual entry form
- 5 MCP tools: time_clock_in, time_clock_out, time_get_running, time_entries_list, time_entries_create
- Sidebar navigation entry

Timer state is stored in PostgreSQL (endedAt IS NULL = running), suitable for serverless/Coolify deployment.
This commit is contained in:
Claude
2026-06-06 17:05:17 +00:00
parent 413eb3e3c0
commit 29630ebc1f
7 changed files with 1025 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS "beenvoice_time_entry" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"description" varchar(500) DEFAULT '' NOT NULL,
"clientId" varchar(255),
"startedAt" timestamp NOT NULL,
"endedAt" timestamp,
"hours" real,
"rate" real,
"notes" varchar(500),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_time_entry_clientId_beenvoice_client_id_fk'
) THEN
ALTER TABLE "beenvoice_time_entry" ADD CONSTRAINT "beenvoice_time_entry_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_time_entry_createdById_beenvoice_user_id_fk'
) THEN
ALTER TABLE "beenvoice_time_entry" ADD CONSTRAINT "beenvoice_time_entry_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_created_by_idx" ON "beenvoice_time_entry" USING btree ("createdById");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_client_id_idx" ON "beenvoice_time_entry" USING btree ("clientId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_started_at_idx" ON "beenvoice_time_entry" USING btree ("startedAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_ended_at_idx" ON "beenvoice_time_entry" USING btree ("endedAt");
+96
View File
@@ -407,6 +407,102 @@ const tools = {
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.businesses.delete(input),
}),
time_clock_in: defineTool({
description:
"Start a time clock entry for the authenticated user. Fails if a timer is already running.",
inputSchema: {
type: "object",
properties: {
description: { type: "string", maxLength: 500 },
clientId: { type: "string" },
rate: { type: "number", minimum: 0 },
},
additionalProperties: false,
},
schema: z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
rate: z.number().min(0).optional(),
}),
handler: async (input, caller) => caller.timeEntries.clockIn(input),
}),
time_clock_out: defineTool({
description:
"Stop the currently running timer for the authenticated user. Returns the completed time entry with computed hours.",
inputSchema: {
type: "object",
properties: {
description: { type: "string", maxLength: 500 },
},
additionalProperties: false,
},
schema: z.object({
description: z.string().max(500).optional(),
}),
handler: async (input, caller) => caller.timeEntries.clockOut(input),
}),
time_get_running: defineTool({
description: "Get the currently running timer, if any. Returns null if no timer is running.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.timeEntries.getRunning(),
}),
time_entries_list: defineTool({
description: "List completed time entries for the authenticated user.",
inputSchema: {
type: "object",
properties: {
clientId: { type: "string" },
from: { type: "string", format: "date-time" },
to: { type: "string", format: "date-time" },
},
additionalProperties: false,
},
schema: z.object({
clientId: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
}),
handler: async (input, caller) =>
caller.timeEntries.getAll({
clientId: input.clientId,
from: input.from ? parseDate(input.from, "from") : undefined,
to: input.to ? parseDate(input.to, "to") : undefined,
}),
}),
time_entries_create: defineTool({
description:
"Create a manual time entry (for backdating or importing existing records). Hours are auto-computed from startedAt/endedAt if not provided.",
inputSchema: {
type: "object",
properties: {
description: { type: "string", maxLength: 500 },
clientId: { type: "string" },
startedAt: { type: "string", format: "date-time" },
endedAt: { type: "string", format: "date-time" },
hours: { type: "number", minimum: 0 },
rate: { type: "number", minimum: 0 },
notes: { type: "string", maxLength: 500 },
},
required: ["startedAt"],
additionalProperties: false,
},
schema: z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
startedAt: dateString,
endedAt: dateString.optional(),
hours: z.number().min(0).optional(),
rate: z.number().min(0).optional(),
notes: z.string().max(500).optional(),
}),
handler: async (input, caller) =>
caller.timeEntries.create({
...input,
startedAt: parseDate(input.startedAt, "startedAt"),
endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined,
}),
}),
} satisfies Record<string, ToolDefinition>;
function rpcResult(id: JsonRpcId, result: unknown, init?: ResponseInit) {
+575
View File
@@ -0,0 +1,575 @@
"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 } from "lucide-react";
import { formatCurrency } from "~/lib/currency";
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: "",
};
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<string | null>(null);
const [manualForm, setManualForm] = useState<ManualEntryForm>(defaultManualForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (running) {
const tick = () => {
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
};
tick();
intervalRef.current = setInterval(tick, 1000);
} else {
setElapsed(0);
}
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: () => {
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: () => {
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);
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
title="Time Clock"
description="Track billable hours with a live timer"
variant="gradient"
>
<Button
onClick={() => {
setEditId(null);
setManualForm(defaultManualForm);
setManualOpen(true);
}}
variant="outline"
>
<Plus className="mr-2 h-4 w-4" /> Add Manual Entry
</Button>
</PageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Total Hours
</p>
<p className="mt-1 text-2xl font-bold">
{formatDuration(summary?.totalHours)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Earnings
</p>
<p className="text-primary mt-1 text-2xl font-bold">
{formatCurrency(summary?.totalEarnings ?? 0)}
</p>
</CardContent>
</Card>
<Card className="col-span-2 sm:col-span-1">
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Entries
</p>
<p className="mt-1 text-2xl font-bold">{summary?.count ?? 0}</p>
</CardContent>
</Card>
</div>
{/* Active timer */}
<Card className={running ? "border-primary/30 bg-primary/5" : ""}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
{running ? "Timer Running" : "Start Timer"}
</CardTitle>
</CardHeader>
<CardContent>
{runningLoading ? (
<div className="text-muted-foreground text-sm">Loading</div>
) : running ? (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-primary font-mono text-4xl font-bold tabular-nums">
{formatElapsed(elapsed)}
</span>
<div className="flex-1">
{running.description && (
<p className="font-medium">{running.description}</p>
)}
{running.client && (
<p className="text-muted-foreground text-sm">{running.client.name}</p>
)}
<p className="text-muted-foreground text-xs">
Started{" "}
{new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(new Date(running.startedAt))}
</p>
</div>
<Button
variant="destructive"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
>
<Square className="mr-2 h-4 w-4" />
{clockOut.isPending ? "Stopping…" : "Stop Timer"}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>What are you working on?</Label>
<Input
value={clockInDesc}
onChange={(e) => 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,
});
}
}}
/>
</div>
<div className="space-y-2">
<Label>Client (optional)</Label>
<Select
value={clockInClientId || "none"}
onValueChange={(v) => {
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);
}
}
}}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end gap-3">
<div className="w-40 space-y-2">
<Label>Hourly Rate (optional)</Label>
<NumberInput
value={clockInRate}
onChange={(v) => setClockInRate(v)}
min={0}
step={0.01}
placeholder="0.00"
/>
</div>
<Button
onClick={() =>
clockIn.mutate({
description: clockInDesc,
clientId: clockInClientId || undefined,
rate: clockInRate || undefined,
})
}
disabled={clockIn.isPending}
className="hover-lift shadow-md"
>
<Play className="mr-2 h-4 w-4" />
{clockIn.isPending ? "Starting…" : "Start Timer"}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Entry list */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" /> Time Entries
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{entriesLoading ? (
<div className="text-muted-foreground p-6 text-center text-sm">Loading</div>
) : completedEntries.length === 0 ? (
<div className="p-8 text-center">
<Clock className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">
No completed entries yet. Start a timer or add a manual entry.
</p>
</div>
) : (
<div className="divide-y">
{completedEntries.map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between gap-3 p-4"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">
{entry.description || (
<span className="text-muted-foreground italic">No description</span>
)}
</p>
{entry.client && (
<Badge variant="secondary" className="text-xs">
{entry.client.name}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{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))}`
: ""}
</p>
{entry.notes && (
<p className="text-muted-foreground mt-1 text-xs">{entry.notes}</p>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<div className="text-right">
<p className="font-semibold tabular-nums">
{formatDuration(entry.hours)}
</p>
{entry.rate && entry.hours ? (
<p className="text-muted-foreground text-xs">
{formatCurrency(entry.hours * entry.rate)}
</p>
) : null}
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleEdit(entry)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => setDeleteId(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Manual entry dialog */}
<Dialog open={manualOpen} onOpenChange={setManualOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? "Edit Entry" : "Add Manual Entry"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Description</Label>
<Input
value={manualForm.description}
onChange={(e) =>
setManualForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="What did you work on?"
/>
</div>
<div className="space-y-2">
<Label>Client (optional)</Label>
<Select
value={manualForm.clientId || "none"}
onValueChange={(v) =>
setManualForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Start Date</Label>
<DatePicker
date={manualForm.startedAt}
onDateChange={(d) =>
setManualForm((p) => ({ ...p, startedAt: d ?? new Date() }))
}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>End Date (optional)</Label>
<DatePicker
date={manualForm.endedAt}
onDateChange={(d) =>
setManualForm((p) => ({ ...p, endedAt: d ?? undefined }))
}
className="w-full"
/>
</div>
</div>
<div className="space-y-2">
<Label>Hourly Rate (optional)</Label>
<NumberInput
value={manualForm.rate}
onChange={(v) => setManualForm((p) => ({ ...p, rate: v }))}
min={0}
step={0.01}
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Input
value={manualForm.notes}
onChange={(e) =>
setManualForm((p) => ({ ...p, notes: e.target.value }))
}
placeholder="Additional details…"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setManualOpen(false)}>
Cancel
</Button>
<Button
onClick={handleManualSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Add Entry"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Entry</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2
View File
@@ -8,6 +8,7 @@ import {
BarChart2,
Shield,
RefreshCw,
Clock,
} from "lucide-react";
export interface NavLink {
@@ -31,6 +32,7 @@ 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 },
],
},
+2
View File
@@ -9,6 +9,7 @@ import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
import { paymentsRouter } from "~/server/api/routers/payments";
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
payments: paymentsRouter,
recurringInvoices: recurringInvoicesRouter,
apiKeys: apiKeysRouter,
timeEntries: timeEntriesRouter,
});
// export type definition of API
+263
View File
@@ -0,0 +1,263 @@
import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
const createSchema = z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
startedAt: z.date(),
endedAt: z.date().optional(),
hours: z.number().min(0).optional(),
rate: z.number().min(0).optional(),
notes: z.string().max(500).optional().or(z.literal("")),
});
const updateSchema = createSchema.partial().extend({ id: z.string() });
function computeHours(startedAt: Date, endedAt: Date): number {
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
}
export const timeEntriesRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
z
.object({
clientId: z.string().optional(),
from: z.date().optional(),
to: z.date().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
if (input?.clientId) conditions.push(eq(timeEntries.clientId, input.clientId));
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
return ctx.db.query.timeEntries.findMany({
where: and(...conditions),
with: { client: true },
orderBy: [desc(timeEntries.startedAt)],
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, input.id),
eq(timeEntries.createdById, ctx.session.user.id),
),
with: { client: true },
});
if (!entry) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
return entry;
}),
getRunning: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
with: { client: true },
});
}),
clockIn: protectedProcedure
.input(
z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
rate: z.number().min(0).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const running = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
});
if (running) {
throw new TRPCError({
code: "CONFLICT",
message: "A timer is already running. Stop it before clocking in.",
});
}
const clientId = input.clientId?.trim() || null;
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
});
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
const [entry] = await ctx.db
.insert(timeEntries)
.values({
description: input.description,
clientId,
startedAt: new Date(),
rate: input.rate ?? null,
createdById: ctx.session.user.id,
})
.returning();
return entry;
}),
clockOut: protectedProcedure
.input(
z.object({
id: z.string().optional(),
description: z.string().max(500).optional(),
}).optional(),
)
.mutation(async ({ ctx, input }) => {
const conditions = [
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
];
if (input?.id) conditions.push(eq(timeEntries.id, input.id));
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(...conditions),
});
if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
}
const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt);
const description = input?.description?.trim() ?? entry.description;
const [updated] = await ctx.db
.update(timeEntries)
.set({ endedAt, hours, description, updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id))
.returning();
return updated;
}),
create: protectedProcedure
.input(createSchema)
.mutation(async ({ ctx, input }) => {
const clientId = input.clientId?.trim() || null;
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
});
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
let hours = input.hours ?? null;
if (!hours && input.endedAt) {
hours = computeHours(input.startedAt, input.endedAt);
}
const [entry] = await ctx.db
.insert(timeEntries)
.values({
description: input.description,
clientId,
startedAt: input.startedAt,
endedAt: input.endedAt ?? null,
hours,
rate: input.rate ?? null,
notes: input.notes?.trim() || null,
createdById: ctx.session.user.id,
})
.returning();
return entry;
}),
update: protectedProcedure
.input(updateSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const existing = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, id),
eq(timeEntries.createdById, ctx.session.user.id),
),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
const clientId =
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
});
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
await ctx.db
.update(timeEntries)
.set({
...data,
clientId,
notes: data.notes?.trim() || null,
updatedAt: new Date(),
})
.where(eq(timeEntries.id, id));
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, input.id),
eq(timeEntries.createdById, ctx.session.user.id),
),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
return { success: true };
}),
getSummary: protectedProcedure
.input(
z.object({
from: z.date().optional(),
to: z.date().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [
eq(timeEntries.createdById, ctx.session.user.id),
isNotNull(timeEntries.endedAt),
];
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
const entries = await ctx.db.query.timeEntries.findMany({
where: and(...conditions),
with: { client: true },
});
const totalHours = entries.reduce((sum, e) => sum + (e.hours ?? 0), 0);
const totalEarnings = entries.reduce(
(sum, e) => sum + (e.hours ?? 0) * (e.rate ?? 0),
0,
);
return { totalHours, totalEarnings, count: entries.length };
}),
});
+50
View File
@@ -89,6 +89,7 @@ export const usersRelations = relations(users, ({ many }) => ({
expenses: many(expenses),
invoiceTemplates: many(invoiceTemplates),
recurringInvoices: many(recurringInvoices),
timeEntries: many(timeEntries),
}));
export const accounts = createTable(
@@ -282,6 +283,7 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
references: [users.id],
}),
invoices: many(invoices),
timeEntries: many(timeEntries),
}));
export const businesses = createTable(
@@ -679,3 +681,51 @@ export const recurringInvoiceItemsRelations = relations(
}),
}),
);
// ─── Time Entries ─────────────────────────────────────────────────────────────
export const timeEntries = createTable(
"time_entry",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
description: d.varchar({ length: 500 }).notNull().default(""),
clientId: d
.varchar({ length: 255 })
.references(() => clients.id, { onDelete: "set null" }),
startedAt: d.timestamp().notNull(),
endedAt: d.timestamp(), // null = currently running
hours: d.real(), // stored when stopped
rate: d.real(),
notes: d.varchar({ length: 500 }),
createdById: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("time_entry_created_by_idx").on(t.createdById),
index("time_entry_client_id_idx").on(t.clientId),
index("time_entry_started_at_idx").on(t.startedAt),
index("time_entry_ended_at_idx").on(t.endedAt),
],
);
export const timeEntriesRelations = relations(timeEntries, ({ one }) => ({
client: one(clients, {
fields: [timeEntries.clientId],
references: [clients.id],
}),
createdBy: one(users, {
fields: [timeEntries.createdById],
references: [users.id],
}),
}));