diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index b21433e..6f5e4e9 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -2,6 +2,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 { QuickAddTime, QuickAddExpense } from "@/components/quick-add/quick-add"; import { Briefcase, Users, @@ -104,7 +105,9 @@ export function AppShell({ children }: { children: ReactNode }) { Counsel -
+
+ +
- {/* Desktop top bar with timer */} -
+ {/* Desktop top bar with quick-add and timer */} +
+ +
diff --git a/src/components/quick-add/quick-add.tsx b/src/components/quick-add/quick-add.tsx new file mode 100644 index 0000000..3b31f49 --- /dev/null +++ b/src/components/quick-add/quick-add.tsx @@ -0,0 +1,365 @@ +import { useEffect, useMemo, useState } from "react"; +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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Clock, Loader2, Plus, Receipt as ReceiptIcon } from "lucide-react"; +import { toast } from "sonner"; +import { roundToTenth } from "@/lib/timer"; + +interface ClientOpt { + id: string; + name: string; +} +interface CaseOpt { + id: string; + case_number: string; + title: string; + client_id: string; + default_hourly_rate: number | null; +} + +function useClientsAndCases(open: boolean) { + const [clients, setClients] = useState([]); + const [cases, setCases] = useState([]); + + useEffect(() => { + if (!open) return; + (async () => { + const [{ data: cs }, { data: ks }] = 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 }), + ]); + setClients(cs ?? []); + setCases(ks ?? []); + })(); + }, [open]); + + return { clients, cases }; +} + +/* -------------------- Quick add: Time -------------------- */ +export function QuickAddTime() { + const { user } = useAuth(); + const [open, setOpen] = useState(false); + const { clients, cases } = useClientsAndCases(open); + const [profileRate, setProfileRate] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ + clientId: "", + caseId: "", + work_date: new Date().toISOString().slice(0, 10), + hours: "", + rate: "", + description: "", + billable: true, + }); + + useEffect(() => { + if (!open || !user?.id) return; + (async () => { + const { data } = await supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle(); + setProfileRate((data as any)?.hourly_rate ?? null); + })(); + }, [open, user?.id]); + + const filteredCases = useMemo( + () => (form.clientId ? cases.filter((c) => c.client_id === form.clientId) : []), + [cases, form.clientId], + ); + const activeCase = useMemo(() => cases.find((c) => c.id === form.caseId), [cases, form.caseId]); + + // Auto-fill rate when case changes (case rate beats profile rate) + useEffect(() => { + if (!open) return; + const auto = activeCase?.default_hourly_rate ?? profileRate ?? null; + if (auto != null && !form.rate) setForm((f) => ({ ...f, rate: String(auto) })); + }, [activeCase?.default_hourly_rate, profileRate, open]); + + const reset = () => { + setForm({ + clientId: "", + caseId: "", + work_date: new Date().toISOString().slice(0, 10), + hours: "", + rate: "", + description: "", + billable: true, + }); + }; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user?.id) return toast.error("Not signed in"); + if (!form.caseId) return toast.error("Select a case"); + if (!form.description.trim()) return toast.error("Description required"); + const hours = roundToTenth(parseFloat(form.hours)); + if (!hours) return toast.error("Hours must be greater than 0"); + const rate = parseFloat(form.rate || "0"); + setSubmitting(true); + const { error } = await supabase.from("time_entries").insert({ + case_id: form.caseId, + user_id: user.id, + work_date: form.work_date, + hours, + hourly_rate: rate, + description: form.description.trim(), + billable: form.billable, + }); + setSubmitting(false); + if (error) return toast.error(error.message); + toast.success(`Logged ${hours.toFixed(1)} hr`); + reset(); + setOpen(false); + }; + + return ( + + + + + + + Log time + Hours round up to the nearest 1/10 (6 minutes). + +
+
+
+ + +
+
+ + +
+
+
+
+ + setForm({ ...form, work_date: e.target.value })} required /> +
+
+ + setForm({ ...form, hours: e.target.value })} required /> +
+
+ + setForm({ ...form, rate: e.target.value })} required /> +
+
+
+ +