Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
+234
View File
@@ -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>
);
}