Added case wizard & team access
X-Lovable-Edit-ID: edt-0c17103f-199f-474b-bbd9-a67b1a9d62e4 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Check, Loader2, UserPlus, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
|
||||
const STEPS: { n: Step; label: string }[] = [
|
||||
{ n: 1, label: "Clients & Contacts" },
|
||||
{ n: 2, label: "Case Details" },
|
||||
{ n: 3, label: "Billing" },
|
||||
{ n: 4, label: "Staff" },
|
||||
];
|
||||
|
||||
const BILLING_METHODS = [
|
||||
{ value: "hourly", label: "Hourly" },
|
||||
{ value: "contingency", label: "Contingency" },
|
||||
{ value: "flat_fee", label: "Flat Fee" },
|
||||
{ value: "mixed", label: "Mix of Flat Fee and Hourly" },
|
||||
{ value: "pro_bono", label: "Pro Bono" },
|
||||
];
|
||||
|
||||
const CLIENT_TYPES = [
|
||||
{ value: "hoa", label: "HOA" },
|
||||
{ value: "condo", label: "Condo Association" },
|
||||
{ value: "business", label: "Business" },
|
||||
{ value: "individual", label: "Individual" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
defaultClientId?: string;
|
||||
}
|
||||
|
||||
export function NewCaseDialog({ open, onOpenChange, defaultClientId }: Props) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [clients, setClients] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [practiceAreas, setPracticeAreas] = useState<string[]>([]);
|
||||
|
||||
// Step 1
|
||||
const [clientId, setClientId] = useState<string>(defaultClientId ?? "");
|
||||
const [showNewClient, setShowNewClient] = useState(false);
|
||||
const [newClient, setNewClient] = useState({
|
||||
name: "",
|
||||
client_type: "hoa",
|
||||
primary_contact_email: "",
|
||||
primary_contact_phone: "",
|
||||
});
|
||||
|
||||
// Step 2
|
||||
const [caseName, setCaseName] = useState("");
|
||||
const [caseNumber, setCaseNumber] = useState("");
|
||||
const [practiceArea, setPracticeArea] = useState("");
|
||||
const [newPracticeArea, setNewPracticeArea] = useState("");
|
||||
const [showNewPractice, setShowNewPractice] = useState(false);
|
||||
const [caseStage, setCaseStage] = useState<"intake" | "active" | "on_hold">("intake");
|
||||
const [dateOpened, setDateOpened] = useState<string>(new Date().toISOString().slice(0, 10));
|
||||
const [description, setDescription] = useState("");
|
||||
const [solDate, setSolDate] = useState<string>("");
|
||||
const [conflictCheck, setConflictCheck] = useState(false);
|
||||
const [conflictNotes, setConflictNotes] = useState("");
|
||||
|
||||
// Step 3
|
||||
const [billingContactId, setBillingContactId] = useState<string>("");
|
||||
const [billingMethod, setBillingMethod] = useState<string>("hourly");
|
||||
const [hourlyRate, setHourlyRate] = useState("");
|
||||
const [flatFee, setFlatFee] = useState("");
|
||||
|
||||
// Step 4
|
||||
const [leadAttorneyId, setLeadAttorneyId] = useState<string>("");
|
||||
const [originatingAttorneyId, setOriginatingAttorneyId] = useState<string>("");
|
||||
const [teamUserIds, setTeamUserIds] = useState<Set<string>>(new Set());
|
||||
const [teamRates, setTeamRates] = useState<Record<string, string>>({});
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(1);
|
||||
setClientId(defaultClientId ?? "");
|
||||
setShowNewClient(false);
|
||||
setNewClient({ name: "", client_type: "hoa", primary_contact_email: "", primary_contact_phone: "" });
|
||||
setCaseName("");
|
||||
const yr = new Date().getFullYear();
|
||||
setCaseNumber(`${yr}-${Math.floor(1000 + Math.random() * 9000)}`);
|
||||
setPracticeArea("");
|
||||
setShowNewPractice(false);
|
||||
setNewPracticeArea("");
|
||||
setCaseStage("intake");
|
||||
setDateOpened(new Date().toISOString().slice(0, 10));
|
||||
setDescription("");
|
||||
setSolDate("");
|
||||
setConflictCheck(false);
|
||||
setConflictNotes("");
|
||||
setBillingContactId("");
|
||||
setBillingMethod("hourly");
|
||||
setHourlyRate("");
|
||||
setFlatFee("");
|
||||
setLeadAttorneyId(user?.id ?? "");
|
||||
setOriginatingAttorneyId("");
|
||||
setTeamUserIds(new Set(user?.id ? [user.id] : []));
|
||||
setTeamRates({});
|
||||
}
|
||||
}, [open, defaultClientId, user?.id]);
|
||||
|
||||
// Load clients, users, practice areas
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
(async () => {
|
||||
const [{ data: cs }, { data: us }, { data: pa }] = await Promise.all([
|
||||
supabase.from("clients").select("id, name, client_type").is("archived_at", null).order("name"),
|
||||
supabase.from("profiles").select("id, full_name, email, title, hourly_rate").order("full_name"),
|
||||
supabase.from("cases").select("practice_area").not("practice_area", "is", null),
|
||||
]);
|
||||
setClients(cs ?? []);
|
||||
setUsers(us ?? []);
|
||||
const unique = Array.from(new Set((pa ?? []).map((r: any) => r.practice_area).filter(Boolean))).sort();
|
||||
setPracticeAreas(unique as string[]);
|
||||
})();
|
||||
}, [open]);
|
||||
|
||||
// Auto-link lead/originating to team
|
||||
useEffect(() => {
|
||||
setTeamUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (leadAttorneyId) next.add(leadAttorneyId);
|
||||
if (originatingAttorneyId) next.add(originatingAttorneyId);
|
||||
return next;
|
||||
});
|
||||
}, [leadAttorneyId, originatingAttorneyId]);
|
||||
|
||||
// Auto-set billing contact when client picked
|
||||
useEffect(() => {
|
||||
if (clientId && !billingContactId) setBillingContactId(clientId);
|
||||
}, [clientId]);
|
||||
|
||||
const selectedClient = useMemo(() => clients.find((c) => c.id === clientId), [clients, clientId]);
|
||||
|
||||
const canContinueStep1 = !!clientId || (showNewClient && newClient.name.trim().length > 0);
|
||||
const canContinueStep2 = caseName.trim().length > 0 && caseNumber.trim().length > 0;
|
||||
const canFinish = !!leadAttorneyId;
|
||||
|
||||
const goNext = async () => {
|
||||
if (step === 1 && showNewClient && !clientId) {
|
||||
// Create client first
|
||||
if (!newClient.name.trim()) return toast.error("Client name is required.");
|
||||
const { data, error } = await supabase
|
||||
.from("clients")
|
||||
.insert({
|
||||
name: newClient.name.trim(),
|
||||
client_type: newClient.client_type as any,
|
||||
primary_contact_email: newClient.primary_contact_email.trim() || null,
|
||||
primary_contact_phone: newClient.primary_contact_phone.trim() || null,
|
||||
created_by: user?.id,
|
||||
})
|
||||
.select("id, name, client_type")
|
||||
.single();
|
||||
if (error) return toast.error("Could not create client", { description: error.message });
|
||||
setClients((prev) => [...prev, data]);
|
||||
setClientId(data.id);
|
||||
setShowNewClient(false);
|
||||
}
|
||||
setStep((s) => (Math.min(4, s + 1) as Step));
|
||||
};
|
||||
|
||||
const submit = async (alsoInvoice = false) => {
|
||||
if (!canFinish) return toast.error("Please pick a lead attorney.");
|
||||
setSubmitting(true);
|
||||
|
||||
let pa = practiceArea;
|
||||
if (showNewPractice && newPracticeArea.trim()) pa = newPracticeArea.trim();
|
||||
|
||||
const payload: any = {
|
||||
client_id: clientId || null,
|
||||
case_number: caseNumber.trim(),
|
||||
title: caseName.trim(),
|
||||
practice_area: pa || null,
|
||||
description: description.trim() || null,
|
||||
status: caseStage,
|
||||
assigned_attorney_id: leadAttorneyId || null,
|
||||
originating_attorney_id: originatingAttorneyId || null,
|
||||
opened_at: dateOpened,
|
||||
statute_of_limitations: solDate || null,
|
||||
billing_method: billingMethod,
|
||||
default_hourly_rate:
|
||||
billingMethod === "hourly" || billingMethod === "mixed"
|
||||
? hourlyRate
|
||||
? parseFloat(hourlyRate)
|
||||
: null
|
||||
: null,
|
||||
flat_fee_amount:
|
||||
billingMethod === "flat_fee" || billingMethod === "mixed"
|
||||
? flatFee
|
||||
? parseFloat(flatFee)
|
||||
: null
|
||||
: null,
|
||||
created_by: user?.id,
|
||||
};
|
||||
|
||||
const { data: caseRow, error } = await supabase.from("cases").insert(payload).select("id").single();
|
||||
if (error) {
|
||||
setSubmitting(false);
|
||||
return toast.error("Could not create case", { description: error.message });
|
||||
}
|
||||
|
||||
// Insert team members
|
||||
const memberRows = Array.from(teamUserIds).map((uid) => ({
|
||||
case_id: caseRow.id,
|
||||
user_id: uid,
|
||||
billing_rate: teamRates[uid] ? parseFloat(teamRates[uid]) : null,
|
||||
created_by: user?.id,
|
||||
}));
|
||||
if (memberRows.length) {
|
||||
const { error: tmErr } = await supabase.from("case_team_members").insert(memberRows);
|
||||
if (tmErr) console.error("Team insert failed:", tmErr.message);
|
||||
}
|
||||
|
||||
// Conflict-check note as comment-style note (store on description tail)
|
||||
if (conflictCheck && conflictNotes.trim()) {
|
||||
await supabase.from("cases").update({
|
||||
description: `${payload.description ?? ""}\n\nConflict check: ${conflictNotes.trim()}`.trim(),
|
||||
}).eq("id", caseRow.id);
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
toast.success("Case created");
|
||||
onOpenChange(false);
|
||||
|
||||
if (alsoInvoice) {
|
||||
navigate({ to: "/invoices" });
|
||||
} else {
|
||||
navigate({ to: "/cases/$caseId", params: { caseId: caseRow.id } });
|
||||
}
|
||||
};
|
||||
|
||||
const userById = (id: string) => users.find((u) => u.id === id);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add case</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center justify-between px-2 py-4">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.n} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full border-2 flex items-center justify-center text-sm font-semibold",
|
||||
step === s.n && "bg-foreground text-background border-foreground",
|
||||
step > s.n && "bg-foreground text-background border-foreground",
|
||||
step < s.n && "bg-background text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{step > s.n ? <Check className="h-4 w-4" /> : s.n}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs whitespace-nowrap",
|
||||
step >= s.n ? "text-foreground font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={cn("flex-1 h-px mx-2 -mt-6", step > s.n ? "bg-foreground" : "bg-border")} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Client */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4 px-2 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={showNewClient ? "default" : "secondary"}
|
||||
onClick={() => setShowNewClient((v) => !v)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add New Contact
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Or</span>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={clientId || "__none"}
|
||||
onValueChange={(v) => {
|
||||
setClientId(v === "__none" ? "" : v);
|
||||
setShowNewClient(false);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Search for an existing contact or company" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">— no client —</SelectItem>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewClient && (
|
||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">New client</h4>
|
||||
<Button size="icon" variant="ghost" onClick={() => setShowNewClient(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={newClient.name}
|
||||
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
|
||||
placeholder="HOA, company, or person"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={newClient.client_type}
|
||||
onValueChange={(v) => setNewClient({ ...newClient, client_type: v })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLIENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Primary contact email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={newClient.primary_contact_email}
|
||||
onChange={(e) => setNewClient({ ...newClient, primary_contact_email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label>Primary contact phone</Label>
|
||||
<Input
|
||||
value={newClient.primary_contact_phone}
|
||||
onChange={(e) => setNewClient({ ...newClient, primary_contact_phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showNewClient && !clientId && (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
Start creating your case by adding a new or existing contact.
|
||||
<br />
|
||||
<span className="text-xs">All cases need at least one client to bill.</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedClient && !showNewClient && (
|
||||
<div className="rounded-md border p-3 bg-muted/30 text-sm">
|
||||
<span className="text-muted-foreground">Selected:</span> <strong>{selectedClient.name}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Case Details */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4 px-2 pb-2">
|
||||
<div className="grid grid-cols-[140px_1fr] gap-4 items-start">
|
||||
<Label className="pt-2">Case name</Label>
|
||||
<Input value={caseName} onChange={(e) => setCaseName(e.target.value)} maxLength={200} />
|
||||
|
||||
<Label className="pt-2">Case number</Label>
|
||||
<div>
|
||||
<Input value={caseNumber} onChange={(e) => setCaseNumber(e.target.value)} maxLength={50} />
|
||||
<p className="text-xs text-muted-foreground mt-1">A unique identifier for this case.</p>
|
||||
</div>
|
||||
|
||||
<Label className="pt-2">Practice area</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
{showNewPractice ? (
|
||||
<>
|
||||
<Input
|
||||
value={newPracticeArea}
|
||||
onChange={(e) => setNewPracticeArea(e.target.value)}
|
||||
placeholder="New practice area"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setShowNewPractice(false); setNewPracticeArea(""); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select value={practiceArea || "__none"} onValueChange={(v) => setPracticeArea(v === "__none" ? "" : v)}>
|
||||
<SelectTrigger className="flex-1"><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">—</SelectItem>
|
||||
{practiceAreas.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPractice(true)}
|
||||
className="text-sm text-primary hover:underline whitespace-nowrap"
|
||||
>
|
||||
Add new practice area
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Label className="pt-2">Case stage</Label>
|
||||
<div>
|
||||
<Select value={caseStage} onValueChange={(v: any) => setCaseStage(v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="intake">Intake</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="on_hold">On hold</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manage case stages in settings.</p>
|
||||
</div>
|
||||
|
||||
<Label className="pt-2">Date opened</Label>
|
||||
<Input type="date" value={dateOpened} onChange={(e) => setDateOpened(e.target.value)} className="max-w-[200px]" />
|
||||
|
||||
<Label className="pt-2">Description</Label>
|
||||
<Textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} maxLength={5000} />
|
||||
|
||||
<Label className="pt-2">Statute of Limitations</Label>
|
||||
<Input type="date" value={solDate} onChange={(e) => setSolDate(e.target.value)} className="max-w-[200px]" />
|
||||
|
||||
<Label className="pt-2">Conflict Check</Label>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Switch checked={conflictCheck} onCheckedChange={setConflictCheck} />
|
||||
<span className="text-sm text-muted-foreground">{conflictCheck ? "Performed" : "Not performed"}</span>
|
||||
</div>
|
||||
|
||||
{conflictCheck && (
|
||||
<>
|
||||
<Label className="pt-2">Conflict Check Notes</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={conflictNotes}
|
||||
onChange={(e) => setConflictNotes(e.target.value)}
|
||||
placeholder="Add notes about the conflict check..."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Billing */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4 px-2 pb-2">
|
||||
<div className="grid grid-cols-[140px_1fr] gap-4 items-start">
|
||||
<Label className="pt-2">Billing Contact</Label>
|
||||
<div>
|
||||
<Select value={billingContactId || "__none"} onValueChange={(v) => setBillingContactId(v === "__none" ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a billing contact" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">—</SelectItem>
|
||||
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">Choosing a billing contact allows you to batch bill this case.</p>
|
||||
</div>
|
||||
|
||||
<Label className="pt-2">Billing Method</Label>
|
||||
<Select value={billingMethod} onValueChange={setBillingMethod}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILLING_METHODS.map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(billingMethod === "hourly" || billingMethod === "mixed") && (
|
||||
<>
|
||||
<Label className="pt-2">Hourly rate ($)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={hourlyRate}
|
||||
onChange={(e) => setHourlyRate(e.target.value)}
|
||||
placeholder="e.g. 350.00"
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(billingMethod === "flat_fee" || billingMethod === "mixed") && (
|
||||
<>
|
||||
<Label className="pt-2">Flat fee ($)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={flatFee}
|
||||
onChange={(e) => setFlatFee(e.target.value)}
|
||||
placeholder="e.g. 1500.00"
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Staff */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-5 px-2 pb-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-bold">Lead Attorney</Label>
|
||||
<Select value={leadAttorneyId} onValueChange={setLeadAttorneyId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a lead attorney for this case…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">The user you select will automatically be checked in the table below.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="font-bold">Originating Attorney</Label>
|
||||
<Select value={originatingAttorneyId || "__none"} onValueChange={(v) => setOriginatingAttorneyId(v === "__none" ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Select an originating attorney for this case…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">—</SelectItem>
|
||||
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">The user you select will automatically be checked in the table below.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="font-bold mb-2 block">Who from your firm should have access to this case?</Label>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr className="text-left">
|
||||
<th className="p-2 w-10"></th>
|
||||
<th className="p-2">Name</th>
|
||||
<th className="p-2">Title</th>
|
||||
<th className="p-2">Billing Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
const checked = teamUserIds.has(u.id);
|
||||
const locked = u.id === leadAttorneyId || u.id === originatingAttorneyId;
|
||||
return (
|
||||
<tr key={u.id} className="border-t">
|
||||
<td className="p-2">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={locked}
|
||||
onCheckedChange={(v) => {
|
||||
setTeamUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (v) next.add(u.id);
|
||||
else next.delete(u.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">{u.full_name || u.email}</td>
|
||||
<td className="p-2 text-muted-foreground">{u.title || "—"}</td>
|
||||
<td className="p-2">
|
||||
{checked ? (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={teamRates[u.id] ?? (u.hourly_rate ?? "")}
|
||||
onChange={(e) => setTeamRates({ ...teamRates, [u.id]: e.target.value })}
|
||||
placeholder={u.hourly_rate ? `Default $${u.hourly_rate}` : "Default Rate"}
|
||||
className="h-8 max-w-[140px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t mt-2">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button variant="link" onClick={() => setStep((s) => (Math.max(1, s - 1) as Step))}>
|
||||
Go Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step < 4 && (
|
||||
<Button
|
||||
onClick={goNext}
|
||||
disabled={(step === 1 && !canContinueStep1) || (step === 2 && !canContinueStep2)}
|
||||
>
|
||||
Continue to {STEPS[step].label}
|
||||
</Button>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<>
|
||||
<Button variant="secondary" disabled={submitting || !canFinish} onClick={() => submit(true)}>
|
||||
Save & Invoice
|
||||
</Button>
|
||||
<Button disabled={submitting || !canFinish} onClick={() => submit(false)}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Save & Finish
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -183,10 +183,53 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
case_team_members: {
|
||||
Row: {
|
||||
billing_rate: number | null
|
||||
case_id: string
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
id: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
billing_rate?: number | null
|
||||
case_id: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
billing_rate?: number | null
|
||||
case_id?: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "case_team_members_case_id_fkey"
|
||||
columns: ["case_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "cases"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "case_team_members_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
cases: {
|
||||
Row: {
|
||||
archived_at: string | null
|
||||
assigned_attorney_id: string | null
|
||||
billing_method: string | null
|
||||
case_caption: string | null
|
||||
case_number: string
|
||||
case_summary: string | null
|
||||
@@ -201,6 +244,7 @@ export type Database = {
|
||||
description: string | null
|
||||
external_id: string | null
|
||||
filing_date: string | null
|
||||
flat_fee_amount: number | null
|
||||
id: string
|
||||
judge: string | null
|
||||
jurisdiction: string | null
|
||||
@@ -214,6 +258,7 @@ export type Database = {
|
||||
opposing_counsel_firm: string | null
|
||||
opposing_counsel_phone: string | null
|
||||
opposing_party: string | null
|
||||
originating_attorney_id: string | null
|
||||
practice_area: string | null
|
||||
settlement_amount: number | null
|
||||
status: Database["public"]["Enums"]["case_status"]
|
||||
@@ -224,6 +269,7 @@ export type Database = {
|
||||
Insert: {
|
||||
archived_at?: string | null
|
||||
assigned_attorney_id?: string | null
|
||||
billing_method?: string | null
|
||||
case_caption?: string | null
|
||||
case_number: string
|
||||
case_summary?: string | null
|
||||
@@ -238,6 +284,7 @@ export type Database = {
|
||||
description?: string | null
|
||||
external_id?: string | null
|
||||
filing_date?: string | null
|
||||
flat_fee_amount?: number | null
|
||||
id?: string
|
||||
judge?: string | null
|
||||
jurisdiction?: string | null
|
||||
@@ -251,6 +298,7 @@ export type Database = {
|
||||
opposing_counsel_firm?: string | null
|
||||
opposing_counsel_phone?: string | null
|
||||
opposing_party?: string | null
|
||||
originating_attorney_id?: string | null
|
||||
practice_area?: string | null
|
||||
settlement_amount?: number | null
|
||||
status?: Database["public"]["Enums"]["case_status"]
|
||||
@@ -261,6 +309,7 @@ export type Database = {
|
||||
Update: {
|
||||
archived_at?: string | null
|
||||
assigned_attorney_id?: string | null
|
||||
billing_method?: string | null
|
||||
case_caption?: string | null
|
||||
case_number?: string
|
||||
case_summary?: string | null
|
||||
@@ -275,6 +324,7 @@ export type Database = {
|
||||
description?: string | null
|
||||
external_id?: string | null
|
||||
filing_date?: string | null
|
||||
flat_fee_amount?: number | null
|
||||
id?: string
|
||||
judge?: string | null
|
||||
jurisdiction?: string | null
|
||||
@@ -288,6 +338,7 @@ export type Database = {
|
||||
opposing_counsel_firm?: string | null
|
||||
opposing_counsel_phone?: string | null
|
||||
opposing_party?: string | null
|
||||
originating_attorney_id?: string | null
|
||||
practice_area?: string | null
|
||||
settlement_amount?: number | null
|
||||
status?: Database["public"]["Enums"]["case_status"]
|
||||
@@ -310,6 +361,13 @@ export type Database = {
|
||||
referencedRelation: "clients"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "cases_originating_attorney_id_fkey"
|
||||
columns: ["originating_attorney_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
client_contacts: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Plus, Search, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
import { NewCaseDialog } from "@/components/cases/new-case-dialog";
|
||||
|
||||
export const Route = createFileRoute("/cases/")({
|
||||
component: () => (
|
||||
@@ -26,6 +27,7 @@ function CasesList() {
|
||||
const [q, setQ] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [view, setView] = useState<"active" | "archived">("active");
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await supabase
|
||||
@@ -60,11 +62,12 @@ function CasesList() {
|
||||
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 onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> New case
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<NewCaseDialog open={newOpen} onOpenChange={setNewOpen} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as "active" | "archived")}>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
-- 1. Add billing fields and originating attorney to cases
|
||||
ALTER TABLE public.cases
|
||||
ADD COLUMN IF NOT EXISTS billing_method text,
|
||||
ADD COLUMN IF NOT EXISTS flat_fee_amount numeric,
|
||||
ADD COLUMN IF NOT EXISTS originating_attorney_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL;
|
||||
|
||||
-- 2. Case team members table
|
||||
CREATE TABLE IF NOT EXISTS public.case_team_members (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
case_id uuid NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
billing_rate numeric,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid,
|
||||
UNIQUE (case_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_case_team_members_case ON public.case_team_members(case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_case_team_members_user ON public.case_team_members(user_id);
|
||||
|
||||
ALTER TABLE public.case_team_members ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 3. Update can_access_case to also include team members
|
||||
CREATE OR REPLACE FUNCTION public.can_access_case(_case_id uuid, _user_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
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
|
||||
OR c.originating_attorney_id = _user_id
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.case_team_members tm
|
||||
WHERE tm.case_id = _case_id AND tm.user_id = _user_id
|
||||
)
|
||||
$function$;
|
||||
|
||||
-- 4. RLS policies for case_team_members
|
||||
DROP POLICY IF EXISTS ctm_select ON public.case_team_members;
|
||||
CREATE POLICY ctm_select ON public.case_team_members
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.can_access_case(case_id, auth.uid()));
|
||||
|
||||
DROP POLICY IF EXISTS ctm_insert ON public.case_team_members;
|
||||
CREATE POLICY ctm_insert ON public.case_team_members
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
public.is_admin(auth.uid())
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.cases c
|
||||
WHERE c.id = case_id
|
||||
AND (c.assigned_attorney_id = auth.uid() OR c.created_by = auth.uid() OR c.originating_attorney_id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS ctm_delete ON public.case_team_members;
|
||||
CREATE POLICY ctm_delete ON public.case_team_members
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
public.is_admin(auth.uid())
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.cases c
|
||||
WHERE c.id = case_id
|
||||
AND (c.assigned_attorney_id = auth.uid() OR c.created_by = auth.uid() OR c.originating_attorney_id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS ctm_update ON public.case_team_members;
|
||||
CREATE POLICY ctm_update ON public.case_team_members
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
public.is_admin(auth.uid())
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.cases c
|
||||
WHERE c.id = case_id
|
||||
AND (c.assigned_attorney_id = auth.uid() OR c.created_by = auth.uid() OR c.originating_attorney_id = auth.uid())
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user