Built core app with security

X-Lovable-Edit-ID: edt-5fe0c5f7-356f-4f6c-81d8-4457bc2ad461
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-16 22:53:03 +00:00
co-authored by renee-png
30 changed files with 4032 additions and 131 deletions
+5
View File
@@ -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"
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -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",
+160
View File
@@ -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 (
<div className="flex min-h-screen bg-background">
{/* Sidebar */}
<aside className="hidden md:flex w-64 flex-col bg-sidebar text-sidebar-foreground border-r border-sidebar-border">
<div className="px-6 py-5 border-b border-sidebar-border flex items-center gap-2.5">
<div className="h-9 w-9 rounded-md bg-sidebar-primary flex items-center justify-center">
<Scale className="h-5 w-5 text-sidebar-primary-foreground" />
</div>
<div className="leading-tight">
<div className="font-serif text-lg">Counsel</div>
<div className="text-[10px] uppercase tracking-widest text-sidebar-foreground/60">
Law Firm Suite
</div>
</div>
</div>
<nav className="flex-1 px-3 py-4 space-y-0.5">
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => {
const active =
item.to === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.to);
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
<div className="px-4 py-4 border-t border-sidebar-border">
<div className="text-xs text-sidebar-foreground/70 truncate mb-0.5">{user?.email}</div>
<div className="text-[10px] uppercase tracking-wider text-sidebar-foreground/50 mb-3">
{roles.join(" · ") || "no role"}
</div>
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground"
onClick={handleSignOut}
>
<LogOut className="h-4 w-4 mr-2" />
Sign out
</Button>
</div>
</aside>
{/* Mobile top bar */}
<div className="md:hidden fixed top-0 inset-x-0 h-14 bg-sidebar text-sidebar-foreground border-b border-sidebar-border flex items-center justify-between px-4 z-30">
<Link to="/" className="flex items-center gap-2">
<Scale className="h-5 w-5" />
<span className="font-serif text-lg">Counsel</span>
</Link>
<Button variant="ghost" size="sm" onClick={handleSignOut} className="text-sidebar-foreground">
<LogOut className="h-4 w-4" />
</Button>
</div>
<main className="flex-1 min-w-0 md:ml-0 mt-14 md:mt-0">
<div className="md:hidden flex overflow-x-auto gap-1 px-3 py-2 border-b bg-card">
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => {
const active =
item.to === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.to);
return (
<Link
key={item.to}
to={item.to}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium whitespace-nowrap",
active ? "bg-primary text-primary-foreground" : "text-muted-foreground",
)}
>
{item.label}
</Link>
);
})}
</div>
{children}
</main>
</div>
);
}
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-6">
<div>
<h1 className="font-serif text-3xl text-foreground">{title}</h1>
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
);
}
export function PageContainer({ children }: { children: ReactNode }) {
return <div className="p-6 md:p-8 max-w-[1400px] mx-auto">{children}</div>;
}
+125
View File
@@ -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<any[]>([]);
const [uploading, setUploading] = useState(false);
const [description, setDescription] = useState("");
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-4">
<Card className="border-border/60">
<CardContent className="p-4 flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div className="flex-1 space-y-1.5">
<Label className="text-xs">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="e.g. Settlement draft v2" maxLength={300} />
</div>
<input ref={fileRef} type="file" className="hidden" onChange={onUpload} />
<Button onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Upload className="h-4 w-4 mr-2" />}
Upload document
</Button>
</CardContent>
</Card>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">Uploaded by</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{docs.length === 0 && (
<tr><td colSpan={5} className="text-center py-12 text-muted-foreground">No documents uploaded.</td></tr>
)}
{docs.map((d) => (
<tr key={d.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{d.name}</span>
</div>
<div className="text-xs text-muted-foreground ml-6">
{d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : ""}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{d.description || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{d.uploader?.full_name || d.uploader?.email || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(d.created_at)}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => download(d)}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => del(d)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
+193
View File
@@ -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<any[]>([]);
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<File | null>(null);
const fileRef = useRef<HTMLInputElement>(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 (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex gap-4 text-sm">
<Stat label="Total" value={formatCurrency(totals.total)} />
<Stat label="Billable" value={formatCurrency(totals.billable)} />
<Stat label="Unbilled" value={formatCurrency(totals.unbilled)} />
</div>
<Button onClick={() => setShowForm((s) => !s)}>
<Plus className="h-4 w-4 mr-2" /> {showForm ? "Cancel" : "Add expense"}
</Button>
</div>
{showForm && (
<Card className="border-border/60">
<CardContent className="p-4">
<form onSubmit={submit} className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
<Input type="date" value={form.expense_date} onChange={(e) => setForm({ ...form, expense_date: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Amount ($)</Label>
<Input type="number" step="0.01" min="0" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} required />
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Description</Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required maxLength={500} />
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
</div>
<div className="md:col-span-2">
<Label className="text-xs">Receipt (optional)</Label>
<Input ref={fileRef} type="file" onChange={(e) => setReceipt(e.target.files?.[0] ?? null)} />
</div>
<div className="md:col-span-4 flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save expense
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={6} className="text-center py-12 text-muted-foreground">No expenses.</td></tr>}
{items.map((e) => (
<tr key={e.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.expense_date)}</td>
<td className="px-4 py-3">
{e.description}
{e.receipt_storage_path && (
<Button variant="link" size="sm" className="h-auto p-0 ml-2" onClick={() => downloadReceipt(e.receipt_storage_path)}>
<ReceiptIcon className="h-3 w-3 mr-1" /> receipt
</Button>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{e.user?.full_name || e.user?.email}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(e.amount)}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"}</td>
<td className="px-4 py-3 text-right">
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="font-serif text-lg">{value}</div>
</div>
);
}
+116
View File
@@ -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<any[]>([]);
const [unbilledTime, setUnbilledTime] = useState<any[]>([]);
const [unbilledExpenses, setUnbilledExpenses] = useState<any[]>([]);
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 (
<div className="space-y-4">
<Card className="border-border/60 bg-muted/20">
<CardContent className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="text-sm space-y-0.5">
<div className="font-medium">Unbilled work</div>
<div className="text-muted-foreground text-xs">
{unbilledTime.length} time entries · {unbilledExpenses.length} expenses
</div>
<div className="font-serif text-2xl text-foreground mt-1">{formatCurrency(subtotal)}</div>
</div>
<Button onClick={generate} disabled={subtotal <= 0 || generating}>
{generating ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <FilePlus className="h-4 w-4 mr-2" />}
Generate invoice
</Button>
</CardContent>
</Card>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Invoice #</th>
<th className="text-left px-4 py-3 font-medium">Issued</th>
<th className="text-left px-4 py-3 font-medium">Due</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Total</th>
</tr>
</thead>
<tbody>
{invoices.length === 0 && <tr><td colSpan={5} className="text-center py-12 text-muted-foreground">No invoices yet.</td></tr>}
{invoices.map((i) => (
<tr key={i.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<Link to="/invoices/$invoiceId" params={{ invoiceId: i.id }} className="font-medium hover:text-primary">
{i.invoice_number}
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.issue_date)}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.due_date)}</td>
<td className="px-4 py-3"><Badge variant="outline" className={statusBadgeClass(i.status)}>{i.status}</Badge></td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(i.total)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
+122
View File
@@ -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<any[]>([]);
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 (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setShowForm((s) => !s)}>
<Plus className="h-4 w-4 mr-2" /> {showForm ? "Cancel" : "Add status update"}
</Button>
</div>
{showForm && (
<Card className="border-border/60">
<CardContent className="p-4">
<form onSubmit={submit} className="space-y-3">
<div className="space-y-1.5">
<Label>Title</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required maxLength={200} />
</div>
<div className="space-y-1.5">
<Label>Details</Label>
<Textarea rows={4} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} required maxLength={5000} />
</div>
<div className="flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Log update
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{items.length === 0 && (
<Card className="border-border/60">
<CardContent className="p-8 text-center text-muted-foreground text-sm">
No status updates yet. Log significant case events to build a timeline.
</CardContent>
</Card>
)}
<div className="relative">
{items.map((u) => {
const canDelete = isAdmin || u.created_by === user?.id;
return (
<div key={u.id} className="relative pl-8 pb-6">
<div className="absolute left-3 top-2 w-px h-full bg-border" />
<div className="absolute left-[7px] top-2 h-3 w-3 rounded-full bg-primary border-2 border-background" />
<Card className="border-border/60">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3 mb-1.5">
<div>
<h4 className="font-serif text-base">{u.title}</h4>
<div className="text-xs text-muted-foreground">
{u.user?.full_name || u.user?.email || "Unknown"} · {formatDateTime(u.created_at)}
</div>
</div>
{canDelete && (
<Button variant="ghost" size="icon" onClick={() => del(u)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
<p className="text-sm whitespace-pre-wrap">{u.body}</p>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
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 { Checkbox } from "@/components/ui/checkbox";
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 { toast } from "sonner";
export function CaseTimeTab({ caseRecord }: { caseRecord: any }) {
const { user } = useAuth();
const [entries, setEntries] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
work_date: new Date().toISOString().slice(0, 10),
hours: "",
hourly_rate: caseRecord.default_hourly_rate?.toString() ?? "",
description: "",
billable: true,
});
const load = async () => {
const { data } = await supabase
.from("time_entries")
.select("*, user:profiles!time_entries_user_id_fkey(full_name, email)")
.eq("case_id", caseRecord.id)
.order("work_date", { ascending: false });
setEntries(data ?? []);
};
useEffect(() => { load(); }, [caseRecord.id]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const hours = parseFloat(form.hours);
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; }
setSubmitting(true);
const { error } = await supabase.from("time_entries").insert({
case_id: caseRecord.id,
user_id: user?.id,
work_date: form.work_date,
hours,
hourly_rate: rate,
description: form.description.trim(),
billable: form.billable,
});
setSubmitting(false);
if (error) toast.error(error.message);
else {
toast.success("Time entry added");
setShowForm(false);
setForm({ ...form, hours: "", description: "" });
load();
}
};
const del = async (id: string) => {
if (!confirm("Delete this entry?")) return;
const { error } = await supabase.from("time_entries").delete().eq("id", id);
if (error) toast.error(error.message); else { toast.success("Deleted"); load(); }
};
const totals = entries.reduce(
(acc, e) => ({
hours: acc.hours + Number(e.hours),
billable: acc.billable + (e.billable ? Number(e.hours) * Number(e.hourly_rate) : 0),
unbilled: acc.unbilled + (e.billable && !e.invoice_id ? Number(e.hours) * Number(e.hourly_rate) : 0),
}),
{ hours: 0, billable: 0, unbilled: 0 },
);
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex gap-4 text-sm">
<Stat label="Hours" value={totals.hours.toFixed(1)} />
<Stat label="Billed total" value={formatCurrency(totals.billable)} />
<Stat label="Unbilled" value={formatCurrency(totals.unbilled)} />
</div>
<Button onClick={() => setShowForm((s) => !s)}>
<Plus className="h-4 w-4 mr-2" /> {showForm ? "Cancel" : "Log time"}
</Button>
</div>
{showForm && (
<Card className="border-border/60">
<CardContent className="p-4">
<form onSubmit={submit} className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
<Input type="date" value={form.work_date} onChange={(e) => setForm({ ...form, work_date: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hours</Label>
<Input type="number" step="0.1" min="0.1" value={form.hours} onChange={(e) => setForm({ ...form, hours: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly rate ($)</Label>
<Input type="number" step="0.01" min="0" value={form.hourly_rate} onChange={(e) => setForm({ ...form, hourly_rate: e.target.value })} required />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
</div>
<div className="md:col-span-4 space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required maxLength={1000} />
</div>
<div className="md:col-span-4 flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save entry
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-right px-4 py-3 font-medium">Hours</th>
<th className="text-right px-4 py-3 font-medium">Rate</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={8} className="text-center py-12 text-muted-foreground">No time entries.</td></tr>}
{entries.map((e) => (
<tr key={e.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.work_date)}</td>
<td className="px-4 py-3 text-muted-foreground">{e.user?.full_name || e.user?.email}</td>
<td className="px-4 py-3 max-w-md">{e.description}</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(e.hours).toFixed(1)}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{formatCurrency(e.hourly_rate)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{e.billable ? formatCurrency(Number(e.hours) * Number(e.hourly_rate)) : "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"}</td>
<td className="px-4 py-3 text-right">
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e.id)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="font-serif text-lg">{value}</div>
</div>
);
}
@@ -0,0 +1,362 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
const boardMember = z.object({
name: z.string().trim().min(1, "Required").max(120),
role: z.string().trim().max(60).optional().or(z.literal("")),
email: z.string().trim().email("Invalid email").max(255).optional().or(z.literal("")),
phone: z.string().trim().max(40).optional().or(z.literal("")),
});
const schema = z.object({
client_type: z.enum(["hoa", "individual", "business"]),
name: z.string().trim().min(1, "Required").max(200),
primary_contact_name: z.string().trim().max(120).optional().or(z.literal("")),
primary_contact_email: z.string().trim().email("Invalid email").max(255).optional().or(z.literal("")),
primary_contact_phone: z.string().trim().max(40).optional().or(z.literal("")),
address_line1: z.string().trim().max(200).optional().or(z.literal("")),
city: z.string().trim().max(100).optional().or(z.literal("")),
state: z.string().trim().max(60).optional().or(z.literal("")),
postal_code: z.string().trim().max(20).optional().or(z.literal("")),
management_company: z.string().trim().max(200).optional().or(z.literal("")),
num_units: z.coerce.number().int().min(0).max(100000).optional().or(z.literal("").transform(() => undefined)),
board_members: z.array(boardMember),
notes: z.string().trim().max(5000).optional().or(z.literal("")),
});
type FormValues = z.infer<typeof schema>;
export function ClientFormDialog({
open,
onOpenChange,
onSaved,
client,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onSaved?: () => void;
client?: any;
}) {
const { user } = useAuth();
const [submitting, setSubmitting] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
client_type: "hoa",
name: "",
primary_contact_name: "",
primary_contact_email: "",
primary_contact_phone: "",
address_line1: "",
city: "",
state: "",
postal_code: "",
management_company: "",
num_units: undefined as any,
board_members: [],
notes: "",
},
});
useEffect(() => {
if (open) {
form.reset({
client_type: client?.client_type ?? "hoa",
name: client?.name ?? "",
primary_contact_name: client?.primary_contact_name ?? "",
primary_contact_email: client?.primary_contact_email ?? "",
primary_contact_phone: client?.primary_contact_phone ?? "",
address_line1: client?.address_line1 ?? "",
city: client?.city ?? "",
state: client?.state ?? "",
postal_code: client?.postal_code ?? "",
management_company: client?.management_company ?? "",
num_units: client?.num_units ?? (undefined as any),
board_members: client?.board_members ?? [],
notes: client?.notes ?? "",
});
}
}, [open, client, form]);
const clientType = form.watch("client_type");
const board = form.watch("board_members");
const addBoardMember = () => {
form.setValue("board_members", [...board, { name: "", role: "", email: "", phone: "" }]);
};
const removeBoardMember = (idx: number) => {
form.setValue("board_members", board.filter((_, i) => i !== idx));
};
const onSubmit = async (values: FormValues) => {
setSubmitting(true);
const payload: any = {
...values,
num_units: values.num_units || null,
// strip empty strings to nulls
...Object.fromEntries(
Object.entries(values).map(([k, v]) => [k, v === "" ? null : v]),
),
};
payload.board_members = values.board_members;
if (!client) payload.created_by = user?.id;
const { error } = client
? await supabase.from("clients").update(payload).eq("id", client.id)
: await supabase.from("clients").insert(payload);
setSubmitting(false);
if (error) {
toast.error("Could not save client", { description: error.message });
return;
}
toast.success(client ? "Client updated" : "Client created");
onOpenChange(false);
onSaved?.();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-serif">{client ? "Edit client" : "New client"}</DialogTitle>
<DialogDescription>
HOA associations support board members and management companies. Other client types only show
relevant fields.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="client_type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="hoa">HOA / Association</SelectItem>
<SelectItem value="business">Business</SelectItem>
<SelectItem value="individual">Individual</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{clientType === "hoa" ? "Association name" : "Name"}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{clientType === "hoa" && (
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="management_company"
render={({ field }) => (
<FormItem>
<FormLabel>Management company</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="num_units"
render={({ field }) => (
<FormItem>
<FormLabel># of units</FormLabel>
<FormControl>
<Input type="number" min={0} {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormField
control={form.control}
name="primary_contact_name"
render={({ field }) => (
<FormItem>
<FormLabel>Primary contact</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="primary_contact_email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="primary_contact_phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="address_line1"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-3 gap-3">
<FormField control={form.control} name="city" render={({ field }) => (
<FormItem><FormLabel>City</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="state" render={({ field }) => (
<FormItem><FormLabel>State</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="postal_code" render={({ field }) => (
<FormItem><FormLabel>Zip</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
{clientType === "hoa" && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium">Board members</label>
<Button type="button" variant="outline" size="sm" onClick={addBoardMember}>
<Plus className="h-3 w-3 mr-1" /> Add member
</Button>
</div>
<div className="space-y-2">
{board.length === 0 && (
<p className="text-xs text-muted-foreground italic">No board members yet.</p>
)}
{board.map((_, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-start p-3 border rounded-md bg-muted/30">
<Input
className="col-span-3"
placeholder="Name"
value={board[idx].name}
onChange={(e) => {
const next = [...board];
next[idx].name = e.target.value;
form.setValue("board_members", next);
}}
/>
<Input
className="col-span-2"
placeholder="Role"
value={board[idx].role}
onChange={(e) => {
const next = [...board];
next[idx].role = e.target.value;
form.setValue("board_members", next);
}}
/>
<Input
className="col-span-3"
placeholder="Email"
type="email"
value={board[idx].email}
onChange={(e) => {
const next = [...board];
next[idx].email = e.target.value;
form.setValue("board_members", next);
}}
/>
<Input
className="col-span-3"
placeholder="Phone"
value={board[idx].phone}
onChange={(e) => {
const next = [...board];
next[idx].phone = e.target.value;
form.setValue("board_members", next);
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="col-span-1"
onClick={() => removeBoardMember(idx)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl><Textarea rows={3} {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{client ? "Save changes" : "Create client"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/app-shell";
import { Loader2 } from "lucide-react";
export function ProtectedLayout({ children, adminOnly }: { children: ReactNode; adminOnly?: boolean }) {
const { loading, session, isAdmin } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (loading) return;
if (!session) {
navigate({ to: "/login" });
} else if (adminOnly && !isAdmin) {
navigate({ to: "/" });
}
}, [loading, session, isAdmin, adminOnly, navigate]);
if (loading || !session || (adminOnly && !isAdmin)) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return <AppShell>{children}</AppShell>;
}
@@ -0,0 +1,77 @@
// This file is automatically generated. Do not edit it directly.
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { createClient } from '@supabase/supabase-js'
import type { Database } from './types'
export const requireSupabaseAuth = createMiddleware({ type: 'function' }).server(
async ({ next }) => {
const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
throw new Response(
'Missing Supabase environment variables. Ensure SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are set.',
{ status: 500 }
);
}
const request = getRequest();
if (!request?.headers) {
throw new Response('Unauthorized: No request headers available', { status: 401 });
}
const authHeader = request.headers.get('authorization');
if (!authHeader) {
throw new Response('Unauthorized: No authorization header provided', { status: 401 });
}
if (!authHeader.startsWith('Bearer ')) {
throw new Response('Unauthorized: Only Bearer tokens are supported', { status: 401 });
}
const token = authHeader.replace('Bearer ', '');
if (!token) {
throw new Response('Unauthorized: No token provided', { status: 401 });
}
const supabase = createClient<Database>(
SUPABASE_URL!,
SUPABASE_PUBLISHABLE_KEY!,
{
global: {
headers: {
Authorization: `Bearer ${token}`,
},
},
auth: {
storage: undefined,
persistSession: false,
autoRefreshToken: false,
},
}
);
const { data, error } = await supabase.auth.getClaims(token);
if (error || !data?.claims) {
throw new Response('Unauthorized: Invalid token', { status: 401 });
}
if (!data.claims.sub) {
throw new Response('Unauthorized: No user ID found in token', { status: 401 });
}
return next({
context: {
supabase,
userId: data.claims.sub,
claims: data.claims,
},
})
}
)
@@ -0,0 +1,37 @@
// This file is automatically generated. Do not edit it directly.
// Server-side Supabase client with service role key - bypasses RLS.
// Use this for admin operations in server functions and server routes only.
// For user-authenticated queries (with RLS), use the auth middleware instead.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
function createSupabaseAdminClient() {
const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
throw new Error(
'Missing Supabase server environment variables. Ensure SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set.'
);
}
return createClient<Database>(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: {
storage: undefined,
persistSession: false,
autoRefreshToken: false,
}
});
}
let _supabaseAdmin: ReturnType<typeof createSupabaseAdminClient> | undefined;
// Server-side Supabase client with service role - bypasses RLS
// SECURITY: Only use this for trusted server-side operations, never expose to client code
// Import like: import { supabaseAdmin } from "@/integrations/supabase/client.server";
export const supabaseAdmin = new Proxy({} as ReturnType<typeof createSupabaseAdminClient>, {
get(_, prop, receiver) {
if (!_supabaseAdmin) _supabaseAdmin = createSupabaseAdminClient();
return Reflect.get(_supabaseAdmin, prop, receiver);
},
});
+36
View File
@@ -0,0 +1,36 @@
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
function createSupabaseClient() {
// Use import.meta.env for client-side (Vite build-time replacement)
// Fall back to process.env for SSR (server-side rendering)
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY;
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
throw new Error(
'Missing Supabase environment variables. Ensure SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY (or VITE_ prefixed versions) are set in your .env file.'
);
}
return createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: typeof window !== 'undefined' ? localStorage : undefined,
persistSession: true,
autoRefreshToken: true,
}
});
}
let _supabase: ReturnType<typeof createSupabaseClient> | undefined;
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = new Proxy({} as ReturnType<typeof createSupabaseClient>, {
get(_, prop, receiver) {
if (!_supabase) _supabase = createSupabaseClient();
return Reflect.get(_supabase, prop, receiver);
},
});
+621
View File
@@ -0,0 +1,621 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.5"
}
public: {
Tables: {
cases: {
Row: {
assigned_attorney_id: string | null
case_number: string
client_id: string
closed_at: string | null
created_at: string
created_by: string | null
default_hourly_rate: number | null
description: string | null
id: string
opened_at: string
practice_area: string | null
status: Database["public"]["Enums"]["case_status"]
title: string
updated_at: string
}
Insert: {
assigned_attorney_id?: string | null
case_number: string
client_id: string
closed_at?: string | null
created_at?: string
created_by?: string | null
default_hourly_rate?: number | null
description?: string | null
id?: string
opened_at?: string
practice_area?: string | null
status?: Database["public"]["Enums"]["case_status"]
title: string
updated_at?: string
}
Update: {
assigned_attorney_id?: string | null
case_number?: string
client_id?: string
closed_at?: string | null
created_at?: string
created_by?: string | null
default_hourly_rate?: number | null
description?: string | null
id?: string
opened_at?: string
practice_area?: string | null
status?: Database["public"]["Enums"]["case_status"]
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "cases_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
]
}
clients: {
Row: {
address_line1: string | null
address_line2: string | null
board_members: Json
city: string | null
client_type: Database["public"]["Enums"]["client_type"]
created_at: string
created_by: string | null
id: string
management_company: string | null
name: string
notes: string | null
num_units: number | null
postal_code: string | null
primary_contact_email: string | null
primary_contact_name: string | null
primary_contact_phone: string | null
state: string | null
updated_at: string
}
Insert: {
address_line1?: string | null
address_line2?: string | null
board_members?: Json
city?: string | null
client_type?: Database["public"]["Enums"]["client_type"]
created_at?: string
created_by?: string | null
id?: string
management_company?: string | null
name: string
notes?: string | null
num_units?: number | null
postal_code?: string | null
primary_contact_email?: string | null
primary_contact_name?: string | null
primary_contact_phone?: string | null
state?: string | null
updated_at?: string
}
Update: {
address_line1?: string | null
address_line2?: string | null
board_members?: Json
city?: string | null
client_type?: Database["public"]["Enums"]["client_type"]
created_at?: string
created_by?: string | null
id?: string
management_company?: string | null
name?: string
notes?: string | null
num_units?: number | null
postal_code?: string | null
primary_contact_email?: string | null
primary_contact_name?: string | null
primary_contact_phone?: string | null
state?: string | null
updated_at?: string
}
Relationships: []
}
documents: {
Row: {
case_id: string
created_at: string
description: string | null
id: string
mime_type: string | null
name: string
size_bytes: number | null
storage_path: string
uploaded_by: string | null
}
Insert: {
case_id: string
created_at?: string
description?: string | null
id?: string
mime_type?: string | null
name: string
size_bytes?: number | null
storage_path: string
uploaded_by?: string | null
}
Update: {
case_id?: string
created_at?: string
description?: string | null
id?: string
mime_type?: string | null
name?: string
size_bytes?: number | null
storage_path?: string
uploaded_by?: string | null
}
Relationships: [
{
foreignKeyName: "documents_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
]
}
expenses: {
Row: {
amount: number
billable: boolean
case_id: string
created_at: string
description: string
expense_date: string
id: string
invoice_id: string | null
receipt_storage_path: string | null
updated_at: string
user_id: string
}
Insert: {
amount: number
billable?: boolean
case_id: string
created_at?: string
description: string
expense_date?: string
id?: string
invoice_id?: string | null
receipt_storage_path?: string | null
updated_at?: string
user_id: string
}
Update: {
amount?: number
billable?: boolean
case_id?: string
created_at?: string
description?: string
expense_date?: string
id?: string
invoice_id?: string | null
receipt_storage_path?: string | null
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "expenses_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
{
foreignKeyName: "expenses_invoice_fk"
columns: ["invoice_id"]
isOneToOne: false
referencedRelation: "invoices"
referencedColumns: ["id"]
},
]
}
invoices: {
Row: {
amount_paid: number
case_id: string | null
client_id: string
created_at: string
created_by: string | null
due_date: string | null
id: string
invoice_number: string
issue_date: string
notes: string | null
paid_at: string | null
status: Database["public"]["Enums"]["invoice_status"]
subtotal: number
tax: number
total: number
updated_at: string
}
Insert: {
amount_paid?: number
case_id?: string | null
client_id: string
created_at?: string
created_by?: string | null
due_date?: string | null
id?: string
invoice_number: string
issue_date?: string
notes?: string | null
paid_at?: string | null
status?: Database["public"]["Enums"]["invoice_status"]
subtotal?: number
tax?: number
total?: number
updated_at?: string
}
Update: {
amount_paid?: number
case_id?: string | null
client_id?: string
created_at?: string
created_by?: string | null
due_date?: string | null
id?: string
invoice_number?: string
issue_date?: string
notes?: string | null
paid_at?: string | null
status?: Database["public"]["Enums"]["invoice_status"]
subtotal?: number
tax?: number
total?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "invoices_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
{
foreignKeyName: "invoices_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
created_at: string
email: string
full_name: string
id: string
updated_at: string
}
Insert: {
created_at?: string
email?: string
full_name?: string
id: string
updated_at?: string
}
Update: {
created_at?: string
email?: string
full_name?: string
id?: string
updated_at?: string
}
Relationships: []
}
status_updates: {
Row: {
body: string
case_id: string
created_at: string
created_by: string | null
id: string
report_storage_path: string | null
title: string
}
Insert: {
body: string
case_id: string
created_at?: string
created_by?: string | null
id?: string
report_storage_path?: string | null
title: string
}
Update: {
body?: string
case_id?: string
created_at?: string
created_by?: string | null
id?: string
report_storage_path?: string | null
title?: string
}
Relationships: [
{
foreignKeyName: "status_updates_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
]
}
time_entries: {
Row: {
billable: boolean
case_id: string
created_at: string
description: string
hourly_rate: number
hours: number
id: string
invoice_id: string | null
updated_at: string
user_id: string
work_date: string
}
Insert: {
billable?: boolean
case_id: string
created_at?: string
description: string
hourly_rate?: number
hours: number
id?: string
invoice_id?: string | null
updated_at?: string
user_id: string
work_date?: string
}
Update: {
billable?: boolean
case_id?: string
created_at?: string
description?: string
hourly_rate?: number
hours?: number
id?: string
invoice_id?: string | null
updated_at?: string
user_id?: string
work_date?: string
}
Relationships: [
{
foreignKeyName: "time_entries_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
{
foreignKeyName: "time_entries_invoice_fk"
columns: ["invoice_id"]
isOneToOne: false
referencedRelation: "invoices"
referencedColumns: ["id"]
},
]
}
user_roles: {
Row: {
created_at: string
id: string
role: Database["public"]["Enums"]["app_role"]
user_id: string
}
Insert: {
created_at?: string
id?: string
role: Database["public"]["Enums"]["app_role"]
user_id: string
}
Update: {
created_at?: string
id?: string
role?: Database["public"]["Enums"]["app_role"]
user_id?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
can_access_case: {
Args: { _case_id: string; _user_id: string }
Returns: boolean
}
has_role: {
Args: {
_role: Database["public"]["Enums"]["app_role"]
_user_id: string
}
Returns: boolean
}
is_admin: { Args: { _user_id: string }; Returns: boolean }
}
Enums: {
app_role: "admin" | "attorney" | "staff"
case_status:
| "intake"
| "active"
| "on_hold"
| "closed_won"
| "closed_lost"
| "closed"
client_type: "hoa" | "individual" | "business"
invoice_status: "draft" | "sent" | "paid" | "overdue" | "void"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {
app_role: ["admin", "attorney", "staff"],
case_status: [
"intake",
"active",
"on_hold",
"closed_won",
"closed_lost",
"closed",
],
client_type: ["hoa", "individual", "business"],
invoice_status: ["draft", "sent", "paid", "overdue", "void"],
},
},
} as const
+88
View File
@@ -0,0 +1,88 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import type { Session, User } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
export type AppRole = "admin" | "attorney" | "staff";
interface AuthState {
session: Session | null;
user: User | null;
roles: AppRole[];
loading: boolean;
isAdmin: boolean;
signIn: (email: string, password: string) => Promise<{ error: string | null }>;
signOut: () => Promise<void>;
refreshRoles: () => Promise<void>;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [roles, setRoles] = useState<AppRole[]>([]);
const [loading, setLoading] = useState(true);
const loadRoles = async (uid: string | undefined) => {
if (!uid) {
setRoles([]);
return;
}
const { data } = await supabase.from("user_roles").select("role").eq("user_id", uid);
setRoles((data ?? []).map((r) => r.role as AppRole));
};
useEffect(() => {
const { data: sub } = supabase.auth.onAuthStateChange((_event, sess) => {
setSession(sess);
setUser(sess?.user ?? null);
// Defer DB call to avoid deadlock
setTimeout(() => {
loadRoles(sess?.user?.id);
}, 0);
});
supabase.auth.getSession().then(({ data: { session: sess } }) => {
setSession(sess);
setUser(sess?.user ?? null);
loadRoles(sess?.user?.id).finally(() => setLoading(false));
});
return () => sub.subscription.unsubscribe();
}, []);
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
return { error: error?.message ?? null };
};
const signOut = async () => {
await supabase.auth.signOut();
setRoles([]);
};
const refreshRoles = async () => loadRoles(user?.id);
return (
<AuthContext.Provider
value={{
session,
user,
roles,
loading,
isAdmin: roles.includes("admin"),
signIn,
signOut,
refreshRoles,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+46
View File
@@ -0,0 +1,46 @@
export function formatCurrency(amount: number | string | null | undefined) {
const n = typeof amount === "string" ? parseFloat(amount) : (amount ?? 0);
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0);
}
export function formatDate(value: string | Date | null | undefined) {
if (!value) return "—";
const d = typeof value === "string" ? new Date(value) : value;
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
export function formatDateTime(value: string | Date | null | undefined) {
if (!value) return "—";
const d = typeof value === "string" ? new Date(value) : value;
return d.toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function statusBadgeClass(status: string) {
switch (status) {
case "active":
case "sent":
return "bg-primary/10 text-primary border-primary/20";
case "intake":
case "draft":
return "bg-muted text-muted-foreground border-border";
case "on_hold":
case "overdue":
return "bg-warning/15 text-warning-foreground border-warning/40";
case "closed_won":
case "paid":
return "bg-success/15 text-success border-success/30";
case "closed_lost":
case "void":
return "bg-destructive/10 text-destructive border-destructive/30";
case "closed":
return "bg-secondary text-secondary-foreground border-border";
default:
return "bg-muted text-muted-foreground border-border";
}
}
+154 -3
View File
@@ -9,38 +9,147 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SetupRouteImport } from './routes/setup'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ClientsIndexRouteImport } from './routes/clients.index'
import { Route as CasesIndexRouteImport } from './routes/cases.index'
import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId'
import { Route as CasesNewRouteImport } from './routes/cases.new'
import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId'
const SetupRoute = SetupRouteImport.update({
id: '/setup',
path: '/setup',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ClientsIndexRoute = ClientsIndexRouteImport.update({
id: '/clients/',
path: '/clients/',
getParentRoute: () => rootRouteImport,
} as any)
const CasesIndexRoute = CasesIndexRouteImport.update({
id: '/cases/',
path: '/cases/',
getParentRoute: () => rootRouteImport,
} as any)
const ClientsClientIdRoute = ClientsClientIdRouteImport.update({
id: '/clients/$clientId',
path: '/clients/$clientId',
getParentRoute: () => rootRouteImport,
} as any)
const CasesNewRoute = CasesNewRouteImport.update({
id: '/cases/new',
path: '/cases/new',
getParentRoute: () => rootRouteImport,
} as any)
const CasesCaseIdRoute = CasesCaseIdRouteImport.update({
id: '/cases/$caseId',
path: '/cases/$caseId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/cases': typeof CasesIndexRoute
'/clients': typeof ClientsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths:
| '/'
| '/login'
| '/setup'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/cases/'
| '/clients/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to:
| '/'
| '/login'
| '/setup'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/cases'
| '/clients'
id:
| '__root__'
| '/'
| '/login'
| '/setup'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/cases/'
| '/clients/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
SetupRoute: typeof SetupRoute
CasesCaseIdRoute: typeof CasesCaseIdRoute
CasesNewRoute: typeof CasesNewRoute
ClientsClientIdRoute: typeof ClientsClientIdRoute
CasesIndexRoute: typeof CasesIndexRoute
ClientsIndexRoute: typeof ClientsIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/setup': {
id: '/setup'
path: '/setup'
fullPath: '/setup'
preLoaderRoute: typeof SetupRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -48,11 +157,53 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/clients/': {
id: '/clients/'
path: '/clients'
fullPath: '/clients/'
preLoaderRoute: typeof ClientsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/cases/': {
id: '/cases/'
path: '/cases'
fullPath: '/cases/'
preLoaderRoute: typeof CasesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/clients/$clientId': {
id: '/clients/$clientId'
path: '/clients/$clientId'
fullPath: '/clients/$clientId'
preLoaderRoute: typeof ClientsClientIdRouteImport
parentRoute: typeof rootRouteImport
}
'/cases/new': {
id: '/cases/new'
path: '/cases/new'
fullPath: '/cases/new'
preLoaderRoute: typeof CasesNewRouteImport
parentRoute: typeof rootRouteImport
}
'/cases/$caseId': {
id: '/cases/$caseId'
path: '/cases/$caseId'
fullPath: '/cases/$caseId'
preLoaderRoute: typeof CasesCaseIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
SetupRoute: SetupRoute,
CasesCaseIdRoute: CasesCaseIdRoute,
CasesNewRoute: CasesNewRoute,
ClientsClientIdRoute: ClientsClientIdRoute,
CasesIndexRoute: CasesIndexRoute,
ClientsIndexRoute: ClientsIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+15 -19
View File
@@ -1,15 +1,16 @@
import { Outlet, Link, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { Outlet, createRootRoute, HeadContent, Scripts, Link } from "@tanstack/react-router";
import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/lib/auth";
import appCss from "../styles.css?url";
function NotFoundComponent() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-7xl font-bold text-foreground">404</h1>
<h1 className="text-7xl font-serif text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Page not found</h2>
<p className="mt-2 text-sm text-muted-foreground">
The page you're looking for doesn't exist or has been moved.
The page you're looking for doesn't exist.
</p>
<div className="mt-6">
<Link
@@ -29,21 +30,11 @@ export const Route = createRootRoute({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "Lovable App" },
{ name: "description", content: "Lovable Generated Project" },
{ name: "author", content: "Lovable" },
{ property: "og:title", content: "Lovable App" },
{ property: "og:description", content: "Lovable Generated Project" },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:site", content: "@Lovable" },
],
links: [
{
rel: "stylesheet",
href: appCss,
},
{ title: "Counsel — Law Firm Management" },
{ name: "description", content: "Secure case, client, document, time and billing management for law firms." },
{ name: "robots", content: "noindex, nofollow" },
],
links: [{ rel: "stylesheet", href: appCss }],
}),
shellComponent: RootShell,
component: RootComponent,
@@ -65,5 +56,10 @@ function RootShell({ children }: { children: React.ReactNode }) {
}
function RootComponent() {
return <Outlet />;
return (
<AuthProvider>
<Outlet />
<Toaster richColors position="top-right" />
</AuthProvider>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState, useCallback } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt } from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { CaseDocumentsTab } from "@/components/cases/documents-tab";
import { CaseTimeTab } from "@/components/cases/time-tab";
import { CaseExpensesTab } from "@/components/cases/expenses-tab";
import { CaseStatusTab } from "@/components/cases/status-tab";
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
export const Route = createFileRoute("/cases/$caseId")({
component: () => (
<ProtectedLayout>
<CaseDetail />
</ProtectedLayout>
),
});
function CaseDetail() {
const { caseId } = Route.useParams();
const { user, isAdmin } = useAuth();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<any[]>([]);
const load = useCallback(async () => {
const [{ data: c, error }, { data: us }] = await Promise.all([
supabase
.from("cases")
.select("*, client:clients(id, name, client_type), assignee:profiles!cases_assigned_attorney_id_fkey(id, full_name, email)")
.eq("id", caseId)
.maybeSingle(),
supabase.from("profiles").select("id, full_name, email"),
]);
if (error) toast.error("Failed to load case", { description: error.message });
setData(c);
setUsers(us ?? []);
setLoading(false);
}, [caseId]);
useEffect(() => {
load();
}, [load]);
const updateStatus = async (status: string) => {
const { error } = await supabase
.from("cases")
.update({ status, closed_at: status.startsWith("closed") ? new Date().toISOString().slice(0, 10) : null })
.eq("id", caseId);
if (error) toast.error("Could not update", { description: error.message });
else {
toast.success("Status updated");
load();
}
};
const updateAssignee = async (assigned_attorney_id: string) => {
const { error } = await supabase.from("cases").update({ assigned_attorney_id }).eq("id", caseId);
if (error) toast.error(error.message);
else { toast.success("Assignee updated"); load(); }
};
if (loading) return <PageContainer><p className="text-muted-foreground">Loading…</p></PageContainer>;
if (!data) {
return (
<PageContainer>
<p className="text-muted-foreground">Case not found or you don't have access.</p>
<Button variant="outline" className="mt-3" asChild>
<Link to="/cases"><ArrowLeft className="h-4 w-4 mr-2" /> All cases</Link>
</Button>
</PageContainer>
);
}
const canManage = isAdmin || data.assigned_attorney_id === user?.id || data.created_by === user?.id;
return (
<PageContainer>
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
<Link to="/cases"><ArrowLeft className="h-4 w-4 mr-1" /> All cases</Link>
</Button>
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-xs uppercase tracking-widest text-muted-foreground">{data.case_number}</span>
<Badge variant="outline" className={statusBadgeClass(data.status)}>{data.status.replace("_", " ")}</Badge>
</div>
<h1 className="font-serif text-3xl">{data.title}</h1>
<p className="text-sm text-muted-foreground mt-1">
<Link to="/clients/$clientId" params={{ clientId: data.client.id }} className="hover:text-primary underline-offset-4 hover:underline">
{data.client.name}
</Link>
{" · "}{data.practice_area || "—"}{" · "}opened {formatDate(data.opened_at)}
</p>
</div>
{canManage && (
<div className="flex flex-wrap gap-2">
<Select value={data.assigned_attorney_id ?? ""} onValueChange={updateAssignee}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Assign attorney" /></SelectTrigger>
<SelectContent>
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
</SelectContent>
</Select>
<Select value={data.status} onValueChange={updateStatus}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="intake">Intake</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="on_hold">On hold</SelectItem>
<SelectItem value="closed_won">Closed — won</SelectItem>
<SelectItem value="closed_lost">Closed — lost</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{data.description && (
<Card className="mb-6 border-border/60">
<CardContent className="p-4">
<p className="text-sm whitespace-pre-wrap">{data.description}</p>
</CardContent>
</Card>
)}
<Tabs defaultValue="overview">
<TabsList className="mb-4 flex-wrap h-auto">
<TabsTrigger value="overview"><Activity className="h-3.5 w-3.5 mr-1.5" />Status log</TabsTrigger>
<TabsTrigger value="documents"><FileText className="h-3.5 w-3.5 mr-1.5" />Documents</TabsTrigger>
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
</TabsList>
<TabsContent value="overview"><CaseStatusTab caseId={caseId} /></TabsContent>
<TabsContent value="documents"><CaseDocumentsTab caseId={caseId} /></TabsContent>
<TabsContent value="time"><CaseTimeTab caseRecord={data} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
</Tabs>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-4 gap-3 text-center text-xs">
<SmallStat label="Default rate" value={data.default_hourly_rate ? formatCurrency(data.default_hourly_rate) + "/hr" : "—"} />
<SmallStat label="Attorney" value={data.assignee?.full_name || data.assignee?.email || "—"} />
<SmallStat label="Closed" value={data.closed_at ? formatDate(data.closed_at) : "—"} />
<SmallStat label="Updated" value={formatDate(data.updated_at)} />
</div>
</PageContainer>
);
}
function SmallStat({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-md py-2 px-3">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="text-sm font-medium mt-0.5 truncate">{value}</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { supabase } from "@/integrations/supabase/client";
import { Plus, Search } from "lucide-react";
import { formatDate, statusBadgeClass } from "@/lib/format";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export const Route = createFileRoute("/cases/")({
component: () => (
<ProtectedLayout>
<CasesList />
</ProtectedLayout>
),
});
function CasesList() {
const [cases, setCases] = useState<any[]>([]);
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
useEffect(() => {
(async () => {
const { data } = await supabase
.from("cases")
.select("*, client:clients(name), assignee:profiles!cases_assigned_attorney_id_fkey(full_name, email)")
.order("updated_at", { ascending: false });
setCases(data ?? []);
})();
}, []);
const filtered = cases.filter((c) => {
const okStatus = statusFilter === "all" || c.status === statusFilter;
const okQ = [c.title, c.case_number, c.client?.name].filter(Boolean).join(" ").toLowerCase().includes(q.toLowerCase());
return okStatus && okQ;
});
return (
<PageContainer>
<PageHeader
title="Cases"
description="Matters assigned to you and ones you've created."
actions={
<Button asChild>
<Link to="/cases/new"><Plus className="h-4 w-4 mr-2" /> New case</Link>
</Button>
}
/>
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search cases…" className="pl-9" />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="intake">Intake</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="on_hold">On hold</SelectItem>
<SelectItem value="closed_won">Closed — won</SelectItem>
<SelectItem value="closed_lost">Closed — lost</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Case</th>
<th className="text-left px-4 py-3 font-medium">Client</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-left px-4 py-3 font-medium">Attorney</th>
<th className="text-left px-4 py-3 font-medium">Opened</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={5} className="text-center py-12 text-muted-foreground">No cases.</td></tr>
)}
{filtered.map((c) => (
<tr key={c.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<Link to="/cases/$caseId" params={{ caseId: c.id }} className="font-medium hover:text-primary">
{c.title}
</Link>
<div className="text-xs text-muted-foreground">{c.case_number}</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{c.client?.name ?? "—"}</td>
<td className="px-4 py-3">
<Badge variant="outline" className={statusBadgeClass(c.status)}>{c.status.replace("_", " ")}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{c.assignee?.full_name || c.assignee?.email || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.opened_at)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</PageContainer>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { z } from "zod";
import { useEffect, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { ArrowLeft, Loader2 } from "lucide-react";
import { toast } from "sonner";
const search = z.object({ clientId: z.string().optional() });
export const Route = createFileRoute("/cases/new")({
validateSearch: search,
component: () => (
<ProtectedLayout>
<NewCase />
</ProtectedLayout>
),
});
function NewCase() {
const { clientId } = Route.useSearch();
const navigate = useNavigate();
const { user } = useAuth();
const [clients, setClients] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
client_id: clientId ?? "",
case_number: "",
title: "",
practice_area: "",
description: "",
status: "intake" as const,
assigned_attorney_id: user?.id ?? "",
default_hourly_rate: "",
});
useEffect(() => {
(async () => {
const [{ data: cs }, { data: us }] = await Promise.all([
supabase.from("clients").select("id, name").order("name"),
supabase.from("profiles").select("id, full_name, email").order("full_name"),
]);
setClients(cs ?? []);
setUsers(us ?? []);
})();
}, []);
useEffect(() => {
if (user?.id && !form.assigned_attorney_id) {
setForm((f) => ({ ...f, assigned_attorney_id: user.id }));
}
}, [user?.id]);
// Auto-generate case number suggestion
useEffect(() => {
if (!form.case_number) {
const yr = new Date().getFullYear();
const rand = Math.floor(1000 + Math.random() * 9000);
setForm((f) => ({ ...f, case_number: `${yr}-${rand}` }));
}
}, []);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.client_id || !form.title.trim() || !form.case_number.trim()) {
toast.error("Please fill in client, title, and case number.");
return;
}
setSubmitting(true);
const payload = {
client_id: form.client_id,
case_number: form.case_number.trim(),
title: form.title.trim(),
practice_area: form.practice_area.trim() || null,
description: form.description.trim() || null,
status: form.status,
assigned_attorney_id: form.assigned_attorney_id || null,
default_hourly_rate: form.default_hourly_rate ? parseFloat(form.default_hourly_rate) : null,
created_by: user?.id,
};
const { data, error } = await supabase.from("cases").insert(payload).select("id").single();
setSubmitting(false);
if (error) {
toast.error("Could not create case", { description: error.message });
return;
}
toast.success("Case created");
navigate({ to: "/cases/$caseId", params: { caseId: data.id } });
};
return (
<PageContainer>
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
<Link to="/cases"><ArrowLeft className="h-4 w-4 mr-1" /> All cases</Link>
</Button>
<PageHeader title="New case" description="Open a new matter." />
<Card className="max-w-2xl border-border/60">
<CardContent className="p-6">
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Client</Label>
<Select value={form.client_id} onValueChange={(v) => setForm({ ...form, client_id: v })}>
<SelectTrigger><SelectValue placeholder="Select client" /></SelectTrigger>
<SelectContent>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Case number</Label>
<Input value={form.case_number} onChange={(e) => setForm({ ...form, case_number: e.target.value })} required maxLength={50} />
</div>
</div>
<div className="space-y-2">
<Label>Title</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required maxLength={200} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Practice area</Label>
<Input
value={form.practice_area}
onChange={(e) => setForm({ ...form, practice_area: e.target.value })}
placeholder="HOA collections, Litigation, Transactional…"
maxLength={120}
/>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={form.status} onValueChange={(v: any) => setForm({ ...form, status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="intake">Intake</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="on_hold">On hold</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Assigned attorney</Label>
<Select value={form.assigned_attorney_id} onValueChange={(v) => setForm({ ...form, assigned_attorney_id: v })}>
<SelectTrigger><SelectValue placeholder="Select user" /></SelectTrigger>
<SelectContent>
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Default hourly rate ($)</Label>
<Input
type="number"
step="0.01"
min="0"
value={form.default_hourly_rate}
onChange={(e) => setForm({ ...form, default_hourly_rate: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea rows={4} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} maxLength={5000} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" asChild>
<Link to="/cases">Cancel</Link>
</Button>
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create case
</Button>
</div>
</form>
</CardContent>
</Card>
</PageContainer>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { supabase } from "@/integrations/supabase/client";
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
import { useAuth } from "@/lib/auth";
import { ArrowLeft, Edit, Plus, Building2, User, Briefcase as Building, MapPin, Mail, Phone, Users } from "lucide-react";
import { formatDate, statusBadgeClass } from "@/lib/format";
import { toast } from "sonner";
export const Route = createFileRoute("/clients/$clientId")({
component: () => (
<ProtectedLayout>
<ClientDetail />
</ProtectedLayout>
),
});
function ClientDetail() {
const { clientId } = Route.useParams();
const navigate = useNavigate();
const { user, isAdmin } = useAuth();
const [client, setClient] = useState<any>(null);
const [cases, setCases] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editOpen, setEditOpen] = useState(false);
const load = async () => {
setLoading(true);
const [{ data: c, error: ce }, { data: cs }] = await Promise.all([
supabase.from("clients").select("*").eq("id", clientId).maybeSingle(),
supabase.from("cases").select("id, case_number, title, status, opened_at").eq("client_id", clientId).order("opened_at", { ascending: false }),
]);
if (ce) toast.error("Failed to load client", { description: ce.message });
setClient(c);
setCases(cs ?? []);
setLoading(false);
};
useEffect(() => {
load();
}, [clientId]);
if (loading) {
return (
<PageContainer>
<p className="text-muted-foreground">Loading…</p>
</PageContainer>
);
}
if (!client) {
return (
<PageContainer>
<p className="text-muted-foreground">Client not found.</p>
<Button variant="outline" className="mt-3" asChild>
<Link to="/clients"><ArrowLeft className="h-4 w-4 mr-2" /> Back to clients</Link>
</Button>
</PageContainer>
);
}
const Icon =
client.client_type === "hoa" ? Building2 : client.client_type === "business" ? Building : User;
const canEdit = isAdmin || client.created_by === user?.id;
return (
<PageContainer>
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
<Link to="/clients"><ArrowLeft className="h-4 w-4 mr-1" /> All clients</Link>
</Button>
<PageHeader
title={client.name}
description={client.management_company || client.client_type.toUpperCase()}
actions={
<>
{canEdit && (
<Button variant="outline" onClick={() => setEditOpen(true)}>
<Edit className="h-4 w-4 mr-2" /> Edit
</Button>
)}
<Button onClick={() => navigate({ to: "/cases/new", search: { clientId: client.id } })}>
<Plus className="h-4 w-4 mr-2" /> New case
</Button>
</>
}
/>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card className="border-border/60">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="font-serif text-lg">Details</h3>
</div>
<dl className="grid grid-cols-2 gap-y-3 gap-x-6 text-sm">
<DetailRow label="Type" value={client.client_type} />
{client.client_type === "hoa" && (
<>
<DetailRow label="Units" value={client.num_units} />
<DetailRow label="Management Co." value={client.management_company} />
</>
)}
<DetailRow label="Primary contact" value={client.primary_contact_name} />
<DetailRow label="Email" value={client.primary_contact_email} icon={Mail} />
<DetailRow label="Phone" value={client.primary_contact_phone} icon={Phone} />
<DetailRow
label="Address"
value={
[client.address_line1, client.city, client.state, client.postal_code]
.filter(Boolean)
.join(", ") || null
}
icon={MapPin}
/>
</dl>
{client.notes && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1">Notes</div>
<p className="text-sm whitespace-pre-wrap">{client.notes}</p>
</div>
)}
</CardContent>
</Card>
{client.client_type === "hoa" && client.board_members?.length > 0 && (
<Card className="border-border/60">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<Users className="h-4 w-4 text-muted-foreground" />
<h3 className="font-serif text-lg">Board members</h3>
</div>
<div className="divide-y">
{client.board_members.map((m: any, i: number) => (
<div key={i} className="py-2.5 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm">
<span className="font-medium">{m.name}</span>
{m.role && <span className="text-xs uppercase tracking-wider text-muted-foreground">{m.role}</span>}
{m.email && <span className="text-muted-foreground">{m.email}</span>}
{m.phone && <span className="text-muted-foreground">{m.phone}</span>}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
<Card className="border-border/60 h-fit">
<CardContent className="p-5">
<h3 className="font-serif text-lg mb-3">Cases</h3>
{cases.length === 0 && <p className="text-sm text-muted-foreground">No cases yet.</p>}
<div className="space-y-1">
{cases.map((c) => (
<Link
key={c.id}
to="/cases/$caseId"
params={{ caseId: c.id }}
className="block p-2.5 rounded-md hover:bg-muted/60 -mx-2"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{c.title}</span>
<Badge variant="outline" className={`text-[10px] ${statusBadgeClass(c.status)}`}>
{c.status.replace("_", " ")}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{c.case_number} · opened {formatDate(c.opened_at)}
</div>
</Link>
))}
</div>
</CardContent>
</Card>
</div>
<ClientFormDialog open={editOpen} onOpenChange={setEditOpen} client={client} onSaved={load} />
</PageContainer>
);
}
function DetailRow({ label, value, icon: Icon }: { label: string; value: any; icon?: any }) {
return (
<>
<dt className="text-xs uppercase tracking-wider text-muted-foreground self-center">{label}</dt>
<dd className="text-sm flex items-center gap-1.5">
{Icon && value && <Icon className="h-3 w-3 text-muted-foreground" />}
{value || <span className="text-muted-foreground italic">—</span>}
</dd>
</>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { supabase } from "@/integrations/supabase/client";
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
import { Plus, Search, Building2, User, Briefcase as Building } from "lucide-react";
import { formatDate } from "@/lib/format";
export const Route = createFileRoute("/clients/")({
component: () => (
<ProtectedLayout>
<ClientsList />
</ProtectedLayout>
),
});
function ClientsList() {
const [clients, setClients] = useState<any[]>([]);
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const load = async () => {
const { data } = await supabase
.from("clients")
.select("*")
.order("name", { ascending: true });
setClients(data ?? []);
};
useEffect(() => {
load();
}, []);
const filtered = clients.filter((c) =>
[c.name, c.management_company, c.primary_contact_name, c.primary_contact_email]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(q.toLowerCase()),
);
const typeIcon = (t: string) =>
t === "hoa" ? Building2 : t === "business" ? Building : User;
return (
<PageContainer>
<PageHeader
title="Clients"
description="HOA associations, businesses, and individual clients."
actions={
<Button onClick={() => setOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New client
</Button>
}
/>
<div className="relative mb-4 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search by name, contact, manager…"
className="pl-9"
/>
</div>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Type</th>
<th className="text-left px-4 py-3 font-medium">Contact</th>
<th className="text-left px-4 py-3 font-medium">Units</th>
<th className="text-left px-4 py-3 font-medium">Created</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-12 text-muted-foreground">
{clients.length === 0 ? "No clients yet. Add your first one." : "No matches."}
</td>
</tr>
)}
{filtered.map((c) => {
const Icon = typeIcon(c.client_type);
return (
<tr key={c.id} className="border-t hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<Link
to="/clients/$clientId"
params={{ clientId: c.id }}
className="flex items-center gap-2 font-medium text-foreground hover:text-primary"
>
<Icon className="h-4 w-4 text-muted-foreground" />
{c.name}
</Link>
{c.management_company && (
<div className="text-xs text-muted-foreground ml-6">{c.management_company}</div>
)}
</td>
<td className="px-4 py-3">
<Badge variant="outline" className="capitalize">{c.client_type}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{c.primary_contact_name || "—"}
{c.primary_contact_email && (
<div className="text-xs">{c.primary_contact_email}</div>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{c.num_units ?? "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.created_at)}</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
<ClientFormDialog open={open} onOpenChange={setOpen} onSaved={load} />
</PageContainer>
);
}
+144 -19
View File
@@ -1,26 +1,151 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Briefcase, Users, Receipt, Clock, ArrowRight } from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { Badge } from "@/components/ui/badge";
export const Route = createFileRoute("/")({
component: Index,
component: () => (
<ProtectedLayout>
<Dashboard />
</ProtectedLayout>
),
});
// IMPORTANT: Replace this placeholder. For sites with multiple pages (About, Services, Contact, etc.),
// create separate route files (about.tsx, services.tsx, contact.tsx) — don't put all pages in this file.
function PlaceholderIndex() {
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ backgroundColor: "#fcfbf8" }}
>
<img
data-lovable-blank-page-placeholder="REMOVE_THIS"
src="https://cdn.gpteng.co/blank-app-v1.svg"
alt="Your app will live here!"
/>
</div>
);
interface Stats {
activeCases: number;
clients: number;
unpaidInvoices: number;
unbilledHours: number;
}
function Index() {
return <PlaceholderIndex />;
function Dashboard() {
const { user } = useAuth();
const [stats, setStats] = useState<Stats>({ activeCases: 0, clients: 0, unpaidInvoices: 0, unbilledHours: 0 });
const [recentCases, setRecentCases] = useState<any[]>([]);
const [recentInvoices, setRecentInvoices] = useState<any[]>([]);
useEffect(() => {
(async () => {
const [casesRes, clientsRes, invoicesRes, timeRes, recentCasesRes, recentInvRes] = await Promise.all([
supabase.from("cases").select("id", { count: "exact", head: true }).in("status", ["intake", "active", "on_hold"]),
supabase.from("clients").select("id", { count: "exact", head: true }),
supabase.from("invoices").select("total, amount_paid").in("status", ["sent", "overdue"]),
supabase.from("time_entries").select("hours").is("invoice_id", null).eq("billable", true),
supabase.from("cases").select("id, case_number, title, status, updated_at, client:clients(name)").order("updated_at", { ascending: false }).limit(5),
supabase.from("invoices").select("id, invoice_number, total, status, issue_date, client:clients(name)").order("created_at", { ascending: false }).limit(5),
]);
const unpaid = (invoicesRes.data ?? []).reduce(
(sum, i) => sum + (Number(i.total) - Number(i.amount_paid)),
0,
);
const hours = (timeRes.data ?? []).reduce((s, t) => s + Number(t.hours), 0);
setStats({
activeCases: casesRes.count ?? 0,
clients: clientsRes.count ?? 0,
unpaidInvoices: unpaid,
unbilledHours: hours,
});
setRecentCases(recentCasesRes.data ?? []);
setRecentInvoices(recentInvRes.data ?? []);
})();
}, [user?.id]);
const cards = [
{ label: "Active cases", value: stats.activeCases, icon: Briefcase, to: "/cases" },
{ label: "Clients", value: stats.clients, icon: Users, to: "/clients" },
{ label: "Unbilled hours", value: stats.unbilledHours.toFixed(1), icon: Clock, to: "/cases" },
{ label: "Outstanding A/R", value: formatCurrency(stats.unpaidInvoices), icon: Receipt, to: "/invoices" },
];
return (
<PageContainer>
<PageHeader title="Dashboard" description="Practice snapshot and recent activity." />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{cards.map((c) => (
<Link key={c.label} to={c.to} className="group">
<Card className="border-border/60 hover:border-primary/40 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{c.label}</div>
<c.icon className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
</div>
<div className="font-serif text-3xl text-foreground">{c.value}</div>
</CardContent>
</Card>
</Link>
))}
</div>
<div className="grid lg:grid-cols-2 gap-6">
<Card className="border-border/60">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="font-serif text-lg">Recent cases</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link to="/cases">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
</Button>
</CardHeader>
<CardContent className="space-y-1">
{recentCases.length === 0 && <p className="text-sm text-muted-foreground">No cases yet.</p>}
{recentCases.map((c) => (
<Link
key={c.id}
to="/cases/$caseId"
params={{ caseId: c.id }}
className="flex items-center justify-between py-2.5 px-2 rounded-md hover:bg-muted/60 -mx-2"
>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{c.title}</div>
<div className="text-xs text-muted-foreground">
{c.case_number} · {c.client?.name}
</div>
</div>
<Badge variant="outline" className={statusBadgeClass(c.status)}>{c.status.replace("_", " ")}</Badge>
</Link>
))}
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="font-serif text-lg">Recent invoices</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link to="/invoices">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
</Button>
</CardHeader>
<CardContent className="space-y-1">
{recentInvoices.length === 0 && <p className="text-sm text-muted-foreground">No invoices yet.</p>}
{recentInvoices.map((i) => (
<Link
key={i.id}
to="/invoices/$invoiceId"
params={{ invoiceId: i.id }}
className="flex items-center justify-between py-2.5 px-2 rounded-md hover:bg-muted/60 -mx-2"
>
<div className="min-w-0">
<div className="text-sm font-medium">{i.invoice_number}</div>
<div className="text-xs text-muted-foreground">
{i.client?.name} · {formatDate(i.issue_date)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{formatCurrency(i.total)}</span>
<Badge variant="outline" className={statusBadgeClass(i.status)}>{i.status}</Badge>
</div>
</Link>
))}
</CardContent>
</Card>
</div>
</PageContainer>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Scale, Loader2, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/login")({
component: LoginPage,
});
function LoginPage() {
const { signIn, session, loading } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!loading && session) navigate({ to: "/" });
}, [loading, session, navigate]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
const { error } = await signIn(email.trim(), password);
setSubmitting(false);
if (error) {
toast.error("Sign-in failed", { description: error });
} else {
navigate({ to: "/" });
}
};
return (
<div className="min-h-screen grid lg:grid-cols-2 bg-background">
{/* Brand panel */}
<div className="hidden lg:flex flex-col justify-between p-12 bg-sidebar text-sidebar-foreground">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-md bg-sidebar-primary flex items-center justify-center">
<Scale className="h-5 w-5 text-sidebar-primary-foreground" />
</div>
<div>
<div className="font-serif text-xl">Counsel</div>
<div className="text-xs uppercase tracking-widest text-sidebar-foreground/60">
Law Firm Suite
</div>
</div>
</div>
<div className="max-w-md">
<h2 className="font-serif text-4xl leading-tight mb-4">
Practice management, built for the discipline of law.
</h2>
<p className="text-sidebar-foreground/70 leading-relaxed">
Cases, clients, documents, time, and billing — secured behind authenticated access and
row-level data protection. Designed for HOA-focused firms and beyond.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-sidebar-foreground/60">
<ShieldCheck className="h-4 w-4" />
Encrypted in transit · Role-based access · Audit-ready
</div>
</div>
{/* Form */}
<div className="flex items-center justify-center p-6">
<Card className="w-full max-w-md border-border/60 shadow-sm">
<CardHeader className="space-y-2">
<div className="lg:hidden flex items-center gap-2 mb-2">
<Scale className="h-5 w-5 text-primary" />
<span className="font-serif text-lg">Counsel</span>
</div>
<CardTitle className="font-serif text-2xl">Sign in</CardTitle>
<CardDescription>Restricted access. Contact your administrator for credentials.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="attorney@firm.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
<p className="text-xs text-muted-foreground text-center">
First time setting up the firm?{" "}
<Link to="/setup" className="text-primary underline-offset-4 hover:underline">
Create the founding admin account
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, Scale } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/setup")({
component: SetupPage,
});
function SetupPage() {
const navigate = useNavigate();
const { loading, session } = useAuth();
const [checking, setChecking] = useState(true);
const [allowed, setAllowed] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
// If anyone exists, block access to /setup
(async () => {
const { count } = await supabase
.from("user_roles")
.select("*", { count: "exact", head: true });
if ((count ?? 0) === 0) setAllowed(true);
setChecking(false);
})();
}, []);
useEffect(() => {
if (!loading && session && !allowed) navigate({ to: "/" });
}, [loading, session, allowed, navigate]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
const redirectUrl = `${window.location.origin}/`;
const { error } = await supabase.auth.signUp({
email: email.trim(),
password,
options: {
emailRedirectTo: redirectUrl,
data: { full_name: name.trim() },
},
});
setSubmitting(false);
if (error) {
toast.error("Could not create account", { description: error.message });
return;
}
toast.success("Founding admin account created");
navigate({ to: "/" });
};
if (checking) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!allowed) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-6">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="font-serif">Setup unavailable</CardTitle>
<CardDescription>
Firm is already initialized. Use the standard sign-in page.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login">Go to sign in</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-6">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center gap-2 mb-2">
<Scale className="h-5 w-5 text-primary" />
<span className="font-serif text-lg">Counsel</span>
</div>
<CardTitle className="font-serif text-2xl">Create founding admin</CardTitle>
<CardDescription>
This is a one-time setup. The first account becomes the firm administrator and can invite
attorneys and staff afterward.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full name</Label>
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
minLength={10}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Minimum 10 characters. Avoid commonly leaked passwords.
</p>
</div>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create admin account
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+90 -89
View File
@@ -4,28 +4,11 @@
@custom-variant dark (&:is(.dark *));
/*
* Design system definition.
*
* The @theme inline block maps CSS custom properties to Tailwind utility
* classes (e.g. --color-primary -> bg-primary, text-primary).
*
* The :root and .dark blocks define the actual color values using oklch.
* All colors MUST use oklch format.
*
* To add a new semantic color:
* 1. Add the variable to :root (light value) and .dark (dark value)
* 2. Register it in @theme inline as --color-<name>: var(--<name>)
*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -42,15 +25,13 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-ring-offset-background: var(--background);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@@ -59,86 +40,106 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-serif: "Source Serif 4", "Source Serif Pro", Georgia, serif;
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--radius: 0.5rem;
/* Surfaces — warm off-white paper */
--background: oklch(0.985 0.005 85);
--foreground: oklch(0.18 0.025 250);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--card-foreground: oklch(0.18 0.025 250);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.984 0.003 247.858);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
--popover-foreground: oklch(0.18 0.025 250);
/* Brand: deep navy ink */
--primary: oklch(0.27 0.06 255);
--primary-foreground: oklch(0.98 0.005 85);
--secondary: oklch(0.94 0.012 250);
--secondary-foreground: oklch(0.27 0.06 255);
--muted: oklch(0.95 0.008 250);
--muted-foreground: oklch(0.5 0.025 250);
--accent: oklch(0.92 0.04 80); /* subtle gold/parchment accent */
--accent-foreground: oklch(0.27 0.06 255);
--destructive: oklch(0.55 0.2 27);
--destructive-foreground: oklch(0.98 0.005 85);
--success: oklch(0.55 0.13 155);
--success-foreground: oklch(0.98 0.005 85);
--warning: oklch(0.7 0.14 75);
--warning-foreground: oklch(0.18 0.025 250);
--border: oklch(0.9 0.012 250);
--input: oklch(0.9 0.012 250);
--ring: oklch(0.45 0.08 255);
--sidebar: oklch(0.22 0.04 255);
--sidebar-foreground: oklch(0.92 0.012 250);
--sidebar-primary: oklch(0.62 0.15 70);
--sidebar-primary-foreground: oklch(0.18 0.025 250);
--sidebar-accent: oklch(0.28 0.05 255);
--sidebar-accent-foreground: oklch(0.96 0.008 85);
--sidebar-border: oklch(0.3 0.04 255);
--sidebar-ring: oklch(0.55 0.1 255);
--chart-1: oklch(0.62 0.15 70);
--chart-2: oklch(0.45 0.1 255);
--chart-3: oklch(0.55 0.13 155);
--chart-4: oklch(0.55 0.2 27);
--chart-5: oklch(0.5 0.025 250);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.984 0.003 247.858);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--background: oklch(0.16 0.025 250);
--foreground: oklch(0.96 0.008 85);
--card: oklch(0.22 0.03 255);
--card-foreground: oklch(0.96 0.008 85);
--popover: oklch(0.22 0.03 255);
--popover-foreground: oklch(0.96 0.008 85);
--primary: oklch(0.85 0.04 80);
--primary-foreground: oklch(0.18 0.025 250);
--secondary: oklch(0.28 0.04 255);
--secondary-foreground: oklch(0.96 0.008 85);
--muted: oklch(0.28 0.04 255);
--muted-foreground: oklch(0.72 0.02 250);
--accent: oklch(0.32 0.05 255);
--accent-foreground: oklch(0.96 0.008 85);
--destructive: oklch(0.65 0.18 27);
--destructive-foreground: oklch(0.98 0.005 85);
--success: oklch(0.65 0.13 155);
--success-foreground: oklch(0.18 0.025 250);
--warning: oklch(0.78 0.14 75);
--warning-foreground: oklch(0.18 0.025 250);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.62 0.1 255);
--sidebar: oklch(0.18 0.03 255);
--sidebar-foreground: oklch(0.92 0.012 250);
--sidebar-primary: oklch(0.62 0.15 70);
--sidebar-primary-foreground: oklch(0.18 0.025 250);
--sidebar-accent: oklch(0.24 0.04 255);
--sidebar-accent-foreground: oklch(0.96 0.008 85);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
--sidebar-ring: oklch(0.55 0.1 255);
}
@layer base {
* {
border-color: var(--color-border);
}
* { border-color: var(--color-border); }
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
font-feature-settings: "cv11", "ss01";
}
h1, h2, h3, h4 { font-family: var(--font-serif); letter-spacing: -0.01em; }
}
+1
View File
@@ -0,0 +1 @@
project_id = "yvoaelcultuatsvkhdiu"
@@ -0,0 +1,366 @@
-- =========================================
-- ENUMS
-- =========================================
CREATE TYPE public.app_role AS ENUM ('admin', 'attorney', 'staff');
CREATE TYPE public.client_type AS ENUM ('hoa', 'individual', 'business');
CREATE TYPE public.case_status AS ENUM ('intake', 'active', 'on_hold', 'closed_won', 'closed_lost', 'closed');
CREATE TYPE public.invoice_status AS ENUM ('draft', 'sent', 'paid', 'overdue', 'void');
-- =========================================
-- UPDATED-AT TRIGGER FN
-- =========================================
CREATE OR REPLACE FUNCTION public.tg_set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql SET search_path = public AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;
-- =========================================
-- PROFILES (display info for auth users)
-- =========================================
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
INSERT INTO public.profiles (id, email, full_name)
VALUES (NEW.id, NEW.email, COALESCE(NEW.raw_user_meta_data->>'full_name', ''));
-- First-ever user becomes admin
IF (SELECT count(*) FROM public.user_roles) = 0 THEN
INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'admin');
ELSE
INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'staff');
END IF;
RETURN NEW;
END;
$$;
-- =========================================
-- USER ROLES
-- =========================================
CREATE TABLE public.user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role public.app_role NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, role)
);
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION public.has_role(_user_id UUID, _role public.app_role)
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = _role)
$$;
CREATE OR REPLACE FUNCTION public.is_admin(_user_id UUID)
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = 'admin')
$$;
-- Now create the trigger (after user_roles exists)
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- =========================================
-- CLIENTS
-- =========================================
CREATE TABLE public.clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_type public.client_type NOT NULL DEFAULT 'hoa',
name TEXT NOT NULL,
primary_contact_name TEXT,
primary_contact_email TEXT,
primary_contact_phone TEXT,
address_line1 TEXT,
address_line2 TEXT,
city TEXT,
state TEXT,
postal_code TEXT,
notes TEXT,
-- HOA-specific
management_company TEXT,
num_units INTEGER,
board_members JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.clients ENABLE ROW LEVEL SECURITY;
CREATE TRIGGER clients_updated_at BEFORE UPDATE ON public.clients
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- =========================================
-- CASES
-- =========================================
CREATE TABLE public.cases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES public.clients(id) ON DELETE RESTRICT,
case_number TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT,
practice_area TEXT,
status public.case_status NOT NULL DEFAULT 'intake',
assigned_attorney_id UUID REFERENCES auth.users(id),
opened_at DATE NOT NULL DEFAULT CURRENT_DATE,
closed_at DATE,
default_hourly_rate NUMERIC(10,2),
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.cases ENABLE ROW LEVEL SECURITY;
CREATE TRIGGER cases_updated_at BEFORE UPDATE ON public.cases
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
CREATE INDEX idx_cases_client ON public.cases(client_id);
CREATE INDEX idx_cases_attorney ON public.cases(assigned_attorney_id);
-- Helper: can current user access this case?
CREATE OR REPLACE FUNCTION public.can_access_case(_case_id UUID, _user_id UUID)
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT
public.has_role(_user_id, 'admin')
OR EXISTS (
SELECT 1 FROM public.cases c
WHERE c.id = _case_id
AND (c.assigned_attorney_id = _user_id OR c.created_by = _user_id)
)
$$;
-- =========================================
-- DOCUMENTS
-- =========================================
CREATE TABLE public.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
name TEXT NOT NULL,
storage_path TEXT NOT NULL,
mime_type TEXT,
size_bytes BIGINT,
description TEXT,
uploaded_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
CREATE INDEX idx_documents_case ON public.documents(case_id);
-- =========================================
-- STATUS UPDATES (case log entries)
-- =========================================
CREATE TABLE public.status_updates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT NOT NULL,
report_storage_path TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.status_updates ENABLE ROW LEVEL SECURITY;
CREATE INDEX idx_status_updates_case ON public.status_updates(case_id);
-- =========================================
-- TIME ENTRIES
-- =========================================
CREATE TABLE public.time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id),
work_date DATE NOT NULL DEFAULT CURRENT_DATE,
hours NUMERIC(6,2) NOT NULL CHECK (hours > 0),
hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0,
description TEXT NOT NULL,
billable BOOLEAN NOT NULL DEFAULT true,
invoice_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.time_entries ENABLE ROW LEVEL SECURITY;
CREATE TRIGGER time_entries_updated_at BEFORE UPDATE ON public.time_entries
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
CREATE INDEX idx_time_case ON public.time_entries(case_id);
CREATE INDEX idx_time_user ON public.time_entries(user_id);
CREATE INDEX idx_time_invoice ON public.time_entries(invoice_id);
-- =========================================
-- EXPENSES
-- =========================================
CREATE TABLE public.expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id),
expense_date DATE NOT NULL DEFAULT CURRENT_DATE,
description TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL CHECK (amount >= 0),
billable BOOLEAN NOT NULL DEFAULT true,
receipt_storage_path TEXT,
invoice_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.expenses ENABLE ROW LEVEL SECURITY;
CREATE TRIGGER expenses_updated_at BEFORE UPDATE ON public.expenses
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
CREATE INDEX idx_expenses_case ON public.expenses(case_id);
CREATE INDEX idx_expenses_invoice ON public.expenses(invoice_id);
-- =========================================
-- INVOICES
-- =========================================
CREATE TABLE public.invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_number TEXT NOT NULL UNIQUE,
client_id UUID NOT NULL REFERENCES public.clients(id) ON DELETE RESTRICT,
case_id UUID REFERENCES public.cases(id) ON DELETE SET NULL,
status public.invoice_status NOT NULL DEFAULT 'draft',
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
due_date DATE,
subtotal NUMERIC(12,2) NOT NULL DEFAULT 0,
tax NUMERIC(12,2) NOT NULL DEFAULT 0,
total NUMERIC(12,2) NOT NULL DEFAULT 0,
amount_paid NUMERIC(12,2) NOT NULL DEFAULT 0,
notes TEXT,
paid_at TIMESTAMPTZ,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY;
CREATE TRIGGER invoices_updated_at BEFORE UPDATE ON public.invoices
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
CREATE INDEX idx_invoices_client ON public.invoices(client_id);
CREATE INDEX idx_invoices_case ON public.invoices(case_id);
ALTER TABLE public.time_entries
ADD CONSTRAINT time_entries_invoice_fk FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE SET NULL;
ALTER TABLE public.expenses
ADD CONSTRAINT expenses_invoice_fk FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE SET NULL;
-- =========================================
-- RLS POLICIES
-- =========================================
-- profiles: any authenticated user can read names; users can update their own
CREATE POLICY "profiles_select_auth" ON public.profiles FOR SELECT TO authenticated USING (true);
CREATE POLICY "profiles_update_own" ON public.profiles FOR UPDATE TO authenticated USING (auth.uid() = id);
-- user_roles: users can see their own roles; admins see all; only admins manage
CREATE POLICY "user_roles_select_self" ON public.user_roles FOR SELECT TO authenticated
USING (user_id = auth.uid() OR public.is_admin(auth.uid()));
CREATE POLICY "user_roles_admin_insert" ON public.user_roles FOR INSERT TO authenticated
WITH CHECK (public.is_admin(auth.uid()));
CREATE POLICY "user_roles_admin_update" ON public.user_roles FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()));
CREATE POLICY "user_roles_admin_delete" ON public.user_roles FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()));
-- clients: any authenticated firm user can view clients (firm-wide).
CREATE POLICY "clients_select_auth" ON public.clients FOR SELECT TO authenticated USING (true);
CREATE POLICY "clients_insert_auth" ON public.clients FOR INSERT TO authenticated
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "clients_update_auth" ON public.clients FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
CREATE POLICY "clients_delete_admin" ON public.clients FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()));
-- cases: only admin or assigned attorney/creator can see
CREATE POLICY "cases_select_assigned" ON public.cases FOR SELECT TO authenticated
USING (public.is_admin(auth.uid()) OR assigned_attorney_id = auth.uid() OR created_by = auth.uid());
CREATE POLICY "cases_insert_auth" ON public.cases FOR INSERT TO authenticated
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "cases_update_assigned" ON public.cases FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()) OR assigned_attorney_id = auth.uid() OR created_by = auth.uid());
CREATE POLICY "cases_delete_admin" ON public.cases FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()));
-- documents
CREATE POLICY "documents_select_case" ON public.documents FOR SELECT TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY "documents_insert_case" ON public.documents FOR INSERT TO authenticated
WITH CHECK (public.can_access_case(case_id, auth.uid()));
CREATE POLICY "documents_delete_case" ON public.documents FOR DELETE TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
-- status updates
CREATE POLICY "status_select_case" ON public.status_updates FOR SELECT TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY "status_insert_case" ON public.status_updates FOR INSERT TO authenticated
WITH CHECK (public.can_access_case(case_id, auth.uid()));
CREATE POLICY "status_update_owner" ON public.status_updates FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
CREATE POLICY "status_delete_owner" ON public.status_updates FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
-- time entries
CREATE POLICY "time_select_case" ON public.time_entries FOR SELECT TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY "time_insert_case" ON public.time_entries FOR INSERT TO authenticated
WITH CHECK (public.can_access_case(case_id, auth.uid()) AND user_id = auth.uid());
CREATE POLICY "time_update_owner" ON public.time_entries FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()) OR user_id = auth.uid());
CREATE POLICY "time_delete_owner" ON public.time_entries FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()) OR user_id = auth.uid());
-- expenses
CREATE POLICY "exp_select_case" ON public.expenses FOR SELECT TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY "exp_insert_case" ON public.expenses FOR INSERT TO authenticated
WITH CHECK (public.can_access_case(case_id, auth.uid()) AND user_id = auth.uid());
CREATE POLICY "exp_update_owner" ON public.expenses FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()) OR user_id = auth.uid());
CREATE POLICY "exp_delete_owner" ON public.expenses FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()) OR user_id = auth.uid());
-- invoices
CREATE POLICY "inv_select" ON public.invoices FOR SELECT TO authenticated
USING (
public.is_admin(auth.uid())
OR created_by = auth.uid()
OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))
);
CREATE POLICY "inv_insert" ON public.invoices FOR INSERT TO authenticated
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "inv_update" ON public.invoices FOR UPDATE TO authenticated
USING (
public.is_admin(auth.uid())
OR created_by = auth.uid()
OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))
);
CREATE POLICY "inv_delete_admin" ON public.invoices FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()));
-- =========================================
-- STORAGE BUCKETS
-- =========================================
INSERT INTO storage.buckets (id, name, public) VALUES ('case-documents', 'case-documents', false)
ON CONFLICT (id) DO NOTHING;
INSERT INTO storage.buckets (id, name, public) VALUES ('case-receipts', 'case-receipts', false)
ON CONFLICT (id) DO NOTHING;
INSERT INTO storage.buckets (id, name, public) VALUES ('case-reports', 'case-reports', false)
ON CONFLICT (id) DO NOTHING;
-- Storage policies: file path must start with case-id/ that the user can access
CREATE POLICY "case_docs_select" ON storage.objects FOR SELECT TO authenticated
USING (
bucket_id IN ('case-documents','case-receipts','case-reports')
AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid())
);
CREATE POLICY "case_docs_insert" ON storage.objects FOR INSERT TO authenticated
WITH CHECK (
bucket_id IN ('case-documents','case-receipts','case-reports')
AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid())
);
CREATE POLICY "case_docs_delete" ON storage.objects FOR DELETE TO authenticated
USING (
bucket_id IN ('case-documents','case-receipts','case-reports')
AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid())
);