diff --git a/src/routes/admin.users.tsx b/src/routes/admin.users.tsx index cb59a23..121aa71 100644 --- a/src/routes/admin.users.tsx +++ b/src/routes/admin.users.tsx @@ -475,3 +475,229 @@ function InviteDialog({ onCreated }: { onCreated: () => void }) { ); } + +interface CaseOption { + id: string; + case_number: string; + title: string; + status: string; + client_name: string | null; +} + +function ManageCasesDialog({ userId, userName }: { userId: string; userName: string }) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [cases, setCases] = useState([]); + const [initial, setInitial] = useState>(new Set()); + const [selected, setSelected] = useState>(new Set()); + const [primary, setPrimary] = useState>(new Set()); + const [search, setSearch] = useState(""); + const [onlyMine, setOnlyMine] = useState(false); + + const load = async () => { + setLoading(true); + const [{ data: caseRows }, { data: teamRows }] = await Promise.all([ + supabase + .from("cases") + .select("id, case_number, title, status, assigned_attorney_id, originating_attorney_id, clients(name)") + .is("archived_at", null) + .order("case_number", { ascending: false }) + .limit(1000), + supabase.from("case_team_members").select("case_id").eq("user_id", userId), + ]); + const opts: CaseOption[] = (caseRows ?? []).map((c: any) => ({ + id: c.id, + case_number: c.case_number, + title: c.title, + status: c.status, + client_name: c.clients?.name ?? null, + })); + const prim = new Set( + (caseRows ?? []) + .filter((c: any) => c.assigned_attorney_id === userId || c.originating_attorney_id === userId) + .map((c: any) => c.id), + ); + const team = new Set((teamRows ?? []).map((r: any) => r.case_id)); + const all = new Set([...prim, ...team]); + setCases(opts); + setPrimary(prim); + setInitial(team); + setSelected(new Set(all)); + setLoading(false); + }; + + useEffect(() => { + if (open) load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const toggle = (id: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }; + + const filtered = cases.filter((c) => { + if (onlyMine && !selected.has(c.id)) return false; + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + c.case_number.toLowerCase().includes(q) || + c.title.toLowerCase().includes(q) || + (c.client_name ?? "").toLowerCase().includes(q) + ); + }); + + const visibleIds = filtered.map((c) => c.id); + const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); + const toggleAllVisible = (checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + visibleIds.forEach((id) => { + if (checked) next.add(id); + else if (!primary.has(id)) next.delete(id); + }); + return next; + }); + }; + + const onSave = async () => { + setSubmitting(true); + // Diff against the team-membership set only (primary attorney rows aren't editable here) + const target = new Set([...selected].filter((id) => !primary.has(id))); + const toAdd = [...target].filter((id) => !initial.has(id)); + const toRemove = [...initial].filter((id) => !target.has(id)); + + try { + if (toAdd.length > 0) { + const { error } = await supabase + .from("case_team_members") + .insert(toAdd.map((case_id) => ({ case_id, user_id: userId }))); + if (error) throw error; + } + if (toRemove.length > 0) { + const { error } = await supabase + .from("case_team_members") + .delete() + .eq("user_id", userId) + .in("case_id", toRemove); + if (error) throw error; + } + toast.success("Case assignments updated", { + description: `${toAdd.length} added, ${toRemove.length} removed`, + }); + setOpen(false); + } catch (e: any) { + toast.error("Could not update assignments", { description: e.message }); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + + Assign cases + + Add or remove {userName} from case teams. Primary attorney assignments are shown but managed on the case itself. + + + +
+
+ + setSearch(e.target.value)} + placeholder="Search by case number, title, or client" + className="pl-8" + /> +
+ +
+ +
+ {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
No cases match.
+ ) : ( + + + + + toggleAllVisible(!!v)} + aria-label="Select all visible" + /> + + Case + Client + Status + + + + {filtered.map((c) => { + const isPrimary = primary.has(c.id); + const isChecked = selected.has(c.id); + return ( + + + toggle(c.id, !!v)} + /> + + +
{c.case_number}
+
{c.title}
+ {isPrimary && ( + + Primary attorney + + )} +
+ + {c.client_name ?? "—"} + + + {c.status} + +
+ ); + })} +
+
+ )} +
+ + +
+ {selected.size} assigned · {cases.length} total +
+ +
+
+
+ ); +}