Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
797 lines
27 KiB
TypeScript
797 lines
27 KiB
TypeScript
import { createFileRoute } from "@tanstack/react-router";
|
|
import { useEffect, useState } from "react";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth, type AppRole } from "@/lib/auth";
|
|
import { ProtectedLayout } from "@/components/protected-layout";
|
|
import { PageContainer, PageHeader } from "@/components/app-shell";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
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")({
|
|
component: () => (
|
|
<ProtectedLayout adminOnly>
|
|
<UsersPage />
|
|
</ProtectedLayout>
|
|
),
|
|
});
|
|
|
|
interface UserRow {
|
|
id: string;
|
|
email: string;
|
|
full_name: string;
|
|
created_at: string;
|
|
hourly_rate: number | null;
|
|
title: string | null;
|
|
role: AppRole | null;
|
|
}
|
|
|
|
const ROLE_LABEL: Record<AppRole, string> = {
|
|
admin: "Administrator",
|
|
attorney: "Attorney",
|
|
paralegal: "Paralegal",
|
|
legal_clerk: "Legal Clerk",
|
|
staff: "Staff",
|
|
};
|
|
|
|
const TITLE_OPTIONS = [
|
|
"Attorney",
|
|
"Paralegal",
|
|
"Legal Assistant",
|
|
"Admin",
|
|
"Other",
|
|
] as const;
|
|
type UserTitle = (typeof TITLE_OPTIONS)[number];
|
|
|
|
function UsersPage() {
|
|
const { user: currentUser } = useAuth();
|
|
const [users, setUsers] = useState<UserRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [inviteOpen, setInviteOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [titleFilter, setTitleFilter] = useState<string>("all");
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
const [{ data: profiles }, { data: rolesData }] = await Promise.all([
|
|
supabase.from("profiles").select("id, email, full_name, created_at, hourly_rate, title"),
|
|
supabase.from("user_roles").select("user_id, role"),
|
|
]);
|
|
const roleMap = new Map<string, AppRole>();
|
|
(rolesData ?? []).forEach((r) => roleMap.set(r.user_id, r.role as AppRole));
|
|
const rows: UserRow[] = (profiles ?? []).map((p: any) => ({
|
|
id: p.id,
|
|
email: p.email,
|
|
full_name: p.full_name,
|
|
created_at: p.created_at,
|
|
hourly_rate: p.hourly_rate,
|
|
title: p.title ?? null,
|
|
role: roleMap.get(p.id) ?? null,
|
|
}));
|
|
rows.sort((a, b) => a.email.localeCompare(b.email));
|
|
setUsers(rows);
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const handleRoleChange = async (userId: string, role: AppRole) => {
|
|
const { error } = await supabase.functions.invoke("admin-update-role", {
|
|
body: { user_id: userId, role },
|
|
});
|
|
if (error) {
|
|
toast.error("Could not update role", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("Role updated");
|
|
load();
|
|
};
|
|
|
|
const handleDelete = async (userId: string) => {
|
|
const { error } = await supabase.functions.invoke("admin-delete-user", {
|
|
body: { user_id: userId },
|
|
});
|
|
if (error) {
|
|
toast.error("Could not delete user", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("User deleted");
|
|
load();
|
|
};
|
|
|
|
const handleRateSave = async (userId: string, rate: number | null) => {
|
|
const { error } = await supabase
|
|
.from("profiles")
|
|
.update({ hourly_rate: rate })
|
|
.eq("id", userId);
|
|
if (error) {
|
|
toast.error("Could not save rate", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("Rate updated");
|
|
load();
|
|
};
|
|
|
|
const handleTitleChange = async (userId: string, title: string | null) => {
|
|
const { error } = await supabase
|
|
.from("profiles")
|
|
.update({ title })
|
|
.eq("id", userId);
|
|
if (error) {
|
|
toast.error("Could not update title", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("Title updated");
|
|
load();
|
|
};
|
|
|
|
const filteredUsers = users.filter((u) => {
|
|
if (titleFilter !== "all") {
|
|
if (titleFilter === "__none__" ? u.title : u.title !== titleFilter) return false;
|
|
}
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return true;
|
|
return (
|
|
u.email.toLowerCase().includes(q) ||
|
|
(u.full_name ?? "").toLowerCase().includes(q) ||
|
|
(u.title ?? "").toLowerCase().includes(q)
|
|
);
|
|
});
|
|
|
|
const titleCounts = TITLE_OPTIONS.reduce<Record<string, number>>((acc, t) => {
|
|
acc[t] = users.filter((u) => u.title === t).length;
|
|
return acc;
|
|
}, {});
|
|
const noTitleCount = users.filter((u) => !u.title).length;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Users"
|
|
description="Invite team members and manage their access."
|
|
actions={
|
|
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<UserPlus className="h-4 w-4 mr-2" />
|
|
Invite user
|
|
</Button>
|
|
</DialogTrigger>
|
|
<InviteDialog
|
|
onCreated={() => {
|
|
setInviteOpen(false);
|
|
load();
|
|
}}
|
|
/>
|
|
</Dialog>
|
|
}
|
|
/>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="font-serif">Team members</CardTitle>
|
|
<CardDescription>
|
|
{filteredUsers.length} of {users.length} shown
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col sm:flex-row gap-2 mb-4">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search by name, email, or title"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<Select value={titleFilter} onValueChange={setTitleFilter}>
|
|
<SelectTrigger className="w-full sm:w-[220px]">
|
|
<SelectValue placeholder="Filter by title" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All titles ({users.length})</SelectItem>
|
|
<SelectItem value="__none__">No title ({noTitleCount})</SelectItem>
|
|
{TITLE_OPTIONS.map((t) => (
|
|
<SelectItem key={t} value={t}>
|
|
{t} ({titleCounts[t] ?? 0})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : filteredUsers.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground py-8 text-center">No users yet.</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead className="w-[170px]">Title</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
<TableHead className="w-[140px]">Hourly rate</TableHead>
|
|
<TableHead className="w-[120px] text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredUsers.map((u) => {
|
|
const isSelf = u.id === currentUser?.id;
|
|
return (
|
|
<TableRow key={u.id}>
|
|
<TableCell className="font-medium">
|
|
{u.full_name || <span className="text-muted-foreground">—</span>}
|
|
{isSelf && (
|
|
<Badge variant="secondary" className="ml-2">
|
|
You
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
|
<TableCell>
|
|
<Select
|
|
value={u.title ?? "__none__"}
|
|
onValueChange={(v) =>
|
|
handleTitleChange(u.id, v === "__none__" ? null : v)
|
|
}
|
|
>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue placeholder="No title" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">No title</SelectItem>
|
|
{TITLE_OPTIONS.map((t) => (
|
|
<SelectItem key={t} value={t}>
|
|
{t}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Select
|
|
value={u.role ?? undefined}
|
|
onValueChange={(v) => handleRoleChange(u.id, v as AppRole)}
|
|
disabled={isSelf}
|
|
>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue placeholder="No role" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{ROLE_LABEL[r]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<RateInput
|
|
value={u.hourly_rate}
|
|
onSave={(v) => handleRateSave(u.id, v)}
|
|
/>
|
|
</TableCell>
|
|
<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>
|
|
<Button variant="ghost" size="icon">
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete user?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will permanently remove {u.email} and revoke their access.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => handleDelete(u.id)}>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
function RateInput({
|
|
value,
|
|
onSave,
|
|
}: {
|
|
value: number | null;
|
|
onSave: (v: number | null) => void | Promise<void>;
|
|
}) {
|
|
const [text, setText] = useState(value != null ? String(value) : "");
|
|
useEffect(() => setText(value != null ? String(value) : ""), [value]);
|
|
const dirty = text !== (value != null ? String(value) : "");
|
|
|
|
const commit = () => {
|
|
if (!dirty) return;
|
|
if (text.trim() === "") return onSave(null);
|
|
const n = parseFloat(text);
|
|
if (Number.isNaN(n) || n < 0) {
|
|
toast.error("Invalid rate");
|
|
setText(value != null ? String(value) : "");
|
|
return;
|
|
}
|
|
onSave(n);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-muted-foreground">$</span>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
}}
|
|
placeholder="—"
|
|
className="h-8 w-24"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">/hr</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function SetPasswordDialog({ userId, email }: { userId: string; email: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [password, setPassword] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const onSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
const { error } = await supabase.functions.invoke("admin-set-password", {
|
|
body: { user_id: userId, password },
|
|
});
|
|
setSubmitting(false);
|
|
if (error) {
|
|
toast.error("Could not change password", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("Password updated", {
|
|
description: "Share the new password securely with the user.",
|
|
});
|
|
setPassword("");
|
|
setOpen(false);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="ghost" size="icon" title="Change password">
|
|
<KeyRound className="h-4 w-4" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="font-serif">Change password</DialogTitle>
|
|
<DialogDescription>
|
|
Set a new password for {email}. Share it with them through a secure channel.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="new-password">New password</Label>
|
|
<Input
|
|
id="new-password"
|
|
type="text"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
minLength={10}
|
|
required
|
|
autoFocus
|
|
/>
|
|
<p className="text-xs text-muted-foreground">Min 10 characters.</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="submit" disabled={submitting}>
|
|
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Update password
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function InviteDialog({ onCreated }: { onCreated: () => void }) {
|
|
const [email, setEmail] = useState("");
|
|
const [fullName, setFullName] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [role, setRole] = useState<AppRole>("attorney");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const onSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
const { error } = await supabase.functions.invoke("admin-invite-user", {
|
|
body: { email: email.trim(), password, full_name: fullName.trim(), role },
|
|
});
|
|
setSubmitting(false);
|
|
if (error) {
|
|
toast.error("Could not create user", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("User created", {
|
|
description: "Share the temporary password securely with them.",
|
|
});
|
|
setEmail("");
|
|
setFullName("");
|
|
setPassword("");
|
|
setRole("attorney");
|
|
onCreated();
|
|
};
|
|
|
|
return (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="font-serif">Invite user</DialogTitle>
|
|
<DialogDescription>
|
|
Create an account with a temporary password. Share it with them through a secure channel.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="invite-name">Full name</Label>
|
|
<Input
|
|
id="invite-name"
|
|
value={fullName}
|
|
onChange={(e) => setFullName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="invite-email">Email</Label>
|
|
<Input
|
|
id="invite-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="invite-password">Temporary password</Label>
|
|
<Input
|
|
id="invite-password"
|
|
type="text"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
minLength={10}
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Min 10 characters. The user can change it after first sign-in.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Role</Label>
|
|
<Select value={role} onValueChange={(v) => setRole(v as AppRole)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{ROLE_LABEL[r]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="submit" disabled={submitting}>
|
|
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Create user
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</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>
|
|
);
|
|
}
|