Bill Approvals: Buildium two-column form + require vendor on all backend payments

- Bill Approvals Create/Edit dialogs redesigned to the same professional
  two-column Buildium layout used on the accounting Bills page: left
  attachment panel (drag-drop + live image/PDF preview), right grouped form,
  prominent blue total bar, primary-action-first footer
- Accounting backend payments now require a vendor chosen from the dropdown:
  - Expenses: vendor is required; removed the free-text vendor fallback
  - Bills: must pick a 'Pay to' vendor before saving
  - Banking payments already enforced this
  (Reconciliation bank adjustments — interest/service charges — intentionally
   still allow no vendor, as they are not vendor payments)
- Board members remain locked to approving/denying their own assigned rows
  plus commenting and submitting invoices (per request); all bill edits, GL,
  line items, status, and deletes stay read-only for board users

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 00:14:13 -04:00
parent fbc5019730
commit dedcbb8889
3 changed files with 112 additions and 86 deletions
+106 -82
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import { UserCheck, Plus, Search, Eye, Upload, X, ArrowUpDown, Edit, Trash2, MoreHorizontal, AlertTriangle, Loader2, Bell, Printer, Sparkles, Download } from "lucide-react";
import { UserCheck, Plus, Search, Eye, Upload, X, ArrowUpDown, Edit, Trash2, MoreHorizontal, AlertTriangle, Loader2, Bell, Printer, Sparkles, Download, FileText, Save } from "lucide-react";
import { generateCheckPDF, type CheckData } from "@/pages/accounting/lib/checkPdf";
import { accounting } from "@/lib/accountingClient";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -99,6 +99,68 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [filePreview, setFilePreview] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
useEffect(() => {
if (uploadFile && uploadFile.type.startsWith("image/")) {
const url = URL.createObjectURL(uploadFile);
setFilePreview(url);
return () => URL.revokeObjectURL(url);
}
setFilePreview(null);
}, [uploadFile]);
const handleFile = (f: File) => setUploadFile(f);
// Buildium-style left attachment panel, shared by the Create and Edit dialogs.
const renderAttachmentPanel = (existingUrl?: string | null) => {
const isPdf = (existingUrl || "").toLowerCase().endsWith(".pdf");
const empty = !filePreview && !uploadFile && !existingUrl;
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
className={`relative rounded-lg border-2 border-dashed min-h-[420px] flex flex-col items-center justify-center text-center p-6 transition-colors ${dragOver ? "border-primary bg-primary/5" : "border-border"} ${empty ? "cursor-pointer hover:bg-muted/40" : ""}`}
onClick={() => { if (empty) fileRef.current?.click(); }}
>
<input ref={fileRef} type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
{filePreview ? (
<img src={filePreview} alt="Attachment" className="max-h-[400px] w-full object-contain rounded" />
) : uploadFile ? (
<div className="text-sm text-muted-foreground">
<FileText className="mx-auto h-12 w-12 mb-2 text-foreground/70" />
<div className="font-medium text-foreground">{uploadFile.name}</div>
<div className="text-xs">{(uploadFile.size / 1024).toFixed(1)} KB</div>
</div>
) : existingUrl ? (
isPdf ? (
<iframe src={`${existingUrl}#toolbar=1&navpanes=0`} className="w-full h-[400px] border-0 rounded" title="Attachment" />
) : (
<img src={existingUrl} alt="Attachment" className="max-h-[400px] w-full object-contain rounded" />
)
) : (
<>
<FileText className="h-12 w-12 text-muted-foreground/40" />
<div className="mt-3 font-medium">Upload &amp; view your attachment here</div>
<div className="text-xs text-muted-foreground mt-1">PDF, JPG, PNG · max 10MB</div>
<div className="text-xs text-muted-foreground mt-1">Drag &amp; drop or <span className="text-primary underline">browse</span></div>
</>
)}
{(uploadFile || existingUrl) && (
<div className="mt-3 flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
<Upload className="mr-1 h-3.5 w-3.5" /> Replace
</Button>
{uploadFile && (
<Button size="sm" variant="ghost" onClick={() => setUploadFile(null)}>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
</div>
);
};
const normalizeApprovalRecord = (record: any) => {
const linkedInvoice = record.source_invoice_id ? record.invoices : null;
@@ -1178,11 +1240,17 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
{/* Create Bill Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bill Details</DialogTitle>
<DialogContent className="max-w-5xl max-h-[92vh] overflow-hidden p-0 gap-0 flex flex-col">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>Record Bill</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
{/* ── Left: attachment panel ── */}
{renderAttachmentPanel(null)}
{/* ── Right: form ── */}
<div className="space-y-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Bill Details</h3>
{/* Client / Association */}
<div>
<Label>Client / Association <span className="text-destructive">*</span></Label>
@@ -1322,45 +1390,22 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
/>
</div>
{/* File Upload */}
<div>
<Label>Supporting Document (PDF)</Label>
<div
className="mt-1 border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors hover:bg-muted/50 border-border"
onClick={() => fileRef.current?.click()}
>
<input
ref={fileRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
/>
{uploadFile ? (
<div className="flex items-center justify-center gap-2">
<span className="text-sm font-medium text-foreground">{uploadFile.name}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setUploadFile(null); }}>
<X className="h-3 w-3" />
</Button>
</div>{/* right column */}
</div>{/* grid */}
</div>{/* scroll */}
{/* Total bar */}
<div className="bg-primary text-primary-foreground px-6 py-3 flex items-center justify-end text-sm font-medium">
Total bill amount:
<span className="ml-2 text-base font-semibold">
${Number(form.amount || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
</span>
</div>
) : (
<>
<div className="mx-auto mb-2 bg-primary/10 rounded-full p-3 w-fit">
<Upload className="h-5 w-5 text-primary" />
</div>
<p className="text-sm text-foreground font-medium">Click to upload or drag and drop</p>
<p className="text-xs text-muted-foreground mt-1">PDF, JPG, PNG (max 10MB)</p>
</>
)}
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>Cancel</Button>
<DialogFooter className="px-6 py-3 border-t sm:justify-start gap-2">
<Button onClick={handleCreate} disabled={submitting} className="gap-2">
{submitting ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" /> : <Plus className="h-4 w-4" />}
Create Bill
</Button>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitting}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1422,12 +1467,18 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
{/* Edit Bill Dialog */}
<Dialog open={editOpen} onOpenChange={(open) => { setEditOpen(open); if (!open) setEditingBill(null); }}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogContent className="max-w-5xl max-h-[92vh] overflow-hidden p-0 gap-0 flex flex-col">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>Edit Bill</DialogTitle>
<DialogDescription>Update the bill details below.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
{/* ── Left: attachment panel ── */}
{renderAttachmentPanel(editingBill?.attachment_url)}
{/* ── Right: form ── */}
<div className="space-y-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Bill Details</h3>
<div>
<Label>Client / Association <span className="text-destructive">*</span></Label>
<Select value={form.association_id} onValueChange={(v) => setForm({ ...form, association_id: v, expense_account_id: "" })}>
@@ -1502,49 +1553,22 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
{/* Attachment */}
<div>
<Label>Attachment (PDF / Image)</Label>
{editingBill?.attachment_url && !uploadFile && (
<div className="mt-1 mb-2 flex items-center gap-2 text-sm">
<a href={editingBill.attachment_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline truncate max-w-[300px]">
{decodeURIComponent(editingBill.attachment_url.split("/").pop() || "attachment")}
</a>
<Badge variant="outline" className="text-xs">Current</Badge>
</div>{/* right column */}
</div>{/* grid */}
</div>{/* scroll */}
{/* Total bar */}
<div className="bg-primary text-primary-foreground px-6 py-3 flex items-center justify-end text-sm font-medium">
Total bill amount:
<span className="ml-2 text-base font-semibold">
${Number(form.amount || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
</span>
</div>
)}
<div
className="mt-1 border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors hover:bg-muted/50 border-border"
onClick={() => fileRef.current?.click()}
>
<input
ref={fileRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
/>
{uploadFile ? (
<div className="flex items-center justify-center gap-2">
<span className="text-sm font-medium text-foreground">{uploadFile.name}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setUploadFile(null); }}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Upload className="h-4 w-4 mx-auto mb-1 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{editingBill?.attachment_url ? "Upload a replacement file" : "Upload an attachment"}</p>
</>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>Cancel</Button>
<Button onClick={handleUpdate} disabled={submitting}>
<DialogFooter className="px-6 py-3 border-t sm:justify-start gap-2">
<Button onClick={handleUpdate} disabled={submitting} className="gap-2">
{submitting ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" /> : <Save className="h-4 w-4" />}
{submitting ? "Saving..." : "Save Changes"}
</Button>
<Button variant="ghost" onClick={() => setEditOpen(false)} disabled={submitting}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -260,6 +260,7 @@ export default function AccountingBillsPage() {
const save = async (keepOpen = false) => {
if (!number.trim()) return toast.error("Bill number required");
if (!vendorId) return toast.error("Select a vendor (Pay to) before saving");
let attachmentUrl = uploadedUrl;
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
@@ -95,6 +95,7 @@ export default function AccountingExpensesPage() {
const save = async () => {
if (!category) return toast.error("Category is required");
if (!vendorId) return toast.error("Vendor is required — select one from the list");
if (!amount || amount <= 0) return toast.error("Amount must be greater than 0");
const selectedVendor = (vendors as any[]).find((v: any) => v.id === vendorId);
const { data: inserted, error } = await accounting.from("expenses").insert({
@@ -171,13 +172,13 @@ export default function AccountingExpensesPage() {
</Select>
</div>
<div>
<Label>Vendor</Label>
<Label>Vendor <span className="text-destructive">*</span></Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger><SelectValue placeholder="Select vendor (optional)" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
</Select>
{!vendorId && (
<Input className="mt-2" placeholder="Or type vendor name" maxLength={120} value={vendorName} onChange={(e) => setVendorName(e.target.value)} />
{(vendors as any[]).length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">No vendors yet add one on the Vendors page first.</p>
)}
</div>
<div>