Merge remote-tracking branch 'origin/main' into accounting-sales-receipts-coa-sync-expenses

# Conflicts:
#	src/integrations/supabase/types.ts
#	src/pages/BillDetailPage.tsx
This commit is contained in:
2026-06-06 23:24:45 -04:00
17 changed files with 789 additions and 84 deletions
@@ -60,6 +60,9 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId,
try {
await supabase.from('bills').update({ status: 'pending' }).eq('id', billId);
const { data: userData } = await supabase.auth.getUser();
const createdBy = userData?.user?.id ?? null;
const approvalRows = selectedMembers.map((memberId) => {
const bm = boardMembers.find(b => b.id === memberId);
return {
@@ -68,6 +71,7 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId,
approver_name: bm?.member_name || 'Approver',
status: 'pending',
notes: comment || null,
created_by: createdBy,
};
});
+48 -2
View File
@@ -8,6 +8,11 @@ import { supabase } from "@/integrations/supabase/client";
import { useNavigate } from "react-router-dom";
import { formatDistanceToNow } from "date-fns";
interface BillApprover {
name: string;
status: string;
}
interface PendingBill {
id: string;
source_invoice_id?: string | null;
@@ -17,6 +22,7 @@ interface PendingBill {
created_at: string;
associations?: { name: string } | null;
vendor_name?: string | null;
approvers: BillApprover[];
source: "bill" | "invoice";
}
@@ -41,7 +47,7 @@ type PendingBillDbRow = Omit<PendingBill, "source" | "vendor_name"> & {
created_at: string | null;
} | null;
};
type BillApprovalStatusRow = { bill_id: string | null; status: string | null };
type BillApprovalStatusRow = { bill_id: string | null; status: string | null; approver_name: string | null };
const fmtMoney = (n: number | null) =>
typeof n === "number"
@@ -83,9 +89,23 @@ const normalizePendingBill = (bill: PendingBillDbRow): PendingBill => ({
created_at: bill.created_at,
associations: bill.associations,
vendor_name: bill.invoices?.vendor_name ?? bill.vendors?.name ?? bill.notes ?? null,
approvers: [],
source: "bill",
});
const approverBadgeClass = (status: string) => {
switch (status) {
case "approved":
return "border-emerald-300 text-emerald-700 bg-emerald-50";
case "denied":
return "border-red-300 text-red-700 bg-red-50";
case "paid":
return "border-sky-300 text-sky-700 bg-sky-50";
default:
return "border-amber-300 text-amber-700 bg-amber-50";
}
};
export function BillApprovalsCard() {
const navigate = useNavigate();
const [tab, setTab] = useState<"approvals" | "inbox">("approvals");
@@ -115,7 +135,7 @@ export function BillApprovalsCard() {
// approved/denied). Those should not contribute to the badge.
supabase
.from("bill_approvals")
.select("bill_id, status")
.select("bill_id, status, approver_name")
.not("bill_id", "is", null),
]);
@@ -126,11 +146,20 @@ export function BillApprovalsCard() {
// Group approvals by bill_id and figure out which pending bills are
// truly actionable (have ≥1 pending approval, or none at all).
const approvalsByBill = new Map<string, string[]>();
const approversByBill = new Map<string, BillApprover[]>();
billApprovalStatusRows.forEach((a) => {
if (!a.bill_id) return;
const arr = approvalsByBill.get(a.bill_id) || [];
if (a.status) arr.push(a.status);
approvalsByBill.set(a.bill_id, arr);
const approvers = approversByBill.get(a.bill_id) || [];
approvers.push({ name: a.approver_name || "Unknown", status: a.status || "pending" });
approversByBill.set(a.bill_id, approvers);
});
collected.forEach((bill) => {
bill.approvers = approversByBill.get(bill.id) || [];
});
const stuckBillIds: string[] = [];
let actionablePendingBills = 0;
@@ -312,6 +341,23 @@ export function BillApprovalsCard() {
{formatDistanceToNow(new Date(bill.created_at), { addSuffix: true })}
</span>
</div>
{bill.approvers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1 mt-1">
{bill.approvers.map((ap, i) => (
<Badge
key={i}
variant="outline"
className={`text-2xs px-1.5 py-0 h-4 font-normal ${approverBadgeClass(ap.status)}`}
>
{ap.name}
</Badge>
))}
</div>
) : (
<p className="text-2xs italic text-muted-foreground mt-1">
No approvers requested
</p>
)}
</div>
</div>
))}
+3 -3
View File
@@ -1772,6 +1772,7 @@ export type Database = {
amount: number
approved_by: string | null
approved_date: string | null
approver_name: string
association_id: string
bill_id: string | null
created_at: string
@@ -1781,12 +1782,12 @@ export type Database = {
notes: string | null
status: string
updated_at: string
approver_name: string
}
Insert: {
amount?: number
approved_by?: string | null
approved_date?: string | null
approver_name: string
association_id: string
bill_id?: string | null
created_at?: string
@@ -1796,12 +1797,12 @@ export type Database = {
notes?: string | null
status?: string
updated_at?: string
approver_name: string
}
Update: {
amount?: number
approved_by?: string | null
approved_date?: string | null
approver_name?: string
association_id?: string
bill_id?: string | null
created_at?: string
@@ -1811,7 +1812,6 @@ export type Database = {
notes?: string | null
status?: string
updated_at?: string
approver_name?: string
}
Relationships: [
{
+1
View File
@@ -375,6 +375,7 @@ export default function AIInvoiceParserPage() {
invoice_id: invoice.id,
status: "pending",
notes: description || null,
created_by: userData?.user?.id || null,
};
});
await supabase.from("bill_approvals").insert(approvalRows);
+9 -54
View File
@@ -133,9 +133,14 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
const handleApprovalAction = async (approvalId: string, action: "approved" | "denied") => {
try {
const { data: userData } = await supabase.auth.getUser();
const { error } = await supabase
.from("bill_approvals")
.update({ status: action, approved_date: new Date().toISOString() })
.update({
status: action,
approved_date: new Date().toISOString(),
approved_by: userData?.user?.id ?? null,
})
.eq("id", approvalId);
if (error) throw error;
@@ -445,56 +450,6 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</CardContent>
</Card>
{/* Requested Approvers */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" /> Requested Approvers
</CardTitle>
</CardHeader>
<CardContent>
{approvals.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No specific approvers requested during creation. You can request one above.
</p>
) : (
<div className="space-y-3">
{approvals.map((a) => (
<div key={a.id} className="flex items-center justify-between border rounded-lg p-3">
<div className="flex-1">
<p className="text-sm font-medium">{a.approver_name}</p>
<p className="text-xs text-muted-foreground">
${Number(a.amount).toFixed(2)} · {new Date(a.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</p>
{a.notes && <p className="text-xs text-muted-foreground mt-1">{a.notes}</p>}
</div>
<div className="flex items-center gap-2">
<Badge className={statusColors[a.status] || "bg-muted text-muted-foreground"}>
{a.status.charAt(0).toUpperCase() + a.status.slice(1)}
</Badge>
{a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.approver_name) : true) && (
<>
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "approved")} className="h-7 px-2 text-xs gap-1">
<CheckCircle className="h-3 w-3" /> Approve
</Button>
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "denied")} className="h-7 px-2 text-xs gap-1 text-destructive">
<XCircle className="h-3 w-3" /> Deny
</Button>
</>
)}
{!boardAssociationIds && (
<Button size="sm" variant="ghost" onClick={() => handleDeleteApproval(a.id)} className="h-7 px-2 text-xs text-destructive">
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{(lineItems.length > 0 || canEditLineItems) && (
<Card>
<CardHeader>
@@ -674,17 +629,17 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</Card>
)}
{/* Approval History */}
{/* Approvers */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" /> Approval History
<Clock className="h-5 w-5 text-primary" /> Approvers
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{approvals.length === 0 ? (
<div className="px-6 pb-6 text-sm text-muted-foreground italic">
No approval records yet.
No approvers requested yet. Use the &ldquo;Request Approval&rdquo; button above to add one.
</div>
) : (
<Table>
@@ -52,26 +52,36 @@ export default function MasterBoardDashboardPage() {
.then(({ data }) => setAnnouncements(data || []));
}, [associationIds]);
// Pending bill approvals count for current user across assigned assocs
// Pending bill approvals count for current user across assigned assocs.
// bill_approvals doesn't store the approver's user_id directly — it stores
// the board member's display name (approver_name). We map auth.uid() to
// the board_member rows for the user, then match by member_name.
useEffect(() => {
if (!user || !associationIds.length) { setPendingBillCount(0); return; }
(async () => {
const billsQ: any = supabase
.from("bills")
.select("id")
.in("association_id", associationIds)
.in("status", ["pending", "pending_approval", "submitted"]);
const { data: bills } = await billsQ;
const [{ data: bills }, { data: myMemberships }] = await Promise.all([
supabase
.from("bills")
.select("id")
.in("association_id", associationIds)
.in("status", ["pending", "pending_approval", "submitted"]),
supabase
.from("board_members")
.select("member_name")
.eq("user_id", user.id)
.in("association_id", associationIds),
]);
const billIds = (bills || []).map((b: any) => b.id);
if (!billIds.length) { setPendingBillCount(0); return; }
const client: any = supabase;
const q = client
const myNames = Array.from(
new Set((myMemberships || []).map((m: any) => m.member_name).filter(Boolean))
);
if (!billIds.length || !myNames.length) { setPendingBillCount(0); return; }
const { count } = await supabase
.from("bill_approvals")
.select("id", { count: "exact", head: true })
.in("bill_id", billIds)
.eq("approver_user_id", user.id)
.in("approver_name", myNames)
.eq("status", "pending");
const { count } = await q;
setPendingBillCount(count ?? 0);
})();
}, [user, associationIds]);