From 4eb7da6427f9625f1b8ee94714db8f17d3e97f25 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:52:54 +0000 Subject: [PATCH 1/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 58 +++++++++++++ ...1_8122182d-2825-4d22-88c1-e6b62ab09ca5.sql | 87 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 supabase/migrations/20260418065251_8122182d-2825-4d22-88c1-e6b62ab09ca5.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index ac52c0e..1f19e6e 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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: { diff --git a/supabase/migrations/20260418065251_8122182d-2825-4d22-88c1-e6b62ab09ca5.sql b/supabase/migrations/20260418065251_8122182d-2825-4d22-88c1-e6b62ab09ca5.sql new file mode 100644 index 0000000..1421c73 --- /dev/null +++ b/supabase/migrations/20260418065251_8122182d-2825-4d22-88c1-e6b62ab09ca5.sql @@ -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()) + ) + ); \ No newline at end of file From 39a4bbe968c6031f79c559dcedb776ccdb6c48b1 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:54:27 +0000 Subject: [PATCH 2/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/new-case-dialog.tsx | 659 +++++++++++++++++++++++ src/routes/cases.index.tsx | 7 +- 2 files changed, 664 insertions(+), 2 deletions(-) create mode 100644 src/components/cases/new-case-dialog.tsx 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]" /> + + +