Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 06:54:27 +00:00
co-authored by renee-png
parent 4eb7da6427
commit 39a4bbe968
2 changed files with 664 additions and 2 deletions
+659
View File
@@ -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>
);
}