mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -94,6 +94,14 @@ export function BidQuoteDetailsDialog({ open, onOpenChange, bid, onRefresh }) {
|
|||||||
<div><span className="text-muted-foreground">Status:</span> {bid.status}</div>
|
<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>}
|
{bid.received_date && <div><span className="text-muted-foreground">Received:</span> {format(new Date(bid.received_date), 'MMM d, yyyy')}</div>}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
|
|||||||
const [associations, setAssociations] = useState([]);
|
const [associations, setAssociations] = useState([]);
|
||||||
const [selectedAssociations, setSelectedAssociations] = useState([]);
|
const [selectedAssociations, setSelectedAssociations] = useState([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [fileUploading, setFileUploading] = useState(false);
|
||||||
|
const [documentUrl, setDocumentUrl] = useState('');
|
||||||
|
const [documentName, setDocumentName] = useState('');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -55,9 +58,34 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
|
|||||||
fetchAssociations();
|
fetchAssociations();
|
||||||
form.reset();
|
form.reset();
|
||||||
setSelectedAssociations([]);
|
setSelectedAssociations([]);
|
||||||
|
setDocumentUrl('');
|
||||||
|
setDocumentName('');
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
}, [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 () => {
|
const fetchAssociations = async () => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -91,7 +119,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
|
|||||||
amount: values.amount || 0,
|
amount: values.amount || 0,
|
||||||
association_id: assocId,
|
association_id: assocId,
|
||||||
created_by: user?.id,
|
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);
|
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="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Assign to Associations</FormLabel>
|
<FormLabel>Assign to Associations</FormLabel>
|
||||||
@@ -232,7 +283,7 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
|
|||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={uploading}>
|
<Button type="submit" disabled={uploading || fileUploading}>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ export default function AccountingReportsPage({ association }: { association?: {
|
|||||||
const cid = companyId ?? "";
|
const cid = companyId ?? "";
|
||||||
const cur = "USD";
|
const cur = "USD";
|
||||||
const [active, setActive] = useState<ReportId>("pnl");
|
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
|
// Period
|
||||||
const [preset, setPreset] = useState<Preset>("ytd");
|
const [preset, setPreset] = useState<Preset>("ytd");
|
||||||
@@ -548,7 +552,7 @@ export default function AccountingReportsPage({ association }: { association?: {
|
|||||||
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||||
)}
|
)}
|
||||||
{active === "general-ledger" && (
|
{active === "general-ledger" && (
|
||||||
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} />
|
||||||
)}
|
)}
|
||||||
{active === "reserve-fund" && (
|
{active === "reserve-fund" && (
|
||||||
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
|
<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>
|
<Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading…</div></CardContent></Card>
|
||||||
) : structured ? (
|
) : structured ? (
|
||||||
<ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
<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>
|
</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>
|
<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;
|
report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
|
||||||
|
onDrill?: (accountId: string, label: string) => void;
|
||||||
}) {
|
}) {
|
||||||
let alt = false;
|
let alt = false;
|
||||||
const span = showCompare ? 5 : 2;
|
const span = showCompare ? 5 : 2;
|
||||||
@@ -666,14 +671,19 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
|
|||||||
const shaded = r.kind === "sub" && alt;
|
const shaded = r.kind === "sub" && alt;
|
||||||
if (r.kind === "sub") alt = !alt;
|
if (r.kind === "sub") alt = !alt;
|
||||||
const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
|
const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
|
||||||
|
const drillable = r.kind === "sub" && !!r.accountId && !!onDrill;
|
||||||
return (
|
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" : "",
|
shaded ? "bg-muted/40" : "",
|
||||||
bold ? "border-t font-semibold" : "",
|
bold ? "border-t font-semibold" : "",
|
||||||
|
drillable ? "cursor-pointer hover:bg-primary/5" : "",
|
||||||
].join(" ")}>
|
].join(" ")}>
|
||||||
<td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}>
|
<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>}
|
{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>
|
</td>
|
||||||
<AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
|
<AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
|
||||||
{showCompare && <AmountCell n={r.compare} />}
|
{showCompare && <AmountCell n={r.compare} />}
|
||||||
@@ -1036,6 +1046,7 @@ function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredRe
|
|||||||
rows.push({
|
rows.push({
|
||||||
kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
|
kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
|
||||||
compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined,
|
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
|
// Assets
|
||||||
rows.push({ kind: "section", label: "Assets" });
|
rows.push({ kind: "section", label: "Assets" });
|
||||||
const assets = byType("asset");
|
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);
|
const totalA = sumBal(assets);
|
||||||
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
|
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
|
||||||
rows.push({ kind: "spacer", label: "" });
|
rows.push({ kind: "spacer", label: "" });
|
||||||
@@ -1152,7 +1163,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
|
|||||||
// Liabilities
|
// Liabilities
|
||||||
rows.push({ kind: "section", label: "Liabilities" });
|
rows.push({ kind: "section", label: "Liabilities" });
|
||||||
const liabs = byType("liability");
|
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);
|
const totalL = sumBal(liabs);
|
||||||
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
|
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
|
||||||
rows.push({ kind: "spacer", label: "" });
|
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
|
// Equity — equity accounts + calculated RE / current-year earnings
|
||||||
rows.push({ kind: "section", label: "Equity" });
|
rows.push({ kind: "section", label: "Equity" });
|
||||||
const equityAccs = byType("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: "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) });
|
rows.push({ kind: "sub", label: "Current Year Earnings", amount: cur.cye, compare: cmp(prev?.cye) });
|
||||||
const totalE = sumBal(equityAccs) + cur.rePrior + cur.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 { useQuery } from "@tanstack/react-query";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -37,10 +37,15 @@ type Txn = {
|
|||||||
debit: number; credit: number; balance: number; abnormal?: boolean;
|
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 [from, setFrom] = useState(startOfYear());
|
||||||
const [to, setTo] = useState(today());
|
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 [basis, setBasis] = useState<"accrual" | "cash">("accrual");
|
||||||
const [showOpening, setShowOpening] = useState(true);
|
const [showOpening, setShowOpening] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type StructuredRow = {
|
|||||||
code?: string;
|
code?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
compare?: number;
|
compare?: number;
|
||||||
|
/** GL account id for per-account rows — enables drill-down from on-screen reports. */
|
||||||
|
accountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructuredReport = {
|
export type StructuredReport = {
|
||||||
|
|||||||
Reference in New Issue
Block a user