mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Board-member upload permission for documents & bids/quotes
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>
This commit is contained in:
@@ -14,6 +14,10 @@ interface BoardAssociationContextType {
|
||||
/** For backward compat: returns array with just the selected ID, or all if none selected */
|
||||
associationIds: string[];
|
||||
loading: boolean;
|
||||
/** Whether this board member may upload docs / bids for the selected association. */
|
||||
canUpload: boolean;
|
||||
/** Per-association upload permission map. */
|
||||
canUploadByAssociation: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const BoardAssociationContext = createContext<BoardAssociationContextType>({
|
||||
@@ -22,18 +26,22 @@ const BoardAssociationContext = createContext<BoardAssociationContextType>({
|
||||
setSelectedAssociationId: () => {},
|
||||
associationIds: [],
|
||||
loading: true,
|
||||
canUpload: false,
|
||||
canUploadByAssociation: {},
|
||||
});
|
||||
|
||||
export function BoardAssociationProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const [associations, setAssociations] = useState<BoardAssociation[]>([]);
|
||||
const [selectedAssociationId, setSelectedAssociationId] = useState<string | null>(null);
|
||||
const [uploadByAssoc, setUploadByAssoc] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setAssociations([]);
|
||||
setSelectedAssociationId(null);
|
||||
setUploadByAssoc({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -45,12 +53,12 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
|
||||
const [bmByIdRes, bmByEmailRes, mbaRes] = await Promise.all([
|
||||
supabase
|
||||
.from("board_members")
|
||||
.select("association_id, associations(name)")
|
||||
.select("association_id, associations(name), can_upload")
|
||||
.eq("user_id", user.id),
|
||||
email
|
||||
? supabase
|
||||
.from("board_members")
|
||||
.select("association_id, associations(name), email")
|
||||
.select("association_id, associations(name), email, can_upload")
|
||||
.ilike("email", email)
|
||||
: Promise.resolve({ data: [] as any[] }),
|
||||
(supabase as any)
|
||||
@@ -76,6 +84,18 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
|
||||
const items = Array.from(merged.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
setAssociations(items);
|
||||
|
||||
// Per-association upload permission (board_members.can_upload). Master-board
|
||||
// assignments are elevated and always permitted.
|
||||
const uploadMap: Record<string, boolean> = {};
|
||||
for (const r of [...((bmByIdRes as any).data || []), ...((bmByEmailRes as any).data || [])]) {
|
||||
if (!r?.association_id) continue;
|
||||
uploadMap[r.association_id] = uploadMap[r.association_id] || !!r.can_upload;
|
||||
}
|
||||
for (const r of ((mbaRes as any).data || [])) {
|
||||
if (r?.association_id) uploadMap[r.association_id] = true;
|
||||
}
|
||||
setUploadByAssoc(uploadMap);
|
||||
|
||||
const savedId = localStorage.getItem("board_selected_association");
|
||||
if (savedId && items.some((a) => a.id === savedId)) {
|
||||
setSelectedAssociationId(savedId);
|
||||
@@ -95,10 +115,13 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
|
||||
|
||||
// Filter to just the selected association
|
||||
const associationIds = selectedAssociationId ? [selectedAssociationId] : associations.map((a) => a.id);
|
||||
const canUpload = selectedAssociationId
|
||||
? !!uploadByAssoc[selectedAssociationId]
|
||||
: Object.values(uploadByAssoc).some(Boolean);
|
||||
|
||||
return (
|
||||
<BoardAssociationContext.Provider
|
||||
value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading }}
|
||||
value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading, canUpload, canUploadByAssociation: uploadByAssoc }}
|
||||
>
|
||||
{children}
|
||||
</BoardAssociationContext.Provider>
|
||||
|
||||
@@ -17,7 +17,7 @@ import VotingAndComments from "@/components/shared/VotingAndComments";
|
||||
|
||||
const statusColors: Record<string, string> = { pending: "bg-amber-100 text-amber-700", accepted: "bg-emerald-100 text-emerald-700", rejected: "bg-red-100 text-red-700", expired: "bg-muted text-muted-foreground" };
|
||||
|
||||
export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
|
||||
export default function BidsQuotesPage({ boardAssociationIds, boardCanManage = false }: { boardAssociationIds?: string[]; boardCanManage?: boolean } = {}) {
|
||||
const { toast } = useToast();
|
||||
const [bids, setBids] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -28,6 +28,8 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
|
||||
const [form, setForm] = useState({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" });
|
||||
|
||||
const isBoardView = !!boardAssociationIds?.length;
|
||||
// Staff always manage; board members only when granted the upload permission.
|
||||
const canManage = !isBoardView || boardCanManage;
|
||||
const fetchData = async () => { setLoading(true); let query = supabase.from("bids_quotes").select("*, associations(name)").order("created_at", { ascending: false }); if (isBoardView) query = query.in("association_id", boardAssociationIds!); const { data } = await query; setBids(data || []); setLoading(false); };
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
const openNew = () => { setEditing(null); setForm({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); setDialogOpen(true); };
|
||||
@@ -35,7 +37,14 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
|
||||
const handleSave = async () => {
|
||||
const payload = { ...form, amount: parseFloat(form.amount) || 0, received_date: form.received_date || null, expiry_date: form.expiry_date || null };
|
||||
if (editing) { await supabase.from("bids_quotes").update(payload).eq("id", editing.id); toast({ title: "Updated" }); }
|
||||
else { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); if (!a?.length) return; await supabase.from("bids_quotes").insert({ ...payload, association_id: a[0].id }); toast({ title: "Bid created" }); }
|
||||
else {
|
||||
let targetAssoc = boardAssociationIds?.[0];
|
||||
if (!targetAssoc) { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); targetAssoc = a?.[0]?.id; }
|
||||
if (!targetAssoc) return;
|
||||
const { error } = await supabase.from("bids_quotes").insert({ ...payload, association_id: targetAssoc });
|
||||
if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); return; }
|
||||
toast({ title: "Bid created" });
|
||||
}
|
||||
setDialogOpen(false); fetchData();
|
||||
};
|
||||
const handleDelete = async (id: string) => { await supabase.from("bids_quotes").delete().eq("id", id); toast({ title: "Deleted" }); fetchData(); };
|
||||
@@ -45,7 +54,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div><h1 className="text-2xl font-bold text-foreground flex items-center gap-2"><Scale className="h-6 w-6 text-primary" /> Bids & Quotes</h1><p className="text-sm text-muted-foreground mt-1">Manage vendor bids and quotes.</p></div>
|
||||
{!isBoardView && <div className="flex gap-2">
|
||||
{canManage && <div className="flex gap-2">
|
||||
<RecordImportButton
|
||||
title="Import Bids & Quotes"
|
||||
description="Upload a CSV or Excel file with bid/quote records."
|
||||
@@ -58,8 +67,9 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
|
||||
{ key: "expiry_date", label: "Expiry Date" },
|
||||
]}
|
||||
onImport={async (rows) => {
|
||||
const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1);
|
||||
if (!a?.length) throw new Error("Create an association first");
|
||||
let targetAssoc = boardAssociationIds?.[0];
|
||||
if (!targetAssoc) { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); targetAssoc = a?.[0]?.id; }
|
||||
if (!targetAssoc) throw new Error("Create an association first");
|
||||
const payload = rows.map(r => ({
|
||||
vendor_name: r.vendor_name || "Unknown",
|
||||
amount: parseFloat(r.amount) || 0,
|
||||
@@ -67,7 +77,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
|
||||
status: r.status || "pending",
|
||||
received_date: r.received_date || null,
|
||||
expiry_date: r.expiry_date || null,
|
||||
association_id: a[0].id,
|
||||
association_id: targetAssoc,
|
||||
}));
|
||||
const { error } = await supabase.from("bids_quotes").insert(payload);
|
||||
if (error) throw error;
|
||||
@@ -82,7 +92,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
|
||||
<div className="relative max-w-md"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search..." className="pl-10" value={search} onChange={(e) => setSearch(e.target.value)} /></div>
|
||||
{loading ? <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div> : filtered.length === 0 ? <Card><CardContent className="py-12 text-center text-muted-foreground">No bids found.</CardContent></Card> : (
|
||||
<Card><Table><TableHeader><TableRow><TableHead>Vendor</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead><TableHead>Received</TableHead><TableHead>Expires</TableHead><TableHead className="w-12" /></TableRow></TableHeader><TableBody>
|
||||
{filtered.map((b) => (<TableRow key={b.id} className="cursor-pointer" onClick={() => setSelectedBid(b)}><TableCell className="font-medium">{b.vendor_name}</TableCell><TableCell>${Number(b.amount).toLocaleString()}</TableCell><TableCell><Badge className={statusColors[b.status] || "bg-muted text-muted-foreground"}>{b.status}</Badge></TableCell><TableCell>{b.received_date || "—"}</TableCell><TableCell>{b.expiry_date || "—"}</TableCell>{!isBoardView && <TableCell>
|
||||
{filtered.map((b) => (<TableRow key={b.id} className="cursor-pointer" onClick={() => setSelectedBid(b)}><TableCell className="font-medium">{b.vendor_name}</TableCell><TableCell>${Number(b.amount).toLocaleString()}</TableCell><TableCell><Badge className={statusColors[b.status] || "bg-muted text-muted-foreground"}>{b.status}</Badge></TableCell><TableCell>{b.received_date || "—"}</TableCell><TableCell>{b.expiry_date || "—"}</TableCell>{canManage && <TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEdit(b); }}><Edit className="h-4 w-4 mr-2" /> Edit</DropdownMenuItem><DropdownMenuItem className="text-destructive" onClick={(e) => { e.stopPropagation(); handleDelete(b.id); }}><Trash2 className="h-4 w-4 mr-2" /> Delete</DropdownMenuItem></DropdownMenuContent></DropdownMenu></TableCell>}</TableRow>))}</TableBody></Table></Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ 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;
|
||||
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, association_id: "" };
|
||||
const emptyForm = { member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, can_upload: false, association_id: "" };
|
||||
|
||||
export default function BoardMembersPage() {
|
||||
const { toast } = useToast();
|
||||
@@ -55,7 +55,7 @@ export default function BoardMembersPage() {
|
||||
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 });
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -68,10 +68,11 @@ export default function BoardMembersPage() {
|
||||
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).eq("id", editingId)
|
||||
: await supabase.from("board_members").insert(payload);
|
||||
? 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);
|
||||
@@ -208,6 +209,10 @@ export default function BoardMembersPage() {
|
||||
<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>
|
||||
|
||||
@@ -236,9 +236,12 @@ function FolderSidebar({
|
||||
export default function DocumentsPage({
|
||||
boardAssociationIds,
|
||||
viewerRole = "admin",
|
||||
boardCanUpload = false,
|
||||
}: {
|
||||
boardAssociationIds?: string[];
|
||||
viewerRole?: "admin" | "board" | "homeowner";
|
||||
/** For board viewers: whether this board member is permitted to upload. */
|
||||
boardCanUpload?: boolean;
|
||||
} = {}) {
|
||||
const { toast } = useToast();
|
||||
const { isAdmin } = useAuth();
|
||||
@@ -372,8 +375,9 @@ export default function DocumentsPage({
|
||||
const isBoardView = viewerRole === "board" || (!!boardAssociationIds?.length && viewerRole !== "homeowner");
|
||||
const isHomeownerView = viewerRole === "homeowner";
|
||||
const isReadOnlyView = isBoardView || isHomeownerView;
|
||||
// Board members can upload documents within their association(s); homeowners remain read-only.
|
||||
const canUpload = !isHomeownerView;
|
||||
// Board members can upload only when granted the per-member permission;
|
||||
// homeowners are always read-only; staff can always upload.
|
||||
const canUpload = isHomeownerView ? false : isBoardView ? boardCanUpload : true;
|
||||
|
||||
const getFolderAssociation = (folder: string | null) => {
|
||||
if (!folder) return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
||||
import BidsQuotesPage from "@/pages/BidsQuotesPage";
|
||||
export default function BoardBidsQuotesPage() {
|
||||
const { associationIds, loading } = useBoardAssociations();
|
||||
const { associationIds, loading, canUpload } = useBoardAssociations();
|
||||
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
|
||||
return <BidsQuotesPage boardAssociationIds={associationIds} />;
|
||||
return <BidsQuotesPage boardAssociationIds={associationIds} boardCanManage={canUpload} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
||||
import DocumentsPage from "@/pages/DocumentsPage";
|
||||
export default function BoardDocumentsPage() {
|
||||
const { associationIds, loading } = useBoardAssociations();
|
||||
const { associationIds, loading, canUpload } = useBoardAssociations();
|
||||
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
|
||||
return <DocumentsPage boardAssociationIds={associationIds} viewerRole="board" />;
|
||||
return <DocumentsPage boardAssociationIds={associationIds} viewerRole="board" boardCanUpload={canUpload} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user