diff --git a/.env b/.env new file mode 100644 index 0000000..ed5fb40 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl2b2FlbGN1bHR1YXRzdmtoZGl1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzYzNzIwMTYsImV4cCI6MjA5MTk0ODAxNn0.dXU0K_7fE1uih0kzbxTeVhfsbjG1V9CEl17DIPL_tfo" +SUPABASE_URL="https://yvoaelcultuatsvkhdiu.supabase.co" +VITE_SUPABASE_PROJECT_ID="yvoaelcultuatsvkhdiu" +VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl2b2FlbGN1bHR1YXRzdmtoZGl1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzYzNzIwMTYsImV4cCI6MjA5MTk0ODAxNn0.dXU0K_7fE1uih0kzbxTeVhfsbjG1V9CEl17DIPL_tfo" +VITE_SUPABASE_URL="https://yvoaelcultuatsvkhdiu.supabase.co" diff --git a/bun.lockb b/bun.lockb index a01dbdb..1ff96ff 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9919e5c..bc9bfe9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@cloudflare/vite-plugin": "^1.25.5", - "@hookform/resolvers": "^5.2.2", + "@hookform/resolvers": "3.10.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -40,6 +40,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@supabase/supabase-js": "^2.103.3", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.83.0", "@tanstack/react-router": "^1.168.0", diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx new file mode 100644 index 0000000..d119f0c --- /dev/null +++ b/src/components/app-shell.tsx @@ -0,0 +1,160 @@ +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { useAuth } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { + Briefcase, + Users, + FileText, + Receipt, + ShieldCheck, + LogOut, + Scale, + LayoutDashboard, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +interface NavItem { + to: string; + label: string; + icon: typeof Briefcase; + adminOnly?: boolean; +} + +const NAV: NavItem[] = [ + { to: "/", label: "Dashboard", icon: LayoutDashboard }, + { to: "/clients", label: "Clients", icon: Users }, + { to: "/cases", label: "Cases", icon: Briefcase }, + { to: "/invoices", label: "Invoices", icon: Receipt }, + { to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true }, +]; + +export function AppShell({ children }: { children: ReactNode }) { + const { user, signOut, isAdmin, roles } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + + const handleSignOut = async () => { + await signOut(); + navigate({ to: "/login" }); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Mobile top bar */} +
+ + + Counsel + + +
+ +
+
+ {NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => { + const active = + item.to === "/" + ? location.pathname === "/" + : location.pathname.startsWith(item.to); + return ( + + {item.label} + + ); + })} +
+ {children} +
+
+ ); +} + +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: string; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} + +export function PageContainer({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/components/cases/documents-tab.tsx b/src/components/cases/documents-tab.tsx new file mode 100644 index 0000000..38b827e --- /dev/null +++ b/src/components/cases/documents-tab.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Upload, FileText, Download, Trash2, Loader2 } from "lucide-react"; +import { formatDate } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseDocumentsTab({ caseId }: { caseId: string }) { + const { user } = useAuth(); + const [docs, setDocs] = useState([]); + const [uploading, setUploading] = useState(false); + const [description, setDescription] = useState(""); + const fileRef = useRef(null); + + const load = async () => { + const { data } = await supabase + .from("documents") + .select("*, uploader:profiles!documents_uploaded_by_fkey(full_name, email)") + .eq("case_id", caseId) + .order("created_at", { ascending: false }); + setDocs(data ?? []); + }; + + useEffect(() => { load(); }, [caseId]); + + const onUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 50 * 1024 * 1024) { toast.error("File too large (50MB max)"); return; } + setUploading(true); + const path = `${caseId}/${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`; + const { error: upErr } = await supabase.storage.from("case-documents").upload(path, file); + if (upErr) { toast.error("Upload failed", { description: upErr.message }); setUploading(false); return; } + const { error: insErr } = await supabase.from("documents").insert({ + case_id: caseId, + name: file.name, + storage_path: path, + mime_type: file.type, + size_bytes: file.size, + description: description.trim() || null, + uploaded_by: user?.id, + }); + if (insErr) toast.error("Save failed", { description: insErr.message }); + else { toast.success("Uploaded"); setDescription(""); load(); } + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + }; + + const download = async (doc: any) => { + const { data, error } = await supabase.storage.from("case-documents").createSignedUrl(doc.storage_path, 60); + if (error) { toast.error(error.message); return; } + window.open(data.signedUrl, "_blank"); + }; + + const del = async (doc: any) => { + if (!confirm(`Delete ${doc.name}?`)) return; + await supabase.storage.from("case-documents").remove([doc.storage_path]); + const { error } = await supabase.from("documents").delete().eq("id", doc.id); + if (error) toast.error(error.message); + else { toast.success("Deleted"); load(); } + }; + + return ( +
+ + +
+ + setDescription(e.target.value)} placeholder="e.g. Settlement draft v2" maxLength={300} /> +
+ + +
+
+ + + + + + + + + + + + + + + {docs.length === 0 && ( + + )} + {docs.map((d) => ( + + + + + + + + ))} + +
NameDescriptionUploaded byDateActions
No documents uploaded.
+
+ + {d.name} +
+
+ {d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : ""} +
+
{d.description || "—"}{d.uploader?.full_name || d.uploader?.email || "—"}{formatDate(d.created_at)} + + +
+
+
+
+ ); +} diff --git a/src/components/cases/expenses-tab.tsx b/src/components/cases/expenses-tab.tsx new file mode 100644 index 0000000..b321e42 --- /dev/null +++ b/src/components/cases/expenses-tab.tsx @@ -0,0 +1,193 @@ +import { useEffect, useRef, useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Plus, Trash2, Loader2, Receipt as ReceiptIcon } from "lucide-react"; +import { formatCurrency, formatDate } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseExpensesTab({ caseId }: { caseId: string }) { + const { user } = useAuth(); + const [items, setItems] = useState([]); + const [showForm, setShowForm] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ + expense_date: new Date().toISOString().slice(0, 10), + description: "", + amount: "", + billable: true, + }); + const [receipt, setReceipt] = useState(null); + const fileRef = useRef(null); + + const load = async () => { + const { data } = await supabase + .from("expenses") + .select("*, user:profiles!expenses_user_id_fkey(full_name, email)") + .eq("case_id", caseId) + .order("expense_date", { ascending: false }); + setItems(data ?? []); + }; + + useEffect(() => { load(); }, [caseId]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const amount = parseFloat(form.amount); + if (isNaN(amount) || amount < 0) { toast.error("Invalid amount"); return; } + if (!form.description.trim()) { toast.error("Description required"); return; } + setSubmitting(true); + let receipt_storage_path: string | null = null; + if (receipt) { + const path = `${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) { toast.error("Receipt upload failed", { description: upErr.message }); setSubmitting(false); return; } + receipt_storage_path = path; + } + const { error } = await supabase.from("expenses").insert({ + case_id: 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) toast.error(error.message); + else { + toast.success("Expense added"); + setShowForm(false); + setReceipt(null); + setForm({ ...form, amount: "", description: "" }); + if (fileRef.current) fileRef.current.value = ""; + load(); + } + }; + + const del = async (item: any) => { + if (!confirm("Delete this expense?")) return; + if (item.receipt_storage_path) await supabase.storage.from("case-receipts").remove([item.receipt_storage_path]); + const { error } = await supabase.from("expenses").delete().eq("id", item.id); + if (error) toast.error(error.message); else { toast.success("Deleted"); load(); } + }; + + const downloadReceipt = async (path: string) => { + const { data, error } = await supabase.storage.from("case-receipts").createSignedUrl(path, 60); + if (error) { toast.error(error.message); return; } + window.open(data.signedUrl, "_blank"); + }; + + const totals = items.reduce( + (acc, e) => ({ + total: acc.total + Number(e.amount), + billable: acc.billable + (e.billable ? Number(e.amount) : 0), + unbilled: acc.unbilled + (e.billable && !e.invoice_id ? Number(e.amount) : 0), + }), + { total: 0, billable: 0, unbilled: 0 }, + ); + + return ( +
+
+
+ + + +
+ +
+ + {showForm && ( + + +
+
+ + setForm({ ...form, expense_date: e.target.value })} required /> +
+
+ + setForm({ ...form, amount: e.target.value })} required /> +
+
+ + setForm({ ...form, description: e.target.value })} required maxLength={500} /> +
+
+ +
+
+ + setReceipt(e.target.files?.[0] ?? null)} /> +
+
+ +
+
+
+
+ )} + + + + + + + + + + + + + + + + {items.length === 0 && } + {items.map((e) => ( + + + + + + + + + ))} + +
DateDescriptionUserAmountStatus
No expenses.
{formatDate(e.expense_date)} + {e.description} + {e.receipt_storage_path && ( + + )} + {e.user?.full_name || e.user?.email}{formatCurrency(e.amount)}{!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"} + {!e.invoice_id && } +
+
+
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/components/cases/invoices-tab.tsx b/src/components/cases/invoices-tab.tsx new file mode 100644 index 0000000..b6ed1db --- /dev/null +++ b/src/components/cases/invoices-tab.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { FilePlus, Loader2 } from "lucide-react"; +import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) { + const { user } = useAuth(); + const [invoices, setInvoices] = useState([]); + const [unbilledTime, setUnbilledTime] = useState([]); + const [unbilledExpenses, setUnbilledExpenses] = useState([]); + const [generating, setGenerating] = useState(false); + + const load = async () => { + const [{ data: invs }, { data: t }, { data: ex }] = await Promise.all([ + supabase.from("invoices").select("*").eq("case_id", caseRecord.id).order("created_at", { ascending: false }), + supabase.from("time_entries").select("*").eq("case_id", caseRecord.id).eq("billable", true).is("invoice_id", null), + supabase.from("expenses").select("*").eq("case_id", caseRecord.id).eq("billable", true).is("invoice_id", null), + ]); + setInvoices(invs ?? []); + setUnbilledTime(t ?? []); + setUnbilledExpenses(ex ?? []); + }; + + useEffect(() => { load(); }, [caseRecord.id]); + + const timeTotal = unbilledTime.reduce((s, e) => s + Number(e.hours) * Number(e.hourly_rate), 0); + const expensesTotal = unbilledExpenses.reduce((s, e) => s + Number(e.amount), 0); + const subtotal = timeTotal + expensesTotal; + + const generate = async () => { + if (subtotal <= 0) { toast.error("Nothing to invoice"); return; } + setGenerating(true); + const yr = new Date().getFullYear(); + const num = `INV-${yr}-${Math.floor(1000 + Math.random() * 9000)}`; + const due = new Date(); due.setDate(due.getDate() + 30); + const { data: inv, error } = await supabase.from("invoices").insert({ + invoice_number: num, + client_id: caseRecord.client.id, + case_id: caseRecord.id, + status: "draft", + issue_date: new Date().toISOString().slice(0, 10), + due_date: due.toISOString().slice(0, 10), + subtotal, + tax: 0, + total: subtotal, + created_by: user?.id, + }).select("id").single(); + if (error || !inv) { toast.error(error?.message || "Failed"); setGenerating(false); return; } + // Link entries + const timeIds = unbilledTime.map((t) => t.id); + const expIds = unbilledExpenses.map((e) => e.id); + if (timeIds.length) await supabase.from("time_entries").update({ invoice_id: inv.id }).in("id", timeIds); + if (expIds.length) await supabase.from("expenses").update({ invoice_id: inv.id }).in("id", expIds); + setGenerating(false); + toast.success(`Invoice ${num} created (draft)`); + load(); + }; + + return ( +
+ + +
+
Unbilled work
+
+ {unbilledTime.length} time entries · {unbilledExpenses.length} expenses +
+
{formatCurrency(subtotal)}
+
+ +
+
+ + + + + + + + + + + + + + + {invoices.length === 0 && } + {invoices.map((i) => ( + + + + + + + + ))} + +
Invoice #IssuedDueStatusTotal
No invoices yet.
+ + {i.invoice_number} + + {formatDate(i.issue_date)}{formatDate(i.due_date)}{i.status}{formatCurrency(i.total)}
+
+
+
+ ); +} diff --git a/src/components/cases/status-tab.tsx b/src/components/cases/status-tab.tsx new file mode 100644 index 0000000..dc634f2 --- /dev/null +++ b/src/components/cases/status-tab.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +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 { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Plus, Trash2, Loader2 } from "lucide-react"; +import { formatDateTime } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseStatusTab({ caseId }: { caseId: string }) { + const { user, isAdmin } = useAuth(); + const [items, setItems] = useState([]); + const [showForm, setShowForm] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ title: "", body: "" }); + + const load = async () => { + const { data } = await supabase + .from("status_updates") + .select("*, user:profiles!status_updates_created_by_fkey(full_name, email)") + .eq("case_id", caseId) + .order("created_at", { ascending: false }); + setItems(data ?? []); + }; + + useEffect(() => { load(); }, [caseId]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.title.trim() || !form.body.trim()) { toast.error("Title and body required"); return; } + setSubmitting(true); + const { error } = await supabase.from("status_updates").insert({ + case_id: caseId, + title: form.title.trim(), + body: form.body.trim(), + created_by: user?.id, + }); + setSubmitting(false); + if (error) toast.error(error.message); + else { toast.success("Update logged"); setShowForm(false); setForm({ title: "", body: "" }); load(); } + }; + + const del = async (item: any) => { + if (!confirm("Delete this update?")) return; + const { error } = await supabase.from("status_updates").delete().eq("id", item.id); + if (error) toast.error(error.message); else { toast.success("Deleted"); load(); } + }; + + return ( +
+
+ +
+ + {showForm && ( + + +
+
+ + setForm({ ...form, title: e.target.value })} required maxLength={200} /> +
+
+ +