Files
acmcc/src/pages/CommitteesPage.tsx
T
2026-06-01 20:19:26 -04:00

561 lines
24 KiB
TypeScript

import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import { Users, Plus, Trash2, UserPlus, Pencil, Link2 } from "lucide-react";
type Association = { id: string; name: string };
type Committee = { id: string; association_id: string; name: string; description: string | null; is_active: boolean };
type Member = { id: string; committee_id: string | null; association_id: string; name: string; email: string | null; role: string | null; start_date: string | null; end_date: string | null; is_active: boolean; user_id: string | null };
type LinkableUser = { user_id: string; full_name: string | null; email: string | null };
function termStatus(m: { start_date: string | null; end_date: string | null; is_active: boolean }) {
const today = new Date().toISOString().slice(0, 10);
if (m.start_date && m.start_date > today) return { label: "Pending", variant: "secondary" as const };
if (m.end_date && m.end_date < today) return { label: "Term ended", variant: "outline" as const };
return { label: "Active", variant: "default" as const };
}
export default function CommitteesPage() {
const { user, isAdmin } = useAuth();
const { toast } = useToast();
const [associations, setAssociations] = useState<Association[]>([]);
const [assocFilter, setAssocFilter] = useState<string>("all");
const [committees, setCommittees] = useState<Committee[]>([]);
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [linkableUsers, setLinkableUsers] = useState<LinkableUser[]>([]);
const [assocUserIds, setAssocUserIds] = useState<Set<string> | null>(null);
// Create committee
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [newAssoc, setNewAssoc] = useState<string>("");
const [saving, setSaving] = useState(false);
// Add member
const [memberDialog, setMemberDialog] = useState<Committee | null>(null);
const [mName, setMName] = useState("");
const [mEmail, setMEmail] = useState("");
const [mRole, setMRole] = useState("");
const [mStart, setMStart] = useState("");
const [mEnd, setMEnd] = useState("");
const [mUserId, setMUserId] = useState<string>("");
// Edit member
const [editingMember, setEditingMember] = useState<Member | null>(null);
const [emName, setEmName] = useState("");
const [emEmail, setEmEmail] = useState("");
const [emRole, setEmRole] = useState("");
const [emStart, setEmStart] = useState("");
const [emEnd, setEmEnd] = useState("");
const [emUserId, setEmUserId] = useState<string>("");
// Edit committee
const [editing, setEditing] = useState<Committee | null>(null);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const fetchAll = async () => {
setLoading(true);
const [{ data: assocs }, { data: cs }, { data: ms }] = await Promise.all([
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
supabase.from("committees").select("*").order("name"),
supabase.from("arc_committee_members").select("id, committee_id, association_id, name, email, role, start_date, end_date, is_active, user_id").order("name"),
]);
setAssociations((assocs ?? []) as Association[]);
setCommittees((cs ?? []) as Committee[]);
setMembers((ms ?? []) as Member[]);
setLoading(false);
};
useEffect(() => { fetchAll(); }, []);
// Load list of users for linking (admin-only)
useEffect(() => {
if (!isAdmin) return;
(async () => {
try {
const { data, error } = await supabase.functions.invoke("admin-user-management", {
body: { action: "list_users" },
});
if (error) throw error;
const list: LinkableUser[] = (data?.users ?? data ?? []).map((u: any) => ({
user_id: u.id,
full_name: u.user_metadata?.full_name || u.full_name || null,
email: u.email || null,
})).filter((u: LinkableUser) => u.user_id);
list.sort((a, b) => (a.full_name || a.email || "").localeCompare(b.full_name || b.email || ""));
setLinkableUsers(list);
} catch (err) {
console.warn("Could not load users for linking", err);
}
})();
}, [isAdmin]);
// Load user_ids of owners belonging to the active dialog's association
useEffect(() => {
const assocId = memberDialog?.association_id || editingMember?.association_id;
if (!assocId) { setAssocUserIds(null); return; }
(async () => {
const { data } = await supabase
.from("owners")
.select("user_id")
.eq("association_id", assocId)
.neq("status", "archived")
.not("user_id", "is", null);
setAssocUserIds(new Set((data ?? []).map((o: any) => o.user_id).filter(Boolean)));
})();
}, [memberDialog, editingMember]);
const dialogLinkableUsers = assocUserIds
? linkableUsers.filter((u) => assocUserIds.has(u.user_id))
: linkableUsers;
const userLabel = (u: LinkableUser) => u.full_name ? `${u.full_name}${u.email ? ` (${u.email})` : ""}` : (u.email || u.user_id);
const linkedUserName = (uid: string | null) => {
if (!uid) return null;
const u = linkableUsers.find((x) => x.user_id === uid);
return u ? userLabel(u) : "Linked user";
};
const visibleCommittees = assocFilter === "all"
? committees
: committees.filter((c) => c.association_id === assocFilter);
const associationName = (id: string) => associations.find((a) => a.id === id)?.name || "—";
const committeeMembers = (cid: string) => members.filter((m) => m.committee_id === cid);
const createCommittee = async () => {
if (!newName.trim() || !newAssoc) {
toast({ title: "Name and association required", variant: "destructive" });
return;
}
setSaving(true);
try {
const { error } = await supabase.from("committees").insert({
name: newName.trim(),
description: newDesc.trim() || null,
association_id: newAssoc,
created_by: user?.id ?? null,
});
if (error) throw error;
toast({ title: "Committee created" });
setNewOpen(false);
setNewName(""); setNewDesc(""); setNewAssoc("");
fetchAll();
} catch (err: any) {
toast({ title: "Error", description: err.message, variant: "destructive" });
} finally {
setSaving(false);
}
};
const saveEdit = async () => {
if (!editing) return;
const { error } = await supabase.from("committees").update({
name: editName.trim() || editing.name,
description: editDesc.trim() || null,
}).eq("id", editing.id);
if (error) {
toast({ title: "Error", description: error.message, variant: "destructive" });
return;
}
toast({ title: "Saved" });
setEditing(null);
fetchAll();
};
const deleteCommittee = async (c: Committee) => {
if (!confirm(`Delete "${c.name}" and all its members?`)) return;
const { error } = await supabase.from("committees").delete().eq("id", c.id);
if (error) {
toast({ title: "Error", description: error.message, variant: "destructive" });
return;
}
fetchAll();
};
const addMember = async () => {
if (!memberDialog || !mName.trim()) return;
const { error } = await supabase.from("arc_committee_members").insert({
committee_id: memberDialog.id,
association_id: memberDialog.association_id,
name: mName.trim(),
email: mEmail.trim() || null,
role: mRole.trim() || null,
start_date: mStart || null,
end_date: mEnd || null,
user_id: mUserId || null,
created_by: user?.id ?? null,
});
if (error) {
toast({ title: "Error", description: error.message, variant: "destructive" });
return;
}
setMName(""); setMEmail(""); setMRole(""); setMStart(""); setMEnd(""); setMUserId("");
fetchAll();
};
const openEditMember = (m: Member) => {
setEditingMember(m);
setEmName(m.name);
setEmEmail(m.email || "");
setEmRole(m.role || "");
setEmStart(m.start_date || "");
setEmEnd(m.end_date || "");
setEmUserId(m.user_id || "");
};
const saveEditMember = async () => {
if (!editingMember) return;
const { error } = await supabase.from("arc_committee_members").update({
name: emName.trim() || editingMember.name,
email: emEmail.trim() || null,
role: emRole.trim() || null,
start_date: emStart || null,
end_date: emEnd || null,
user_id: emUserId || null,
}).eq("id", editingMember.id);
if (error) {
toast({ title: "Error", description: error.message, variant: "destructive" });
return;
}
setEditingMember(null);
fetchAll();
};
const removeMember = async (id: string) => {
const { error } = await supabase.from("arc_committee_members").delete().eq("id", id);
if (error) {
toast({ title: "Error", description: error.message, variant: "destructive" });
return;
}
fetchAll();
};
return (
<div className="space-y-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<Users className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-[22px] font-bold tracking-tight text-foreground">Committees</h1>
<p className="text-[13px] text-muted-foreground">
Manage committees and members per association. Members don't need user accounts useful for recording historical votes.
</p>
</div>
</div>
{isAdmin && (
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5"><Plus className="h-3.5 w-3.5" /> New Committee</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Committee</DialogTitle>
<DialogDescription>Add a committee for a specific association.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Association *</Label>
<Select value={newAssoc} onValueChange={setNewAssoc}>
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
<SelectContent>
{associations.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Committee Name *</Label>
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="e.g. Architectural Review Committee" />
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewOpen(false)}>Cancel</Button>
<Button onClick={createCommittee} disabled={saving}>{saving ? "Saving…" : "Create"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
<div className="flex items-center gap-3">
<Select value={assocFilter} onValueChange={setAssocFilter}>
<SelectTrigger className="w-[260px] h-9 text-[13px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Associations</SelectItem>
{associations.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{loading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : visibleCommittees.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
<Users className="mx-auto h-10 w-10 mb-3 opacity-30" />
<p className="font-medium">No committees yet.</p>
{isAdmin && <p className="text-[12px] mt-1">Click "New Committee" to add one.</p>}
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{visibleCommittees.map((c) => {
const cm = committeeMembers(c.id);
return (
<Card key={c.id}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2">
{c.name}
<Badge variant="outline" className="text-[10px]">{associationName(c.association_id)}</Badge>
</CardTitle>
{c.description && <p className="text-xs text-muted-foreground mt-1">{c.description}</p>}
</div>
{isAdmin && (
<div className="flex gap-1">
<Button size="sm" variant="ghost" className="gap-1 h-8" onClick={() => { setMemberDialog(c); }}>
<UserPlus className="h-3.5 w-3.5" /> Add Member
</Button>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => { setEditing(c); setEditName(c.name); setEditDesc(c.description || ""); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => deleteCommittee(c)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</CardHeader>
<CardContent>
{cm.length === 0 ? (
<p className="text-xs text-muted-foreground">No members yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-[11px]">Name</TableHead>
<TableHead className="text-[11px]">Email</TableHead>
<TableHead className="text-[11px]">Role</TableHead>
<TableHead className="text-[11px]">Term</TableHead>
<TableHead className="text-[11px]">Status</TableHead>
{isAdmin && <TableHead className="w-[80px]" />}
</TableRow>
</TableHeader>
<TableBody>
{cm.map((m) => {
const status = termStatus(m);
const term = [m.start_date || "—", m.end_date || "Present"].join(" → ");
return (
<TableRow key={m.id}>
<TableCell className="text-sm font-medium">
<div className="flex items-center gap-1.5">
{m.name}
{m.user_id && (
<Badge variant="secondary" className="text-[10px] gap-0.5" title={linkedUserName(m.user_id) || "Linked"}>
<Link2 className="h-2.5 w-2.5" /> Linked
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{m.email || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{m.role || "—"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{term}</TableCell>
<TableCell><Badge variant={status.variant} className="text-[10px]">{status.label}</Badge></TableCell>
{isAdmin && (
<TableCell>
<div className="flex gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEditMember(m)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => removeMember(m.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* Add Member Dialog */}
<Dialog open={!!memberDialog} onOpenChange={(o) => !o && setMemberDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Member to {memberDialog?.name}</DialogTitle>
<DialogDescription>Optionally link this member to an existing user account so their portal access updates automatically.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Link to User Account (optional)</Label>
<Select
value={mUserId || "__none__"}
onValueChange={(v) => {
if (v === "__none__") { setMUserId(""); return; }
setMUserId(v);
const u = linkableUsers.find((x) => x.user_id === v);
if (u) {
if (!mName.trim() && u.full_name) setMName(u.full_name);
if (!mEmail.trim() && u.email) setMEmail(u.email);
}
}}
>
<SelectTrigger><SelectValue placeholder="No linked user" /></SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="__none__">No linked user</SelectItem>
{dialogLinkableUsers.map((u) => (
<SelectItem key={u.user_id} value={u.user_id}>{userLabel(u)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Name *</Label>
<Input value={mName} onChange={(e) => setMName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input value={mEmail} onChange={(e) => setMEmail(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Role</Label>
<Input value={mRole} onChange={(e) => setMRole(e.target.value)} placeholder="e.g. Chair, Member" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Start Date</Label>
<Input type="date" value={mStart} onChange={(e) => setMStart(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>End Date</Label>
<Input type="date" value={mEnd} onChange={(e) => setMEnd(e.target.value)} />
</div>
</div>
<p className="text-[11px] text-muted-foreground">Outside the term window the member is auto-deactivated and loses committee access; their past actions remain on record.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMemberDialog(null)}>Close</Button>
<Button onClick={addMember} disabled={!mName.trim()}>Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Committee Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Committee</DialogTitle>
<DialogDescription>Update committee details.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Textarea value={editDesc} onChange={(e) => setEditDesc(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
<Button onClick={saveEdit}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Member Dialog */}
<Dialog open={!!editingMember} onOpenChange={(o) => !o && setEditingMember(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Committee Member</DialogTitle>
<DialogDescription>Update term dates to schedule access. Past actions are preserved.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Link to User Account</Label>
<Select
value={emUserId || "__none__"}
onValueChange={(v) => {
if (v === "__none__") { setEmUserId(""); return; }
setEmUserId(v);
const u = linkableUsers.find((x) => x.user_id === v);
if (u) {
if (!emName.trim() && u.full_name) setEmName(u.full_name);
if (!emEmail.trim() && u.email) setEmEmail(u.email);
}
}}
>
<SelectTrigger><SelectValue placeholder="No linked user" /></SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="__none__">No linked user</SelectItem>
{dialogLinkableUsers.map((u) => (
<SelectItem key={u.user_id} value={u.user_id}>{userLabel(u)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={emName} onChange={(e) => setEmName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input value={emEmail} onChange={(e) => setEmEmail(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Role</Label>
<Input value={emRole} onChange={(e) => setEmRole(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Start Date</Label>
<Input type="date" value={emStart} onChange={(e) => setEmStart(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>End Date</Label>
<Input type="date" value={emEnd} onChange={(e) => setEmEnd(e.target.value)} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingMember(null)}>Cancel</Button>
<Button onClick={saveEditMember}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}