diff --git a/src/components/cases/new-case-dialog.tsx b/src/components/cases/new-case-dialog.tsx new file mode 100644 index 0000000..08fdfdb --- /dev/null +++ b/src/components/cases/new-case-dialog.tsx @@ -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(1); + const [submitting, setSubmitting] = useState(false); + + const [clients, setClients] = useState([]); + const [users, setUsers] = useState([]); + const [practiceAreas, setPracticeAreas] = useState([]); + + // Step 1 + const [clientId, setClientId] = useState(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(new Date().toISOString().slice(0, 10)); + const [description, setDescription] = useState(""); + const [solDate, setSolDate] = useState(""); + const [conflictCheck, setConflictCheck] = useState(false); + const [conflictNotes, setConflictNotes] = useState(""); + + // Step 3 + const [billingContactId, setBillingContactId] = useState(""); + const [billingMethod, setBillingMethod] = useState("hourly"); + const [hourlyRate, setHourlyRate] = useState(""); + const [flatFee, setFlatFee] = useState(""); + + // Step 4 + const [leadAttorneyId, setLeadAttorneyId] = useState(""); + const [originatingAttorneyId, setOriginatingAttorneyId] = useState(""); + const [teamUserIds, setTeamUserIds] = useState>(new Set()); + const [teamRates, setTeamRates] = useState>({}); + + // 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 ( + + + + Add case + + + {/* Stepper */} +
+ {STEPS.map((s, i) => ( +
+
+
s.n && "bg-foreground text-background border-foreground", + step < s.n && "bg-background text-muted-foreground border-border", + )} + > + {step > s.n ? : s.n} +
+ = s.n ? "text-foreground font-medium" : "text-muted-foreground", + )} + > + {s.label} + +
+ {i < STEPS.length - 1 && ( +
s.n ? "bg-foreground" : "bg-border")} /> + )} +
+ ))} +
+ + {/* Step 1: Client */} + {step === 1 && ( +
+
+ + Or +
+ +
+
+ + {showNewClient && ( +
+
+

New client

+ +
+
+
+ + setNewClient({ ...newClient, name: e.target.value })} + placeholder="HOA, company, or person" + /> +
+
+ + +
+
+ + setNewClient({ ...newClient, primary_contact_email: e.target.value })} + /> +
+
+ + setNewClient({ ...newClient, primary_contact_phone: e.target.value })} + /> +
+
+
+ )} + + {!showNewClient && !clientId && ( +

+ Start creating your case by adding a new or existing contact. +
+ All cases need at least one client to bill. +

+ )} + + {selectedClient && !showNewClient && ( +
+ Selected: {selectedClient.name} +
+ )} +
+ )} + + {/* Step 2: Case Details */} + {step === 2 && ( +
+
+ + setCaseName(e.target.value)} maxLength={200} /> + + +
+ setCaseNumber(e.target.value)} maxLength={50} /> +

A unique identifier for this case.

+
+ + +
+ {showNewPractice ? ( + <> + setNewPracticeArea(e.target.value)} + placeholder="New practice area" + /> + + + ) : ( + <> + + + + )} +
+ + +
+ +

Manage case stages in settings.

+
+ + + setDateOpened(e.target.value)} className="max-w-[200px]" /> + + +