mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
a3a0b706a1
Add a "Allow document & bid/quote uploads" toggle on board member profiles (board_members.can_upload). When enabled, that board member can upload association documents and create/manage bids & quotes for their association(s); otherwise the board portal stays read-only for them. - Migration (prod): board_members.can_upload column; tighten the documents insert + storage 'files' upload policies to require can_upload; add a bids_quotes board policy gated on can_upload. - BoardMembersPage: permission switch (load/save). - BoardAssociationContext: expose canUpload for the selected association. - DocumentsPage: board upload gated by the flag (was always-on for board). - BidsQuotesPage: permitted board members can add/manage bids (was hidden); board inserts target the board's association. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
13 KiB
TypeScript
240 lines
13 KiB
TypeScript
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; can_upload: boolean;
|
|
created_at: string; associations?: { name: string } | null;
|
|
};
|
|
|
|
const emptyForm = { member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, can_upload: 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, can_upload: m.can_upload ?? false, 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,
|
|
can_upload: form.can_upload,
|
|
};
|
|
const { error } = editingId
|
|
? await supabase.from("board_members").update(payload as any).eq("id", editingId)
|
|
: await supabase.from("board_members").insert(payload as any);
|
|
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 className="flex items-center gap-3">
|
|
<Switch checked={form.can_upload} onCheckedChange={v => setForm({ ...form, can_upload: v })} />
|
|
<Label>Allow document & bid/quote uploads</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>
|
|
);
|
|
}
|