mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Plus, Edit, Trash2, Shield, Loader2, FilterX } from "lucide-react";
|
||||
import RecordImportButton from "@/components/RecordImportButton";
|
||||
import type { Tables } from "@/integrations/supabase/types";
|
||||
|
||||
type BoardMember = {
|
||||
id: string; association_id: string; member_name: string; member_email: string | null;
|
||||
phone: string | null; role: string | null; approval_authority: boolean;
|
||||
created_at: string; associations?: { name: string } | null;
|
||||
};
|
||||
|
||||
const emptyForm = { member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, association_id: "" };
|
||||
|
||||
export default function BoardMembersPage() {
|
||||
const { toast } = useToast();
|
||||
const [members, setMembers] = useState<BoardMember[]>([]);
|
||||
const [associations, setAssociations] = useState<Tables<"associations">[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedAssoc, setSelectedAssoc] = useState("all");
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteMember, setDeleteMember] = useState<BoardMember | null>(null);
|
||||
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const [mRes, aRes] = await Promise.all([
|
||||
supabase.from("board_members").select("*, associations(name)").order("created_at", { ascending: false }),
|
||||
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
|
||||
]);
|
||||
if (mRes.data) setMembers(mRes.data as BoardMember[]);
|
||||
if (aRes.data) setAssociations(aRes.data as Tables<"associations">[]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const filtered = members.filter(m => selectedAssoc === "all" || m.association_id === selectedAssoc);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm({ ...emptyForm }); setFormOpen(true); };
|
||||
const openEdit = (m: BoardMember) => {
|
||||
setEditingId(m.id);
|
||||
setForm({ member_name: m.member_name, member_email: m.member_email || "", phone: m.phone || "", role: m.role || "Member", approval_authority: m.approval_authority, association_id: m.association_id });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.member_name || !form.association_id) {
|
||||
toast({ variant: "destructive", title: "Name and association are required" }); return;
|
||||
}
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
association_id: form.association_id, member_name: form.member_name,
|
||||
member_email: form.member_email || null, phone: form.phone || null,
|
||||
role: form.role || "Member", approval_authority: form.approval_authority,
|
||||
};
|
||||
const { error } = editingId
|
||||
? await supabase.from("board_members").update(payload).eq("id", editingId)
|
||||
: await supabase.from("board_members").insert(payload);
|
||||
if (error) toast({ variant: "destructive", title: "Error", description: error.message });
|
||||
else { toast({ title: editingId ? "Member updated" : "Member added" }); setFormOpen(false); fetchData(); }
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteMember) return;
|
||||
const { error } = await supabase.from("board_members").delete().eq("id", deleteMember.id);
|
||||
if (error) toast({ variant: "destructive", title: "Error", description: error.message });
|
||||
else { toast({ title: "Deleted" }); fetchData(); }
|
||||
setDeleteOpen(false); setDeleteMember(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2"><Shield className="h-6 w-6 text-primary" /> Board Members</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage association board members and approval authorities.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<RecordImportButton
|
||||
title="Import Board Members"
|
||||
description="Upload a CSV or Excel file with board member records."
|
||||
expectedColumns={[
|
||||
{ key: "member_name", label: "Member Name", required: true },
|
||||
{ key: "member_email", label: "Email" },
|
||||
{ key: "phone", label: "Phone" },
|
||||
{ key: "role", label: "Role" },
|
||||
]}
|
||||
onImport={async (rows) => {
|
||||
const assocId = associations[0]?.id;
|
||||
if (!assocId) throw new Error("Create an association first");
|
||||
const payload = rows.map(r => ({
|
||||
member_name: r.member_name || "Unknown",
|
||||
member_email: r.member_email || null,
|
||||
phone: r.phone || null,
|
||||
role: r.role || "Member",
|
||||
association_id: assocId,
|
||||
}));
|
||||
const { error } = await supabase.from("board_members").insert(payload);
|
||||
if (error) throw error;
|
||||
toast({ title: `Imported ${rows.length} board members` });
|
||||
fetchData();
|
||||
}}
|
||||
templateFileName="board_members_template.xlsx"
|
||||
/>
|
||||
<Button onClick={openAdd} className="gap-2"><Plus className="h-4 w-4" /> Add Member</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col sm:flex-row gap-4 justify-between bg-muted/50 border-b">
|
||||
<CardTitle className="text-lg flex items-center gap-2"><Shield className="h-5 w-5 text-muted-foreground" /> Member Directory</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedAssoc} onValueChange={setSelectedAssoc}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Filter by Association" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Associations</SelectItem>
|
||||
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedAssoc !== "all" && <Button variant="ghost" size="icon" onClick={() => setSelectedAssoc("all")}><FilterX className="h-4 w-4" /></Button>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Association</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Authority</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center py-8"><Loader2 className="h-6 w-6 animate-spin mx-auto text-primary" /></TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No board members found.</TableCell></TableRow>
|
||||
) : filtered.map(m => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-medium">{m.member_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{m.member_email || "—"}</TableCell>
|
||||
<TableCell>{m.associations?.name || "—"}</TableCell>
|
||||
<TableCell><Badge variant="outline">{m.role || "Member"}</Badge></TableCell>
|
||||
<TableCell>
|
||||
{m.approval_authority
|
||||
? <Badge className="bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10 border-emerald-200">Approver</Badge>
|
||||
: <span className="text-xs text-muted-foreground">None</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(m)}><Edit className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => { setDeleteMember(m); setDeleteOpen(true); }}><Trash2 className="h-4 w-4" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit Dialog */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editingId ? "Edit" : "Add"} Board Member</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Association *</Label>
|
||||
<Select value={form.association_id} onValueChange={v => setForm({ ...form, association_id: v })}>
|
||||
<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-2"><Label>Name *</Label><Input value={form.member_name} onChange={e => setForm({ ...form, member_name: e.target.value })} /></div>
|
||||
<div className="space-y-2"><Label>Email</Label><Input type="email" value={form.member_email} onChange={e => setForm({ ...form, member_email: e.target.value })} /></div>
|
||||
<div className="space-y-2"><Label>Phone</Label><Input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="President">President</SelectItem>
|
||||
<SelectItem value="Vice President">Vice President</SelectItem>
|
||||
<SelectItem value="Treasurer">Treasurer</SelectItem>
|
||||
<SelectItem value="Secretary">Secretary</SelectItem>
|
||||
<SelectItem value="Member">Member</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={form.approval_authority} onCheckedChange={v => setForm({ ...form, approval_authority: v })} />
|
||||
<Label>Approval Authority</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />} {editingId ? "Update" : "Add"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle className="text-destructive">Confirm Deletion</DialogTitle></DialogHeader>
|
||||
<DialogDescription>Are you sure you want to delete {deleteMember?.member_name}? This cannot be undone.</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>Delete</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user