Added bulk case assignment UI
X-Lovable-Edit-ID: edt-61fac9b4-78c7-467e-8353-fcb0dcbc145a Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
+229
-1
@@ -50,7 +50,8 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, UserPlus, Trash2, KeyRound } from "lucide-react";
|
||||
import { Loader2, UserPlus, Trash2, KeyRound, Briefcase, Search } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/admin/users")({
|
||||
@@ -233,6 +234,7 @@ function UsersPage() {
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<SetPasswordDialog userId={u.id} email={u.email} />
|
||||
<ManageCasesDialog userId={u.id} userName={u.full_name || u.email} />
|
||||
{!isSelf && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -473,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user