mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user