Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-08 17:02:41 +00:00
co-authored by renee-png
parent 713b1816ba
commit 5aa0718cf5
+226
View File
@@ -475,3 +475,229 @@ function InviteDialog({ onCreated }: { onCreated: () => void }) {
</DialogContent>
);
}
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<CaseOption[]>([]);
const [initial, setInitial] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [primary, setPrimary] = useState<Set<string>>(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<string>(
(caseRows ?? [])
.filter((c: any) => c.assigned_attorney_id === userId || c.originating_attorney_id === userId)
.map((c: any) => c.id),
);
const team = new Set<string>((teamRows ?? []).map((r: any) => r.case_id));
const all = new Set<string>([...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<string>([...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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title="Manage case assignments">
<Briefcase className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="font-serif">Assign cases</DialogTitle>
<DialogDescription>
Add or remove {userName} from case teams. Primary attorney assignments are shown but managed on the case itself.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by case number, title, or client"
className="pl-8"
/>
</div>
<label className="flex items-center gap-2 text-sm whitespace-nowrap">
<Checkbox checked={onlyMine} onCheckedChange={(v) => setOnlyMine(!!v)} />
Assigned only
</label>
</div>
<div className="border rounded-md max-h-[420px] overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">No cases match.</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected}
onCheckedChange={(v) => toggleAllVisible(!!v)}
aria-label="Select all visible"
/>
</TableHead>
<TableHead>Case</TableHead>
<TableHead>Client</TableHead>
<TableHead className="w-24">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((c) => {
const isPrimary = primary.has(c.id);
const isChecked = selected.has(c.id);
return (
<TableRow key={c.id}>
<TableCell>
<Checkbox
checked={isChecked}
disabled={isPrimary}
onCheckedChange={(v) => toggle(c.id, !!v)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{c.case_number}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{c.title}</div>
{isPrimary && (
<Badge variant="secondary" className="mt-1">
Primary attorney
</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{c.client_name ?? "—"}
</TableCell>
<TableCell className="text-xs text-muted-foreground capitalize">
{c.status}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<div className="text-xs text-muted-foreground">
{selected.size} assigned · {cases.length} total
</div>
<Button onClick={onSave} disabled={submitting || loading}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save assignments
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}