mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
+106
-82
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
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 { generateCheckPDF, type CheckData } from "@/pages/accounting/lib/checkPdf";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
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 [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
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 & 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 & 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 normalizeApprovalRecord = (record: any) => {
|
||||||
const linkedInvoice = record.source_invoice_id ? record.invoices : null;
|
const linkedInvoice = record.source_invoice_id ? record.invoices : null;
|
||||||
@@ -1178,11 +1240,17 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
|
|
||||||
{/* Create Bill Dialog */}
|
{/* Create Bill Dialog */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-5xl max-h-[92vh] overflow-hidden p-0 gap-0 flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader className="px-6 py-4 border-b">
|
||||||
<DialogTitle>Bill Details</DialogTitle>
|
<DialogTitle>Record Bill</DialogTitle>
|
||||||
</DialogHeader>
|
</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 */}
|
{/* Client / Association */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Client / Association <span className="text-destructive">*</span></Label>
|
<Label>Client / Association <span className="text-destructive">*</span></Label>
|
||||||
@@ -1322,45 +1390,22 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Upload */}
|
</div>{/* right column */}
|
||||||
<div>
|
</div>{/* grid */}
|
||||||
<Label>Supporting Document (PDF)</Label>
|
</div>{/* scroll */}
|
||||||
<div
|
{/* Total bar */}
|
||||||
className="mt-1 border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors hover:bg-muted/50 border-border"
|
<div className="bg-primary text-primary-foreground px-6 py-3 flex items-center justify-end text-sm font-medium">
|
||||||
onClick={() => fileRef.current?.click()}
|
Total bill amount:
|
||||||
>
|
<span className="ml-2 text-base font-semibold">
|
||||||
<input
|
${Number(form.amount || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||||||
ref={fileRef}
|
</span>
|
||||||
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>
|
</div>
|
||||||
) : (
|
<DialogFooter className="px-6 py-3 border-t sm:justify-start gap-2">
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
<Button onClick={handleCreate} disabled={submitting} className="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" />}
|
{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
|
Create Bill
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitting}>Cancel</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -1422,12 +1467,18 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
|
|
||||||
{/* Edit Bill Dialog */}
|
{/* Edit Bill Dialog */}
|
||||||
<Dialog open={editOpen} onOpenChange={(open) => { setEditOpen(open); if (!open) setEditingBill(null); }}>
|
<Dialog open={editOpen} onOpenChange={(open) => { setEditOpen(open); if (!open) setEditingBill(null); }}>
|
||||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-5xl max-h-[92vh] overflow-hidden p-0 gap-0 flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader className="px-6 py-4 border-b">
|
||||||
<DialogTitle>Edit Bill</DialogTitle>
|
<DialogTitle>Edit Bill</DialogTitle>
|
||||||
<DialogDescription>Update the bill details below.</DialogDescription>
|
<DialogDescription>Update the bill details below.</DialogDescription>
|
||||||
</DialogHeader>
|
</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>
|
<div>
|
||||||
<Label>Client / Association <span className="text-destructive">*</span></Label>
|
<Label>Client / Association <span className="text-destructive">*</span></Label>
|
||||||
<Select value={form.association_id} onValueChange={(v) => setForm({ ...form, association_id: v, expense_account_id: "" })}>
|
<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 })} />
|
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachment */}
|
</div>{/* right column */}
|
||||||
<div>
|
</div>{/* grid */}
|
||||||
<Label>Attachment (PDF / Image)</Label>
|
</div>{/* scroll */}
|
||||||
{editingBill?.attachment_url && !uploadFile && (
|
{/* Total bar */}
|
||||||
<div className="mt-1 mb-2 flex items-center gap-2 text-sm">
|
<div className="bg-primary text-primary-foreground px-6 py-3 flex items-center justify-end text-sm font-medium">
|
||||||
<a href={editingBill.attachment_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline truncate max-w-[300px]">
|
Total bill amount:
|
||||||
{decodeURIComponent(editingBill.attachment_url.split("/").pop() || "attachment")}
|
<span className="ml-2 text-base font-semibold">
|
||||||
</a>
|
${Number(form.amount || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||||||
<Badge variant="outline" className="text-xs">Current</Badge>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<DialogFooter className="px-6 py-3 border-t sm:justify-start gap-2">
|
||||||
<div
|
<Button onClick={handleUpdate} disabled={submitting} className="gap-2">
|
||||||
className="mt-1 border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors hover:bg-muted/50 border-border"
|
{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" />}
|
||||||
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}>
|
|
||||||
{submitting ? "Saving..." : "Save Changes"}
|
{submitting ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setEditOpen(false)} disabled={submitting}>Cancel</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export default function AccountingBillsPage() {
|
|||||||
|
|
||||||
const save = async (keepOpen = false) => {
|
const save = async (keepOpen = false) => {
|
||||||
if (!number.trim()) return toast.error("Bill number required");
|
if (!number.trim()) return toast.error("Bill number required");
|
||||||
|
if (!vendorId) return toast.error("Select a vendor (Pay to) before saving");
|
||||||
let attachmentUrl = uploadedUrl;
|
let attachmentUrl = uploadedUrl;
|
||||||
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export default function AccountingExpensesPage() {
|
|||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!category) return toast.error("Category is required");
|
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");
|
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 selectedVendor = (vendors as any[]).find((v: any) => v.id === vendorId);
|
||||||
const { data: inserted, error } = await accounting.from("expenses").insert({
|
const { data: inserted, error } = await accounting.from("expenses").insert({
|
||||||
@@ -171,13 +172,13 @@ export default function AccountingExpensesPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Vendor</Label>
|
<Label>Vendor <span className="text-destructive">*</span></Label>
|
||||||
<Select value={vendorId} onValueChange={setVendorId}>
|
<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>
|
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{!vendorId && (
|
{(vendors as any[]).length === 0 && (
|
||||||
<Input className="mt-2" placeholder="Or type vendor name" maxLength={120} value={vendorName} onChange={(e) => setVendorName(e.target.value)} />
|
<p className="mt-1 text-xs text-muted-foreground">No vendors yet — add one on the Vendors page first.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user