+ {/* 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 (
+
+ );
+}
+
+/* -------------------- Quick add: Expense -------------------- */
+export function QuickAddExpense() {
+ const { user } = useAuth();
+ const [open, setOpen] = useState(false);
+ const { clients, cases } = useClientsAndCases(open);
+ const [submitting, setSubmitting] = useState(false);
+ const [form, setForm] = useState({
+ clientId: "",
+ caseId: "",
+ expense_date: new Date().toISOString().slice(0, 10),
+ description: "",
+ amount: "",
+ billable: true,
+ });
+ const [receipt, setReceipt] = useState(null);
+
+ const filteredCases = useMemo(
+ () => (form.clientId ? cases.filter((c) => c.client_id === form.clientId) : []),
+ [cases, form.clientId],
+ );
+
+ const reset = () => {
+ setForm({
+ clientId: "",
+ caseId: "",
+ expense_date: new Date().toISOString().slice(0, 10),
+ description: "",
+ amount: "",
+ billable: true,
+ });
+ setReceipt(null);
+ };
+
+ 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 amount = parseFloat(form.amount);
+ if (Number.isNaN(amount) || amount < 0) return toast.error("Invalid amount");
+ setSubmitting(true);
+ let receipt_storage_path: string | null = null;
+ if (receipt) {
+ const path = `${form.caseId}/${Date.now()}-${receipt.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
+ const { error: upErr } = await supabase.storage.from("case-receipts").upload(path, receipt);
+ if (upErr) {
+ setSubmitting(false);
+ return toast.error("Receipt upload failed", { description: upErr.message });
+ }
+ receipt_storage_path = path;
+ }
+ const { error } = await supabase.from("expenses").insert({
+ case_id: form.caseId,
+ user_id: user.id,
+ expense_date: form.expense_date,
+ description: form.description.trim(),
+ amount,
+ billable: form.billable,
+ receipt_storage_path,
+ });
+ setSubmitting(false);
+ if (error) return toast.error(error.message);
+ toast.success("Expense added");
+ reset();
+ setOpen(false);
+ };
+
+ return (
+
+ );
+}