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:
@@ -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"
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
Reference in New Issue
Block a user