diff --git a/src/app/dashboard/_components/active-timer-widget.tsx b/src/app/dashboard/_components/active-timer-widget.tsx index 1c2cd20..9378fb6 100644 --- a/src/app/dashboard/_components/active-timer-widget.tsx +++ b/src/app/dashboard/_components/active-timer-widget.tsx @@ -1,19 +1,13 @@ "use client"; +import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { api } from "~/trpc/react"; import { Card, CardContent } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; -import { Square } from "lucide-react"; +import { Square, Clock } from "lucide-react"; import { toast } from "sonner"; -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(":"); -} +import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock"; export function ActiveTimerWidget() { const utils = api.useUtils(); @@ -39,19 +33,32 @@ export function ActiveTimerWidget() { const clockOut = api.timeEntries.clockOut.useMutation({ onSuccess: (data) => { - if (data.invoice) { - const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`; + const message = describeClockOutOutcome({ + outcome: data.outcome, + hours: data.hours, + rate: data.rate, + invoice: data.invoice, + }); + + if (data.outcome === "linked_to_invoice" && data.invoice) { toast.success("Timer stopped", { - description: `Added to invoice ${label}`, + description: message, action: { - label: "View Invoice", - onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), + label: "View invoice", + onClick: () => + window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), }, }); + } else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") { + toast.warning("Time saved", { description: message }); } else { - toast.success("Timer stopped"); + toast.success(message); } + void utils.timeEntries.getRunning.invalidate(); + void utils.timeEntries.getAll.invalidate(); + void utils.invoices.getAll.invalidate(); + void utils.dashboard.getStats.invalidate(); }, onError: (e) => toast.error(e.message), }); @@ -65,7 +72,6 @@ export function ActiveTimerWidget() { return ( - {/* Pulse indicator */} @@ -82,7 +88,8 @@ export function ActiveTimerWidget() {

{invoiceLabel ? ( - <>Tracking for{" "} + <> + Billing to{" "} ) : ( - <>Started{" "} - {new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", - }).format(new Date(running.startedAt))} - + <>No invoice selected — open time clock to assign )} + {" · "} + + Time clock +

- {formatElapsed(elapsed)} + {formatElapsedSeconds(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 index 00b033b..a2b685c 100644 --- a/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx +++ b/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx @@ -1,178 +1,29 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { api } from "~/trpc/react"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import Link from "next/link"; +import { TimeClockPanel } from "~/components/time-clock/time-clock-panel"; 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(":"); -} +import { ExternalLink } from "lucide-react"; 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)} - -
- -
-
- ); - } - +export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) { 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 992fb44..1abb732 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -523,11 +523,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { {/* Right Column - Actions */}
{effectiveStatus !== "paid" && ( - + )} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b1e5a6f..86635d7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -490,7 +490,6 @@ function CardSkeleton() { } import { DashboardPageHeader } from "~/components/layout/page-header"; -import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; // ... imports @@ -511,10 +510,6 @@ export default async function DashboardPage() { description="Here's what's happening with your business today" /> - - - - }> diff --git a/src/app/dashboard/time-clock/page.tsx b/src/app/dashboard/time-clock/page.tsx index 9363815..c5d8203 100644 --- a/src/app/dashboard/time-clock/page.tsx +++ b/src/app/dashboard/time-clock/page.tsx @@ -1,5 +1,33 @@ -import { redirect } from "next/navigation"; +import { HydrateClient, api } from "~/trpc/server"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { TimeClockPanel } from "~/components/time-clock/time-clock-panel"; -export default function TimeClockPage() { - redirect("/dashboard/invoices"); +export default async function TimeClockPage({ + searchParams, +}: { + searchParams: Promise<{ clientId?: string; invoiceId?: string }>; +}) { + const params = await searchParams; + void api.timeEntries.getRunning.prefetch(); + void api.clients.getAll.prefetch(); + if (params.clientId) { + void api.invoices.getBillable.prefetch({ clientId: params.clientId }); + } else { + void api.invoices.getBillable.prefetch(); + } + + return ( +
+ + + + +
+ ); } diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx index b76686b..f71ea4e 100644 --- a/src/components/forms/invoice-form.tsx +++ b/src/components/forms/invoice-form.tsx @@ -806,6 +806,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { onUpdateItem={updateItem} onAddItemWithValues={addItemWithValues} invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined} + clientId={formData.clientId || undefined} defaultRate={formData.items[0]?.rate} /> diff --git a/src/components/forms/invoice-line-items.tsx b/src/components/forms/invoice-line-items.tsx index d6db28e..48d9b46 100644 --- a/src/components/forms/invoice-line-items.tsx +++ b/src/components/forms/invoice-line-items.tsx @@ -4,6 +4,7 @@ import { Plus, Timer, Trash2, Zap } from "lucide-react"; import * as React from "react"; import { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; +import Link from "next/link"; import { Button } from "~/components/ui/button"; import { DatePicker } from "~/components/ui/date-picker"; import { Input } from "~/components/ui/input"; @@ -15,7 +16,6 @@ import { useLineItemSuggestions, type LineItemSuggestion, } from "~/hooks/use-line-item-suggestions"; -import { TimeTracker } from "~/components/invoice/time-tracker"; interface InvoiceItem { id: string; @@ -37,6 +37,7 @@ interface InvoiceLineItemsProps { ) => void; onAddItemWithValues?: (parsed: ParsedLineItem) => void; invoiceId?: string; + clientId?: string; defaultRate?: number; className?: string; } @@ -348,7 +349,8 @@ export function InvoiceLineItems({ onUpdateItem, onAddItemWithValues, invoiceId, - defaultRate, + clientId, + defaultRate: _defaultRate, className, }: InvoiceLineItemsProps) { const canRemoveItems = items.length > 1; @@ -422,18 +424,24 @@ export function InvoiceLineItems({ /> ))} - {onAddItemWithValues && invoiceId && ( + {invoiceId && (

- Time tracker + Time clock

- { - onAddItemWithValues({ description, hours, rate: defaultRate ?? 0 }); - }} - /> +

+ Track time on the dedicated time clock — entries sync across devices and + bill directly to an invoice. +

+
)} {onAddItemWithValues && ( diff --git a/src/components/invoice/time-tracker.tsx b/src/components/invoice/time-tracker.tsx deleted file mode 100644 index e8ac71d..0000000 --- a/src/components/invoice/time-tracker.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { Play, Square } from "lucide-react"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; - -interface TimeTrackerProps { - invoiceId: string; - onStop: (hours: number, description: string) => void; - defaultRate?: number; -} - -interface PersistedState { - running: boolean; - startedAt: number | null; - description: string; -} - -function storageKey(invoiceId: string) { - return `time-tracker-${invoiceId}`; -} - -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 readPersistedState(invoiceId: string): PersistedState { - if (typeof window === "undefined") return { running: false, startedAt: null, description: "" }; - try { - const raw = localStorage.getItem(storageKey(invoiceId)); - if (raw) return JSON.parse(raw) as PersistedState; - } catch { - // ignore - } - return { running: false, startedAt: null, description: "" }; -} - -export function TimeTracker({ invoiceId, onStop }: TimeTrackerProps) { - const [running, setRunning] = useState(() => readPersistedState(invoiceId).running); - const [startedAt, setStartedAt] = useState( - () => readPersistedState(invoiceId).startedAt, - ); - const [elapsed, setElapsed] = useState(() => { - const s = readPersistedState(invoiceId); - if (s.running && s.startedAt) { - return Math.max(0, Math.floor((Date.now() - s.startedAt) / 1000)); - } - return 0; - }); - const [description, setDescription] = useState( - () => readPersistedState(invoiceId).description, - ); - const intervalRef = useRef | null>(null); - - useEffect(() => { - if (running && startedAt !== null) { - intervalRef.current = setInterval(() => { - setElapsed(Math.floor((Date.now() - startedAt) / 1000)); - }, 1000); - } - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [running, startedAt]); - - function persist(state: PersistedState) { - localStorage.setItem(storageKey(invoiceId), JSON.stringify(state)); - } - - function handleStart() { - const now = Date.now(); - setStartedAt(now); - setElapsed(0); - setRunning(true); - persist({ running: true, startedAt: now, description }); - } - - function handleStop() { - if (intervalRef.current) clearInterval(intervalRef.current); - const hours = Math.max(0.25, Math.ceil(elapsed / 900) * 0.25); - setRunning(false); - setStartedAt(null); - setElapsed(0); - localStorage.removeItem(storageKey(invoiceId)); - onStop(hours, description); - setDescription(""); - } - - return ( -
- {running ? ( - <> - - {formatElapsed(elapsed)} - - { - setDescription(e.target.value); - persist({ running: true, startedAt, description: e.target.value }); - }} - placeholder="What are you working on?" - className="flex-1" - /> - - - ) : ( - <> - setDescription(e.target.value)} - placeholder="What will you work on?" - className="flex-1" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleStart(); - } - }} - /> - - - )} -
- ); -} diff --git a/src/components/layout/dashboard-shell.tsx b/src/components/layout/dashboard-shell.tsx index 96eddab..2ace687 100644 --- a/src/components/layout/dashboard-shell.tsx +++ b/src/components/layout/dashboard-shell.tsx @@ -12,6 +12,7 @@ import { Logo } from "~/components/branding/logo"; import { Button } from "~/components/ui/button"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { useAppearance } from "~/components/providers/appearance-provider"; +import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; function DashboardContent({ children }: { children: React.ReactNode }) { const { isCollapsed } = useSidebar(); @@ -68,10 +69,12 @@ function DashboardContent({ children }: { children: React.ReactNode }) { )} >
- {/* Mobile header spacer is handled by pt-16 on mobile */}
{/* Mobile Breadcrumbs could go here or be part of the page */}
+
+ +
{children}
diff --git a/src/components/time-clock/time-clock-panel.tsx b/src/components/time-clock/time-clock-panel.tsx new file mode 100644 index 0000000..72ba706 --- /dev/null +++ b/src/components/time-clock/time-clock-panel.tsx @@ -0,0 +1,342 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Clock, Play, Square, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock"; + +export type TimeClockPanelProps = { + defaultClientId?: string; + defaultInvoiceId?: string; + compact?: boolean; +}; + +export function TimeClockPanel({ + defaultClientId = "", + defaultInvoiceId = "", + compact = false, +}: TimeClockPanelProps) { + const utils = api.useUtils(); + const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery( + undefined, + { refetchInterval: 30_000 }, + ); + const { data: clients } = api.clients.getAll.useQuery(); + + const [clientId, setClientId] = useState(defaultClientId); + const [invoiceId, setInvoiceId] = useState(defaultInvoiceId); + const [description, setDescription] = useState(""); + const [rate, setRate] = useState(0); + const [elapsed, setElapsed] = useState(0); + const intervalRef = useRef | null>(null); + + const draftClientId = running ? (running.clientId ?? "") : clientId; + const { data: billableInvoices } = api.invoices.getBillable.useQuery( + draftClientId ? { clientId: draftClientId } : undefined, + { enabled: Boolean(draftClientId) }, + ); + + const todayStart = useMemo(() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; + }, []); + + const { data: todayEntries } = api.timeEntries.getAll.useQuery({ + from: todayStart, + }); + + useEffect(() => { + if (intervalRef.current) clearInterval(intervalRef.current); + if (!running) return; + + 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(); + }, + onError: (e) => toast.error(e.message), + }); + + const clockOut = api.timeEntries.clockOut.useMutation({ + onSuccess: (data) => { + const message = describeClockOutOutcome({ + outcome: data.outcome, + hours: data.hours, + rate: data.rate, + invoice: data.invoice, + }); + + if (data.outcome === "linked_to_invoice" && data.invoice) { + toast.success("Time logged", { + description: message, + action: { + label: "View invoice", + onClick: () => + window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), + }, + }); + } else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") { + toast.warning("Time saved", { description: message }); + } else { + toast.success(message); + } + + void utils.timeEntries.getRunning.invalidate(); + void utils.timeEntries.getAll.invalidate(); + void utils.invoices.getAll.invalidate(); + void utils.invoices.getBillable.invalidate(); + void utils.dashboard.getStats.invalidate(); + setDescription(""); + }, + onError: (e) => toast.error(e.message), + }); + + function handleClientChange(value: string) { + setClientId(value); + setInvoiceId(""); + const client = clients?.find((c) => c.id === value); + setRate(client?.defaultHourlyRate ?? 0); + } + + if (runningLoading) { + return ( + + Loading timer… + + ); + } + + const invoiceLabel = (inv: { + invoicePrefix: string | null; + invoiceNumber: string; + status: string; + }) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`; + + const displayDescription = running ? running.description : description; + const displayRate = running ? (running.rate ?? 0) : rate; + + return ( +
+ + + + {running ? ( + + + + + ) : ( + + )} + {running ? "Timer running" : "Time clock"} + + + + {running ? ( +
+
+

+ {displayDescription || ( + No description + )} +

+

+ {running.client?.name ?? "No client"} + {running.invoice + ? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` + : ""} + {displayRate ? ` · $${displayRate}/hr` : ""} +

+
+ + {formatElapsedSeconds(elapsed)} + +
+ ) : null} + + {!running ? ( + <> +
+
+ + +
+ +
+ + +
+
+ +
+ + setDescription(e.target.value)} + placeholder="What are you working on?" + /> +
+ +
+ + + {clientId && rate === 0 ? ( +

+ Set a rate or add a default on the client record. +

+ ) : null} +
+ + ) : ( +
+ + setDescription(e.target.value)} + placeholder={running.description || "What did you work on?"} + /> +
+ )} + + {running ? ( + + ) : ( + + )} +
+
+ + {!compact && todayEntries && todayEntries.length > 0 ? ( + + + Today's entries + + + {todayEntries + .filter((e) => e.endedAt) + .map((entry) => ( +
+
+

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

+

+ {entry.client?.name ?? "No client"} + {entry.invoice + ? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}` + : entry.hours + ? " · not on invoice" + : ""} +

+
+
+

{entry.hours ?? "—"}h

+ {entry.rate ? ( +

${entry.rate}/hr

+ ) : null} +
+
+ ))} +
+
+ ) : null} + + {compact ? ( + + ) : null} +
+ ); +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 17b6497..50d57cd 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 { @@ -26,6 +27,7 @@ export const navigationConfig: NavSection[] = [ title: "Main", links: [ { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, + { name: "Time clock", href: "/dashboard/time-clock", icon: Clock }, { name: "Clients", href: "/dashboard/clients", icon: Users }, { name: "Businesses", href: "/dashboard/businesses", icon: Building }, { name: "Invoices", href: "/dashboard/invoices", icon: FileText }, diff --git a/src/lib/time-clock.ts b/src/lib/time-clock.ts new file mode 100644 index 0000000..0ef6094 --- /dev/null +++ b/src/lib/time-clock.ts @@ -0,0 +1,41 @@ +export type ClockOutOutcome = + | "linked_to_invoice" + | "saved_no_invoice" + | "saved_no_client" + | "zero_hours"; + +export function computeTrackedHours(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 function formatElapsedSeconds(seconds: number): string { + 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(":"); +} + +export function describeClockOutOutcome(input: { + outcome: ClockOutOutcome; + hours: number; + rate: number; + invoice?: { invoicePrefix: string; invoiceNumber: string } | null; +}): string { + const amount = input.hours * input.rate; + + switch (input.outcome) { + case "linked_to_invoice": + if (input.invoice) { + const label = `${input.invoice.invoicePrefix}${input.invoice.invoiceNumber}`; + return `Added ${input.hours}h @ $${input.rate}/hr ($${amount.toFixed(2)}) to invoice ${label}`; + } + return `Added ${input.hours}h to invoice`; + case "saved_no_invoice": + return `Saved ${input.hours}h — no open invoice found for this client. Pick an invoice on the time clock or create one.`; + case "saved_no_client": + return `Saved ${input.hours}h — assign a client and invoice to bill this time.`; + case "zero_hours": + return "Timer stopped (less than minimum billable increment)."; + } +} diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts index c3fd806..41335ba 100644 --- a/src/server/api/routers/invoices.ts +++ b/src/server/api/routers/invoices.ts @@ -155,6 +155,38 @@ export const invoicesRouter = createTRPCRouter({ } }), + /** Draft and sent invoices available for time-clock billing. */ + getBillable: protectedProcedure + .input(z.object({ clientId: z.string().optional() }).optional()) + .query(async ({ ctx, input }) => { + const conditions = [ + eq(invoices.createdById, ctx.session.user.id), + inArray(invoices.status, ["draft", "sent"]), + ]; + if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId)); + + return ctx.db.query.invoices.findMany({ + where: and(...conditions), + columns: { + id: true, + invoiceNumber: true, + invoicePrefix: true, + status: true, + clientId: true, + issueDate: true, + totalAmount: true, + }, + with: { + client: { columns: { id: true, name: true } }, + }, + orderBy: (invoices, { desc }) => [ + desc(invoices.issueDate), + desc(invoices.dueDate), + desc(invoices.invoiceNumber), + ], + }); + }), + getLineItemHistory: protectedProcedure.query(async ({ ctx }) => { const userInvoices = await ctx.db .select({ id: invoices.id }) diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts index 2a686fe..c486f6f 100644 --- a/src/server/api/routers/time-entries.ts +++ b/src/server/api/routers/time-entries.ts @@ -4,6 +4,10 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema"; import { TRPCError } from "@trpc/server"; import type { db } from "~/server/db"; +import { + computeTrackedHours, + type ClockOutOutcome, +} from "~/lib/time-clock"; type Db = typeof db; @@ -19,9 +23,25 @@ const createSchema = z.object({ const updateSchema = createSchema.partial().extend({ id: z.string() }); +async function resolveHourlyRate( + database: Db, + userId: string, + clientId: string | null, + explicitRate?: number | null, +): Promise { + if (explicitRate != null && explicitRate > 0) return explicitRate; + if (!clientId) return explicitRate ?? null; + + const client = await database.query.clients.findFirst({ + where: and(eq(clients.id, clientId), eq(clients.createdById, userId)), + columns: { defaultHourlyRate: true }, + }); + + return client?.defaultHourlyRate ?? explicitRate ?? null; +} + 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); + return computeTrackedHours(startedAt, endedAt); } async function addEntryToInvoice( @@ -156,7 +176,7 @@ export const timeEntriesRouter = createTRPCRouter({ }), getRunning: protectedProcedure.query(async ({ ctx }) => { - return ctx.db.query.timeEntries.findFirst({ + const entry = await ctx.db.query.timeEntries.findFirst({ where: and( eq(timeEntries.createdById, ctx.session.user.id), isNull(timeEntries.endedAt), @@ -166,6 +186,7 @@ export const timeEntriesRouter = createTRPCRouter({ invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } }, }, }); + return entry ?? null; }), clockIn: protectedProcedure @@ -193,19 +214,40 @@ export const timeEntriesRouter = createTRPCRouter({ } const clientId = input.clientId?.trim() ?? null; + let clientRecord: { defaultHourlyRate: number | null } | null = null; if (clientId) { - const client = await ctx.db.query.clients.findFirst({ + const found = await ctx.db.query.clients.findFirst({ where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), + columns: { defaultHourlyRate: true }, }); - if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" }); + if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" }); + clientRecord = found; } const invoiceId = input.invoiceId ?? null; + let resolvedClientId = clientId; if (invoiceId) { const invoice = await ctx.db.query.invoices.findFirst({ - where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id)), + where: and( + eq(invoices.id, invoiceId), + eq(invoices.createdById, ctx.session.user.id), + or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), + ), + columns: { id: true, clientId: true }, }); - if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" }); + if (!invoice) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Invoice not found or not open for time tracking", + }); + } + if (resolvedClientId && invoice.clientId !== resolvedClientId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Selected invoice does not belong to this client", + }); + } + resolvedClientId = resolvedClientId ?? invoice.clientId; } const startedAt = input.startedAt ?? new Date(); @@ -213,14 +255,32 @@ export const timeEntriesRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" }); } + if (!clientRecord && resolvedClientId) { + const found = await ctx.db.query.clients.findFirst({ + where: and( + eq(clients.id, resolvedClientId), + eq(clients.createdById, ctx.session.user.id), + ), + columns: { defaultHourlyRate: true }, + }); + clientRecord = found ?? null; + } + + const rate = await resolveHourlyRate( + ctx.db, + ctx.session.user.id, + resolvedClientId, + input.rate ?? clientRecord?.defaultHourlyRate, + ); + const [entry] = await ctx.db .insert(timeEntries) .values({ description: input.description, - clientId, + clientId: resolvedClientId, invoiceId, startedAt, - rate: input.rate ?? null, + rate, createdById: ctx.session.user.id, }) .returning(); @@ -228,6 +288,109 @@ export const timeEntriesRouter = createTRPCRouter({ return entry; }), + updateRunning: protectedProcedure + .input( + z.object({ + description: z.string().max(500).optional(), + clientId: z.string().optional().or(z.literal("")), + invoiceId: z.string().optional().or(z.literal("")), + rate: z.number().min(0).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const entry = await ctx.db.query.timeEntries.findFirst({ + where: and( + eq(timeEntries.createdById, ctx.session.user.id), + isNull(timeEntries.endedAt), + ), + }); + + if (!entry) { + throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" }); + } + + const updates: { + description?: string; + clientId?: string | null; + invoiceId?: string | null; + rate?: number | null; + updatedAt: Date; + } = { updatedAt: new Date() }; + + if (input.description !== undefined) { + updates.description = input.description; + } + + let resolvedClientId = entry.clientId; + + if (input.clientId !== undefined) { + const clientId = input.clientId.trim() || null; + if (clientId) { + const found = await ctx.db.query.clients.findFirst({ + where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), + }); + if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" }); + } + resolvedClientId = clientId; + updates.clientId = clientId; + if (input.invoiceId === undefined) { + updates.invoiceId = null; + } + } + + if (input.invoiceId !== undefined) { + const invoiceId = input.invoiceId.trim() || null; + if (invoiceId) { + const invoice = await ctx.db.query.invoices.findFirst({ + where: and( + eq(invoices.id, invoiceId), + eq(invoices.createdById, ctx.session.user.id), + or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), + ), + columns: { id: true, clientId: true }, + }); + if (!invoice) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Invoice not found or not open for time tracking", + }); + } + if (resolvedClientId && invoice.clientId !== resolvedClientId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Selected invoice does not belong to this client", + }); + } + resolvedClientId = resolvedClientId ?? invoice.clientId; + updates.clientId = resolvedClientId; + } + updates.invoiceId = invoiceId; + } + + if (input.rate !== undefined) { + updates.rate = input.rate; + } else if (input.clientId !== undefined && resolvedClientId) { + updates.rate = await resolveHourlyRate( + ctx.db, + ctx.session.user.id, + resolvedClientId, + entry.rate, + ); + } + + const [updated] = await ctx.db + .update(timeEntries) + .set(updates) + .where(eq(timeEntries.id, entry.id)) + .returning(); + + if (!updated) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" }); + } + + return updated; + }), + clockOut: protectedProcedure .input( z.object({ @@ -253,6 +416,7 @@ export const timeEntriesRouter = createTRPCRouter({ const endedAt = new Date(); const hours = computeHours(entry.startedAt, endedAt); const description = input?.description?.trim() ?? entry.description; + const rate = entry.rate ?? 0; const [updated] = await ctx.db .update(timeEntries) @@ -263,6 +427,8 @@ 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; + let outcome: ClockOutOutcome = "zero_hours"; + if (hours > 0) { if (entry.invoiceId) { linkedInvoice = await addEntryToSpecificInvoice( @@ -272,9 +438,10 @@ export const timeEntriesRouter = createTRPCRouter({ updated.id, description, hours, - entry.rate ?? 0, + rate, endedAt, ); + outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice"; } else if (entry.clientId) { linkedInvoice = await addEntryToLatestInvoice( ctx.db, @@ -283,13 +450,23 @@ export const timeEntriesRouter = createTRPCRouter({ updated.id, description, hours, - entry.rate ?? 0, + rate, endedAt, ); + outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice"; + } else { + outcome = "saved_no_client"; } } - return { entry: updated, invoice: linkedInvoice }; + return { + entry: updated, + invoice: linkedInvoice, + outcome, + hours, + rate, + amount: hours * rate, + }; }), create: protectedProcedure diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 0435d0b..657e6e8 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,5 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { index, pgTableCreator } from "drizzle-orm/pg-core"; +import { index, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core"; /** * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same @@ -720,6 +720,9 @@ export const timeEntries = createTable( 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), + uniqueIndex("time_entry_one_running_per_user_idx") + .on(t.createdById) + .where(sql`${t.endedAt} is null`), ], );