diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index d119f0c..b21433e 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,6 +1,7 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useAuth } from "@/lib/auth"; import { Button } from "@/components/ui/button"; +import { HeaderTimer } from "@/components/timer/header-timer"; import { Briefcase, Users, @@ -98,17 +99,24 @@ export function AppShell({ children }: { children: ReactNode }) { {/* Mobile top bar */} -
- - - Counsel +
+ + + Counsel - +
+ + +
+ {/* Desktop top bar with timer */} +
+ +
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => { const active = diff --git a/src/components/cases/time-tab.tsx b/src/components/cases/time-tab.tsx index 43fd677..eb855e7 100644 --- a/src/components/cases/time-tab.tsx +++ b/src/components/cases/time-tab.tsx @@ -9,6 +9,7 @@ import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { Plus, Trash2, Loader2 } from "lucide-react"; import { formatCurrency, formatDate } from "@/lib/format"; +import { roundToTenth } from "@/lib/timer"; import { toast } from "sonner"; export function CaseTimeTab({ caseRecord }: { caseRecord: any }) { @@ -16,6 +17,7 @@ export function CaseTimeTab({ caseRecord }: { caseRecord: any }) { const [entries, setEntries] = useState([]); const [showForm, setShowForm] = useState(false); const [submitting, setSubmitting] = useState(false); + const [profileRate, setProfileRate] = useState(null); const [form, setForm] = useState({ work_date: new Date().toISOString().slice(0, 10), hours: "", @@ -35,9 +37,23 @@ export function CaseTimeTab({ caseRecord }: { caseRecord: any }) { useEffect(() => { load(); }, [caseRecord.id]); + // Pull this user's default hourly rate; use it to prefill when case has no override. + useEffect(() => { + if (!user?.id) return; + (async () => { + const { data } = await supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle(); + const rate = (data as any)?.hourly_rate ?? null; + setProfileRate(rate); + if (!caseRecord.default_hourly_rate && rate != null) { + setForm((f) => (f.hourly_rate ? f : { ...f, hourly_rate: String(rate) })); + } + })(); + }, [user?.id, caseRecord.default_hourly_rate]); + const submit = async (e: React.FormEvent) => { e.preventDefault(); - const hours = parseFloat(form.hours); + const rawHours = parseFloat(form.hours); + const hours = roundToTenth(rawHours); const rate = parseFloat(form.hourly_rate || "0"); if (!hours || hours <= 0) { toast.error("Hours must be greater than 0"); return; } if (!form.description.trim()) { toast.error("Description required"); return; } diff --git a/src/components/protected-layout.tsx b/src/components/protected-layout.tsx index 5ac716c..96b2bc6 100644 --- a/src/components/protected-layout.tsx +++ b/src/components/protected-layout.tsx @@ -2,7 +2,6 @@ import { useEffect, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAuth } from "@/lib/auth"; import { AppShell } from "@/components/app-shell"; -import { TimerWidget } from "@/components/timer/timer-widget"; import { Loader2 } from "lucide-react"; export function ProtectedLayout({ children, adminOnly }: { children: ReactNode; adminOnly?: boolean }) { @@ -26,10 +25,5 @@ export function ProtectedLayout({ children, adminOnly }: { children: ReactNode; ); } - return ( - - {children} - - - ); + return {children}; } diff --git a/src/components/timer/header-timer.tsx b/src/components/timer/header-timer.tsx new file mode 100644 index 0000000..4da0e13 --- /dev/null +++ b/src/components/timer/header-timer.tsx @@ -0,0 +1,249 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTimer, formatHMS } from "@/lib/timer"; +import { useAuth } from "@/lib/auth"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Pause, Play, Square, Timer as TimerIcon, Loader2, X } from "lucide-react"; +import { toast } from "sonner"; + +interface ClientOption { + id: string; + name: string; +} +interface CaseOption { + id: string; + case_number: string; + title: string; + client_id: string; + default_hourly_rate: number | null; +} + +export function HeaderTimer() { + const { user } = useAuth(); + const { timer, elapsedMs, isRunning, hasStarted, setClient, setCase, setDescription, start, pause, resume, reset, consume } = useTimer(); + + const [open, setOpen] = useState(false); + const [clients, setClients] = useState([]); + const [cases, setCases] = useState([]); + const [profileRate, setProfileRate] = useState(null); + const [saving, setSaving] = useState(false); + const [billable, setBillable] = useState(true); + + // Load clients + cases the user can access (RLS handles filtering) + useEffect(() => { + if (!user?.id) return; + (async () => { + const [{ data: cs }, { data: ks }, { data: prof }] = await Promise.all([ + supabase.from("clients").select("id, name").order("name"), + supabase + .from("cases") + .select("id, case_number, title, client_id, default_hourly_rate") + .order("case_number", { ascending: false }), + supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle(), + ]); + setClients(cs ?? []); + setCases(ks ?? []); + setProfileRate((prof as any)?.hourly_rate ?? null); + })(); + }, [user?.id]); + + const filteredCases = useMemo( + () => (timer.clientId ? cases.filter((c) => c.client_id === timer.clientId) : []), + [cases, timer.clientId], + ); + + const activeCase = useMemo( + () => cases.find((c) => c.id === timer.caseId), + [cases, timer.caseId], + ); + + const effectiveRate = activeCase?.default_hourly_rate ?? profileRate ?? 0; + + const handleSave = async () => { + if (!user?.id) return toast.error("Not signed in"); + if (!timer.caseId) return toast.error("Select a case before saving"); + if (!timer.description.trim()) return toast.error("Description required"); + if (elapsedMs < 1000) return toast.error("Timer hasn't recorded any time yet"); + setSaving(true); + const { hours } = consume(); + const { error } = await supabase.from("time_entries").insert({ + case_id: timer.caseId, + user_id: user.id, + work_date: new Date().toISOString().slice(0, 10), + hours, + hourly_rate: effectiveRate, + description: timer.description.trim(), + billable, + }); + setSaving(false); + if (error) { + toast.error(error.message); + return; + } + toast.success(`Saved ${hours.toFixed(1)} hr`, { + description: `${formatCurrency(hours * effectiveRate)} at ${formatCurrency(effectiveRate)}/hr`, + }); + setBillable(true); + setOpen(false); + }; + + const handleDiscard = () => { + if (hasStarted && !confirm("Discard the running timer?")) return; + reset(); + setBillable(true); + }; + + const dotClass = isRunning + ? "bg-emerald-500 animate-pulse" + : hasStarted + ? "bg-amber-500" + : "bg-muted-foreground/40"; + + return ( + + + + + +
+
+
Time tracker
+
+ {formatHMS(elapsedMs)} +
+
+
+ {isRunning ? ( + + ) : ( + + )} + {hasStarted && ( + + )} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ +