Bids & Quotes: PDF attachment upload (board-accessible)

Adds a PDF upload to the bid/quote dialog (stored in the bid-attachments
bucket, saved to document_url/document_name) and shows the attachment in
the detail view. Board members with can_upload can attach PDFs — table
RLS and the storage bucket already permit it; only the UI was missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 20:53:19 -04:00
parent b18a9b9e78
commit c670ca7e0e
+45 -4
View File
@@ -25,15 +25,34 @@ export default function BidsQuotesPage({ boardAssociationIds, boardCanManage = f
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any>(null); const [editing, setEditing] = useState<any>(null);
const [selectedBid, setSelectedBid] = useState<any>(null); const [selectedBid, setSelectedBid] = useState<any>(null);
const [form, setForm] = useState({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); const [form, setForm] = useState({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "", document_url: "", document_name: "" });
const [uploading, setUploading] = useState(false);
const isBoardView = !!boardAssociationIds?.length; const isBoardView = !!boardAssociationIds?.length;
// Staff always manage; board members only when granted the upload permission. // Staff always manage; board members only when granted the upload permission.
const canManage = !isBoardView || boardCanManage; 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); }; 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(); }, []); useEffect(() => { fetchData(); }, []);
const openNew = () => { setEditing(null); setForm({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); setDialogOpen(true); }; const openNew = () => { setEditing(null); setForm({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "", document_url: "", document_name: "" }); setDialogOpen(true); };
const openEdit = (b: any) => { setEditing(b); setForm({ vendor_name: b.vendor_name, amount: b.amount?.toString() || "", description: b.description || "", status: b.status, received_date: b.received_date || "", expiry_date: b.expiry_date || "" }); setDialogOpen(true); }; const openEdit = (b: any) => { setEditing(b); setForm({ vendor_name: b.vendor_name, amount: b.amount?.toString() || "", description: b.description || "", status: b.status, received_date: b.received_date || "", expiry_date: b.expiry_date || "", document_url: b.document_url || "", document_name: b.document_name || "" }); setDialogOpen(true); };
const handleFileUpload = async (file?: File) => {
if (!file) return;
if (file.type !== "application/pdf") { toast({ variant: "destructive", title: "PDF only", description: "Please choose a PDF file." }); return; }
setUploading(true);
try {
const path = `${crypto.randomUUID()}.pdf`;
const { error } = await supabase.storage.from("bid-attachments").upload(path, file, { contentType: "application/pdf", upsert: false });
if (error) throw error;
const { data } = supabase.storage.from("bid-attachments").getPublicUrl(path);
setForm((f) => ({ ...f, document_url: data.publicUrl, document_name: file.name }));
toast({ title: "PDF uploaded" });
} catch (e: any) {
toast({ variant: "destructive", title: "Upload failed", description: e.message });
} finally {
setUploading(false);
}
};
const handleSave = async () => { const handleSave = async () => {
const payload = { ...form, amount: parseFloat(form.amount) || 0, received_date: form.received_date || null, expiry_date: form.expiry_date || null }; 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" }); } if (editing) { await supabase.from("bids_quotes").update(payload).eq("id", editing.id); toast({ title: "Updated" }); }
@@ -104,7 +123,22 @@ export default function BidsQuotesPage({ boardAssociationIds, boardCanManage = f
<div><Label>Status</Label><Select value={form.status} onValueChange={(v) => setForm({ ...form, status: v })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="pending">Pending</SelectItem><SelectItem value="accepted">Accepted</SelectItem><SelectItem value="rejected">Rejected</SelectItem><SelectItem value="expired">Expired</SelectItem></SelectContent></Select></div></div> <div><Label>Status</Label><Select value={form.status} onValueChange={(v) => setForm({ ...form, status: v })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="pending">Pending</SelectItem><SelectItem value="accepted">Accepted</SelectItem><SelectItem value="rejected">Rejected</SelectItem><SelectItem value="expired">Expired</SelectItem></SelectContent></Select></div></div>
<div className="grid grid-cols-2 gap-4"><div><Label>Received</Label><Input type="date" value={form.received_date} onChange={(e) => setForm({ ...form, received_date: e.target.value })} /></div><div><Label>Expires</Label><Input type="date" value={form.expiry_date} onChange={(e) => setForm({ ...form, expiry_date: e.target.value })} /></div></div> <div className="grid grid-cols-2 gap-4"><div><Label>Received</Label><Input type="date" value={form.received_date} onChange={(e) => setForm({ ...form, received_date: e.target.value })} /></div><div><Label>Expires</Label><Input type="date" value={form.expiry_date} onChange={(e) => setForm({ ...form, expiry_date: e.target.value })} /></div></div>
<div><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div> <div><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
</div><DialogFooter><Button onClick={handleSave}>{editing ? "Update" : "Create"}</Button></DialogFooter></DialogContent></Dialog> <div>
<Label>Attachment (PDF)</Label>
{form.document_url ? (
<div className="flex items-center justify-between gap-2 rounded-md border bg-muted/30 p-2 mt-1">
<a href={form.document_url} target="_blank" rel="noopener noreferrer" className="text-sm text-primary underline truncate">
{form.document_name || "View PDF"}
</a>
<Button type="button" variant="ghost" size="sm" onClick={() => setForm({ ...form, document_url: "", document_name: "" })}>Remove</Button>
</div>
) : (
<Input type="file" accept="application/pdf" className="mt-1" disabled={uploading}
onChange={(e) => handleFileUpload(e.target.files?.[0])} />
)}
{uploading && <p className="text-xs text-muted-foreground mt-1">Uploading</p>}
</div>
</div><DialogFooter><Button onClick={handleSave} disabled={uploading}>{editing ? "Update" : "Create"}</Button></DialogFooter></DialogContent></Dialog>
{/* Detail/Vote Dialog */} {/* Detail/Vote Dialog */}
<Dialog open={!!selectedBid} onOpenChange={(open) => !open && setSelectedBid(null)}> <Dialog open={!!selectedBid} onOpenChange={(open) => !open && setSelectedBid(null)}>
@@ -121,6 +155,13 @@ export default function BidsQuotesPage({ boardAssociationIds, boardCanManage = f
{selectedBid.description && ( {selectedBid.description && (
<div><p className="text-sm text-muted-foreground">Description</p><p className="bg-muted p-3 rounded-md mt-1 border whitespace-pre-wrap">{selectedBid.description}</p></div> <div><p className="text-sm text-muted-foreground">Description</p><p className="bg-muted p-3 rounded-md mt-1 border whitespace-pre-wrap">{selectedBid.description}</p></div>
)} )}
{selectedBid.document_url && (
<div><p className="text-sm text-muted-foreground">Attachment</p>
<a href={selectedBid.document_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-sm text-primary underline mt-1">
{selectedBid.document_name || "View PDF"}
</a>
</div>
)}
<VotingAndComments entityType="bid_quote" entityId={selectedBid.id} /> <VotingAndComments entityType="bid_quote" entityId={selectedBid.id} />
</div> </div>
)} )}