diff --git a/drizzle/0010_time_entries.sql b/drizzle/0010_time_entries.sql new file mode 100644 index 0000000..cf73819 --- /dev/null +++ b/drizzle/0010_time_entries.sql @@ -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"); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 9897ba1..43cd483 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -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; function rpcResult(id: JsonRpcId, result: unknown, init?: ResponseInit) { diff --git a/src/app/dashboard/time-clock/page.tsx b/src/app/dashboard/time-clock/page.tsx new file mode 100644 index 0000000..b09c99f --- /dev/null +++ b/src/app/dashboard/time-clock/page.tsx @@ -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(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); + } 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 ( +
+ + + + + {/* Summary cards */} +
+ + +

+ Total Hours +

+

+ {formatDuration(summary?.totalHours)} +

+
+
+ + +

+ Earnings +

+

+ {formatCurrency(summary?.totalEarnings ?? 0)} +

+
+
+ + +

+ 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} + + )} +
+

+ {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. + + + + + + + +
+ ); +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 17b6497..98bfdc1 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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 }, ], }, diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 7c87ee8..25dd6f8 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -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 diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts new file mode 100644 index 0000000..fc19028 --- /dev/null +++ b/src/server/api/routers/time-entries.ts @@ -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 }; + }), +}); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 15cb0a2..6e62995 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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], + }), +}));