From add07a84509d22a0e03a5af4846dc0dd08c74540 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:31:58 +0000 Subject: [PATCH 01/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 3 +++ .../20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql | 1 + 2 files changed, 4 insertions(+) create mode 100644 supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index b4820bd..2d88199 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -378,6 +378,7 @@ export type Database = { created_at: string email: string full_name: string + hourly_rate: number | null id: string updated_at: string } @@ -385,6 +386,7 @@ export type Database = { created_at?: string email?: string full_name?: string + hourly_rate?: number | null id: string updated_at?: string } @@ -392,6 +394,7 @@ export type Database = { created_at?: string email?: string full_name?: string + hourly_rate?: number | null id?: string updated_at?: string } diff --git a/supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql b/supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql new file mode 100644 index 0000000..1953cd4 --- /dev/null +++ b/supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql @@ -0,0 +1 @@ +ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS hourly_rate numeric; \ No newline at end of file From 18545bc0930ca25f09fc441dcdd7a610b5e74770 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:32:51 +0000 Subject: [PATCH 02/11] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/timer/header-timer.tsx | 249 ++++++++++++++++++++++++++ src/lib/timer.tsx | 163 +++++++++-------- 2 files changed, 329 insertions(+), 83 deletions(-) create mode 100644 src/components/timer/header-timer.tsx 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 && ( + + )} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ +