Budget Workbook: certify-as-approved + preliminary disclaimer

Add a 'Certify as Approved' toggle (accounting.budget_workbooks.certified
/certified_at/certified_by). Until certified, show a bold red note that
the projections are preliminary and not subject to annual increases or
inflation; once certified, show an approved/certified banner. Both states
render in the PDF export too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 20:03:40 -04:00
parent 422b828cdb
commit 7aedc6d90d
+69 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import autoTable from "jspdf-autotable"; import autoTable from "jspdf-autotable";
import { Building2, RefreshCw, Save, Upload, Download, FileText, Loader2, RotateCcw, ChevronLeft, ChevronRight } from "lucide-react"; import { Building2, RefreshCw, Save, Upload, Download, FileText, Loader2, RotateCcw, ChevronLeft, ChevronRight, CheckCircle2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAssociation } from "@/contexts/AssociationContext"; import { useAssociation } from "@/contexts/AssociationContext";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
@@ -59,6 +59,9 @@ export default function BudgetWorkbookPage() {
const [unitOv, setUnitOv] = useState(""); // "" = use the live unit count const [unitOv, setUnitOv] = useState(""); // "" = use the live unit count
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [certifying, setCertifying] = useState(false);
const [certified, setCertified] = useState(false);
const [certifiedAt, setCertifiedAt] = useState<string | null>(null);
const sortedAssoc = useMemo( const sortedAssoc = useMemo(
() => [...(associations ?? [])].sort((a: any, b: any) => a.name.localeCompare(b.name)), () => [...(associations ?? [])].sort((a: any, b: any) => a.name.localeCompare(b.name)),
@@ -126,8 +129,41 @@ export default function BudgetWorkbookPage() {
setYtdOv(y); setInfl(i); setProjOv(p); setYtdOv(y); setInfl(i); setProjOv(p);
if ((workbook.head as any)?.through_month) setThrough((workbook.head as any).through_month); if ((workbook.head as any)?.through_month) setThrough((workbook.head as any).through_month);
setUnitOv((workbook.head as any)?.unit_override != null ? String((workbook.head as any).unit_override) : ""); setUnitOv((workbook.head as any)?.unit_override != null ? String((workbook.head as any).unit_override) : "");
setCertified(!!(workbook.head as any)?.certified);
setCertifiedAt((workbook.head as any)?.certified_at ?? null);
}, [workbook]); }, [workbook]);
// Certify the workbook as the board-approved budget (or revert to preliminary).
const toggleCertify = async () => {
if (!cid) return;
const next = !certified;
if (next && !confirm(`Certify the FY${fy} budget as approved? This marks the projections as the board-approved budget and removes the preliminary disclaimer.`)) return;
setCertifying(true);
try {
const { data: u } = await supabase.auth.getUser();
const { error } = await accounting.from("budget_workbooks")
.upsert({
company_id: cid, fiscal_year: fy, through_month: through,
unit_override: unitOv.trim() ? Math.round(Number(unitOv)) : null,
certified: next,
certified_at: next ? new Date().toISOString() : null,
certified_by: next ? (u?.user?.id ?? null) : null,
updated_at: new Date().toISOString(),
}, { onConflict: "company_id,fiscal_year" });
if (error) throw new Error(error.message);
setCertified(next);
setCertifiedAt(next ? new Date().toISOString() : null);
toast.success(next ? "Budget certified as approved" : "Reverted to preliminary");
qc.invalidateQueries({ queryKey: ["wb-state", cid, fy] });
} catch (e: any) {
toast.error(e.message || "Failed to update certification");
} finally {
setCertifying(false);
}
};
const PRELIM_NOTE = "These projections are for preliminary purposes only and are not subject to annual increases or inflation.";
// Computed YTD actual per account from the GL // Computed YTD actual per account from the GL
const ytdComputed = useMemo(() => { const ytdComputed = useMemo(() => {
const typeById = new Map<string, string>((accounts as any[]).map((a) => [a.id, a.type])); const typeById = new Map<string, string>((accounts as any[]).map((a) => [a.id, a.type]));
@@ -267,6 +303,23 @@ export default function BudgetWorkbookPage() {
let y = 100; let y = 100;
// Certification status: bold red preliminary disclaimer until approved.
if (certified) {
doc.setTextColor(22, 101, 52);
doc.setFontSize(10);
doc.setFont("helvetica", "bold");
doc.text(`APPROVED BUDGET${certifiedAt ? ` — Certified ${new Date(certifiedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}` : ""}`, margin, y);
y += 22;
} else {
doc.setTextColor(220, 38, 38);
doc.setFontSize(9.5);
doc.setFont("helvetica", "bold");
const lines = doc.splitTextToSize(PRELIM_NOTE, contentW);
doc.text(lines, margin, y);
y += lines.length * 13 + 10;
}
doc.setTextColor(30, 30, 30);
const sectionTable = (title: string, rows: any[], total: number) => { const sectionTable = (title: string, rows: any[], total: number) => {
autoTable(doc, { autoTable(doc, {
startY: y, startY: y,
@@ -477,10 +530,25 @@ export default function BudgetWorkbookPage() {
<Button variant="outline" size="sm" onClick={exportPdf}><FileText className="h-4 w-4 mr-1" /> PDF</Button> <Button variant="outline" size="sm" onClick={exportPdf}><FileText className="h-4 w-4 mr-1" /> PDF</Button>
<Button variant="outline" size="sm" onClick={saveWorkbook} disabled={saving}><Save className="h-4 w-4 mr-1" /> {saving ? "Saving…" : "Save"}</Button> <Button variant="outline" size="sm" onClick={saveWorkbook} disabled={saving}><Save className="h-4 w-4 mr-1" /> {saving ? "Saving…" : "Save"}</Button>
<Button size="sm" onClick={pushToBudget} disabled={pushing}><Upload className="h-4 w-4 mr-1" /> {pushing ? "Pushing…" : "Push to Budget"}</Button> <Button size="sm" onClick={pushToBudget} disabled={pushing}><Upload className="h-4 w-4 mr-1" /> {pushing ? "Pushing…" : "Push to Budget"}</Button>
<Button size="sm" variant={certified ? "outline" : "default"} onClick={toggleCertify} disabled={certifying}
className={certified ? "" : "bg-emerald-600 hover:bg-emerald-700"}>
<CheckCircle2 className="h-4 w-4 mr-1" /> {certifying ? "Saving…" : certified ? "Certified ✓" : "Certify as Approved"}
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{certified ? (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-4 py-2.5 text-sm text-emerald-800 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span><strong>Approved budget.</strong> Certified{certifiedAt ? ` on ${new Date(certifiedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}` : ""}.</span>
</div>
) : (
<div className="rounded-md border-2 border-red-500 bg-red-50 px-4 py-3 text-center">
<p className="font-bold text-red-600">{PRELIM_NOTE}</p>
</div>
)}
{/* Summary */} {/* Summary */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Annual Budget (Expenses)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(expenseAnnual)}</div></CardContent></Card> <Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Annual Budget (Expenses)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(expenseAnnual)}</div></CardContent></Card>