mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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>
|
||||
{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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user