Reports: account drill-down to GL; bids/quotes PDF attachments

- P&L and Balance Sheet account rows are now clickable and open the
  General Ledger filtered to that account (its transaction list for the COA).
  Adds StructuredRow.accountId, threaded from the report builders, with a
  clickable row in StructuredTable and an initialAccountId prop on
  GeneralLedgerReport.
- Bids/Quotes: PDF upload on the New Bid/Quote dialog (bid-attachments
  bucket + document_url/document_name columns), shown as a link on the
  bid Details dialog.

Migration applied: bids_quotes_pdf_attachments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 00:47:11 -04:00
parent c3a0682e57
commit c3d1d86b07
5 changed files with 90 additions and 13 deletions
+8
View File
@@ -94,6 +94,14 @@ export function BidQuoteDetailsDialog({ open, onOpenChange, bid, onRefresh }) {
<div><span className="text-muted-foreground">Status:</span> {bid.status}</div>
{bid.received_date && <div><span className="text-muted-foreground">Received:</span> {format(new Date(bid.received_date), 'MMM d, yyyy')}</div>}
</div>
{bid.document_url && (
<div className="mt-3 pt-3 border-t">
<a href={bid.document_url} target="_blank" rel="noreferrer"
className="text-sm text-primary underline">
📄 {bid.document_name || 'View attached PDF'}
</a>
</div>
)}
</div>
</div>
</ScrollArea>
+53 -2
View File
@@ -38,6 +38,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
const [associations, setAssociations] = useState([]);
const [selectedAssociations, setSelectedAssociations] = useState([]);
const [uploading, setUploading] = useState(false);
const [fileUploading, setFileUploading] = useState(false);
const [documentUrl, setDocumentUrl] = useState('');
const [documentName, setDocumentName] = useState('');
const { user } = useAuth();
const { toast } = useToast();
@@ -55,9 +58,34 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
fetchAssociations();
form.reset();
setSelectedAssociations([]);
setDocumentUrl('');
setDocumentName('');
}
}, [open, form]);
const handleFileUpload = async (e) => {
const f = e.target.files?.[0];
if (!f) return;
if (f.type !== 'application/pdf') {
toast({ variant: 'destructive', title: 'PDF only', description: 'Please upload a PDF file.' });
return;
}
setFileUploading(true);
try {
const path = `${user?.id || 'anon'}/${Date.now()}-${f.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const { error } = await supabase.storage.from('bid-attachments').upload(path, f, { upsert: true });
if (error) throw error;
const { data } = supabase.storage.from('bid-attachments').getPublicUrl(path);
setDocumentUrl(data.publicUrl);
setDocumentName(f.name);
toast({ title: 'PDF attached', description: f.name });
} catch (err) {
toast({ variant: 'destructive', title: 'Upload failed', description: err.message });
} finally {
setFileUploading(false);
}
};
const fetchAssociations = async () => {
try {
const { data, error } = await supabase
@@ -91,7 +119,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
amount: values.amount || 0,
association_id: assocId,
created_by: user?.id,
status: 'pending'
status: 'pending',
document_url: documentUrl || null,
document_name: documentName || null,
}));
const { error } = await supabase.from('bids_quotes').insert(inserts);
@@ -191,6 +221,27 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
)}
/>
<div className="space-y-2">
<FormLabel>Attachment (PDF)</FormLabel>
{documentName ? (
<div className="flex items-center gap-3 p-3 border rounded-md bg-muted/30">
<a href={documentUrl} target="_blank" rel="noreferrer" className="text-sm text-primary underline truncate flex-1">
{documentName}
</a>
<Button type="button" variant="ghost" size="sm" className="gap-1 text-destructive"
onClick={() => { setDocumentUrl(''); setDocumentName(''); }}>
<X className="h-4 w-4" /> Remove
</Button>
</div>
) : (
<label className="inline-flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer hover:bg-muted text-sm">
{fileUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{fileUploading ? 'Uploading…' : 'Upload PDF'}
<input type="file" accept="application/pdf" className="hidden" onChange={handleFileUpload} />
</label>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Assign to Associations</FormLabel>
@@ -232,7 +283,7 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={uploading}>
<Button type="submit" disabled={uploading || fileUploading}>
{uploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+19 -8
View File
@@ -202,6 +202,10 @@ export default function AccountingReportsPage({ association }: { association?: {
const cid = companyId ?? "";
const cur = "USD";
const [active, setActive] = useState<ReportId>("pnl");
// Drill-down: clicking an account amount in a financial report opens the
// General Ledger focused on that account (its transaction list for the COA).
const [drillAccountId, setDrillAccountId] = useState<string | null>(null);
const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); };
// Period
const [preset, setPreset] = useState<Preset>("ytd");
@@ -548,7 +552,7 @@ export default function AccountingReportsPage({ association }: { association?: {
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
)}
{active === "general-ledger" && (
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} />
)}
{active === "reserve-fund" && (
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
@@ -563,7 +567,7 @@ export default function AccountingReportsPage({ association }: { association?: {
<Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading</div></CardContent></Card>
) : structured ? (
<ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} />
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} onDrill={drillToAccount} />
</ReportSheet>
) : (
<Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
@@ -622,8 +626,9 @@ function Toggle({ id, checked, onChange, label, disabled }: { id: string; checke
);
}
function StructuredTable({ report, showCodes, showCompare, showZero, currency }: {
function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: {
report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
onDrill?: (accountId: string, label: string) => void;
}) {
let alt = false;
const span = showCompare ? 5 : 2;
@@ -666,14 +671,19 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
const shaded = r.kind === "sub" && alt;
if (r.kind === "sub") alt = !alt;
const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
const drillable = r.kind === "sub" && !!r.accountId && !!onDrill;
return (
<tr key={i} className={[
<tr key={i}
onClick={drillable ? () => onDrill!(r.accountId!, r.label) : undefined}
title={drillable ? "View transactions for this account" : undefined}
className={[
shaded ? "bg-muted/40" : "",
bold ? "border-t font-semibold" : "",
drillable ? "cursor-pointer hover:bg-primary/5" : "",
].join(" ")}>
<td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}>
{showCodes && r.code && <span className="text-xs text-muted-foreground mr-2 font-mono">{r.code}</span>}
{r.label}
<span className={drillable ? "text-primary underline-offset-2 hover:underline" : ""}>{r.label}</span>
</td>
<AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
{showCompare && <AmountCell n={r.compare} />}
@@ -1036,6 +1046,7 @@ function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredRe
rows.push({
kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined,
accountId: l.accountId,
});
}
};
@@ -1144,7 +1155,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
// Assets
rows.push({ kind: "section", label: "Assets" });
const assets = byType("asset");
for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
const totalA = sumBal(assets);
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
rows.push({ kind: "spacer", label: "" });
@@ -1152,7 +1163,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
// Liabilities
rows.push({ kind: "section", label: "Liabilities" });
const liabs = byType("liability");
for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
const totalL = sumBal(liabs);
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
rows.push({ kind: "spacer", label: "" });
@@ -1160,7 +1171,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
// Equity — equity accounts + calculated RE / current-year earnings
rows.push({ kind: "section", label: "Equity" });
const equityAccs = byType("equity");
for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) });
rows.push({ kind: "sub", label: "Current Year Earnings", amount: cur.cye, compare: cmp(prev?.cye) });
const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye;
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accounting } from "@/lib/accountingClient";
import { Card, CardContent } from "@/components/ui/card";
@@ -37,10 +37,15 @@ type Txn = {
debit: number; credit: number; balance: number; abnormal?: boolean;
};
export function GeneralLedgerReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAccountId }: { companyId: string; companyName: string; logoUrl?: string | null; initialAccountId?: string | null }) {
const [from, setFrom] = useState(startOfYear());
const [to, setTo] = useState(today());
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(initialAccountId ? [initialAccountId] : []);
// When opened via a report drill-down, focus the chosen account.
useEffect(() => {
if (initialAccountId) setSelectedAccounts([initialAccountId]);
}, [initialAccountId]);
const [basis, setBasis] = useState<"accrual" | "cash">("accrual");
const [showOpening, setShowOpening] = useState(true);
const [search, setSearch] = useState("");
+2
View File
@@ -9,6 +9,8 @@ export type StructuredRow = {
code?: string;
amount?: number;
compare?: number;
/** GL account id for per-account rows — enables drill-down from on-screen reports. */
accountId?: string;
};
export type StructuredReport = {