import React, { useState, useEffect, useRef, useMemo } from "react";
import * as mammoth from "mammoth";
import Papa from "papaparse";
import {
Upload, AlertTriangle, CheckCircle2, Plus, Trash2,
Building2, Wallet, Target, ClipboardList, LayoutDashboard, Loader2, PencilLine,
Banknote, CreditCard, Coins, FolderInput, FileText,
Landmark, Users, ArrowLeftRight, BarChart3, Boxes
} from "lucide-react";
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from "recharts";
/* ----------------------------- constants ----------------------------- */
const CATEGORY_TAXONOMY = {
Income: {
"Earned Revenue": ["Sales", "Service Fees", "Ticket/Event Revenue", "Rental Income"],
"Contributions & Grants": ["Individual Donations", "Foundation Grants", "Government Grants", "In-Kind"],
"Fundraising Campaign Proceeds": ["Capital Campaign", "Annual Fund", "Crowdfunding Platform"],
"Investment Income": ["Interest", "Dividends", "Capital Gains"],
"Asset Sales": ["Equipment Sale", "Real Estate Sale", "Securities Sale"],
"Cash Deposit \u2013 Unclassified": [],
"Other Income": []
},
Expense: {
"Program & Supplies": ["Program Supplies", "Concession & Beverage Supplies", "Cost of Goods Sold"],
"Payroll & Labor": ["Wages", "Contract Labor", "Benefits"],
"Facilities": ["Rent", "Utilities", "Insurance", "Repairs & Maintenance"],
"Capital Improvements": ["Roofing", "HVAC", "Electrical", "Structural"],
"Professional Services": ["Legal", "Accounting", "Consulting"],
"Food & Grocery": ["Pantry Restock", "Dining/Meals"],
"Marketing & Development": ["Campaign Costs", "Printing", "Advertising"],
"Debt Service": ["Loan Principal", "Loan Interest"],
"Taxes & Licensing": [],
"Travel": [],
"Office & Administrative": [],
"Uncategorized": []
}
};
// Approximate mapping for organizing records by IRS return line \u2014 not tax advice. Confirm placement
// with a preparer; actual line assignment can depend on specifics this tool doesn't see.
const TAX_LINE_MAPS = {
"Form 990": {
Income: {
"Earned Revenue": "Part VIII, Line 2 \u2013 Program service revenue",
"Contributions & Grants": "Part VIII, Line 1 \u2013 Contributions, gifts, grants",
"Fundraising Campaign Proceeds": "Part VIII, Line 1c/8 \u2013 Fundraising events",
"Investment Income": "Part VIII, Lines 3\u20134 \u2013 Investment income",
"Asset Sales": "Part VIII, Line 7 \u2013 Gain/loss on sale of assets",
"Cash Deposit \u2013 Unclassified": "Unmapped \u2013 resolve category first",
"Other Income": "Part VIII, Line 11 \u2013 Miscellaneous revenue"
},
Expense: {
"Program & Supplies": "Part IX, Line 24 \u2013 Other program expenses",
"Payroll & Labor": "Part IX, Lines 5\u20139 \u2013 Salaries, wages, benefits",
"Facilities": "Part IX, Lines 16\u201317 \u2013 Occupancy",
"Capital Improvements": "Capitalized \u2013 see Part X, not Part IX",
"Professional Services": "Part IX, Line 11 \u2013 Fees for services",
"Food & Grocery": "Part IX, Line 24 \u2013 Other program expenses",
"Marketing & Development": "Part IX, Lines 12\u201313 \u2013 Advertising & promotion",
"Debt Service": "Part IX, Lines 20\u201321 \u2013 Interest, payments to affiliates",
"Taxes & Licensing": "Part IX, Line 18 \u2013 Other expenses",
"Travel": "Part IX, Line 17 \u2013 Travel",
"Office & Administrative": "Part IX, Lines 22\u201323 \u2013 Office/IT",
"Uncategorized": "Unmapped \u2013 resolve category first"
}
},
"Schedule C": {
Income: {
"Earned Revenue": "Line 1 \u2013 Gross receipts or sales",
"Contributions & Grants": "Not typically applicable on Schedule C",
"Fundraising Campaign Proceeds": "Not typically applicable on Schedule C",
"Investment Income": "Not reported on Schedule C \u2013 see Schedule B",
"Asset Sales": "Not on Schedule C \u2013 see Form 4797",
"Cash Deposit \u2013 Unclassified": "Unmapped \u2013 resolve category first",
"Other Income": "Line 6 \u2013 Other income"
},
Expense: {
"Program & Supplies": "Line 22 \u2013 Supplies",
"Payroll & Labor": "Line 26 \u2013 Wages (or Line 11 \u2013 Contract labor)",
"Facilities": "Line 20b \u2013 Rent (other) / Line 25 \u2013 Utilities",
"Capital Improvements": "Capitalized \u2013 see Form 4562, not Schedule C directly",
"Professional Services": "Line 17 \u2013 Legal & professional services",
"Food & Grocery": "Line 24b \u2013 Meals (subject to limitation)",
"Marketing & Development": "Line 8 \u2013 Advertising",
"Debt Service": "Line 16 \u2013 Interest",
"Taxes & Licensing": "Line 23 \u2013 Taxes & licenses",
"Travel": "Line 24a \u2013 Travel",
"Office & Administrative": "Line 18 \u2013 Office expense",
"Uncategorized": "Unmapped \u2013 resolve category first"
}
}
};
function filingTypeForEntityType(entityType) {
if (entityType === "Nonprofit") return "Form 990";
if (entityType === "Business") return "Schedule C";
return null;
}
function taxLineFor(filingType, direction, category) {
if (!filingType) return null;
const map = TAX_LINE_MAPS[filingType];
const dirMap = map && (direction === "income" ? map.Income : map.Expense);
return (dirMap && dirMap[category]) || "Not mapped";
}
const FOLDER_GROUPS = [
{
key: "bank", label: "Banks/Investments", icon: Banknote,
children: [
{ key: "bank", label: "Statements", accept: ".pdf,.jpg,.jpeg,.png,.docx" },
{ key: "bankDeposits", label: "Deposit Tickets", accept: ".pdf,.jpg,.jpeg,.png" },
{ key: "bankReceipts", label: "Receipts", accept: ".pdf,.jpg,.jpeg,.png" }
]
},
{
key: "credit", label: "Credit Cards", icon: CreditCard,
children: [
{ key: "credit", label: "Statements", accept: ".pdf,.jpg,.jpeg,.png,.docx" },
{ key: "creditReceipts", label: "Receipts", accept: ".pdf,.jpg,.jpeg,.png" }
]
},
{
key: "cash", label: "Cash Transactions", icon: Coins,
children: [
{ key: "cash", label: "Cash Log", accept: ".pdf,.jpg,.jpeg,.png,.docx" },
{ key: "cashReceipts", label: "Receipts", accept: ".pdf,.jpg,.jpeg,.png" }
]
},
{
key: "vendor", label: "Vendor", icon: FileText,
children: [
{ key: "vendorInvoices", label: "Invoices", accept: ".pdf,.jpg,.jpeg,.png" },
{ key: "vendorBills", label: "Bills", accept: ".pdf,.jpg,.jpeg,.png" }
]
},
{
key: "customer", label: "Customer", icon: Users,
children: [
{ key: "customerInvoices", label: "Invoices", accept: ".pdf,.jpg,.jpeg,.png" },
{ key: "customerPledges", label: "Memberships & Pledges", accept: ".pdf,.jpg,.jpeg,.png" }
]
},
{ key: "salesReports", label: "Sales Reports", icon: BarChart3, accept: ".pdf,.csv,.jpg,.jpeg,.png" },
{ key: "fundraising", label: "Fundraising Imports", icon: FolderInput, accept: ".csv,.pdf" },
{ key: "taxReturns", label: "Tax Returns", icon: Landmark, accept: ".pdf,.jpg,.jpeg,.png" }
];
function leavesOf(group) { return group.children || [{ key: group.key, label: group.label, accept: group.accept }]; }
const FOLDERS_FLAT = FOLDER_GROUPS.flatMap((g) => leavesOf(g).map((c) => ({ ...c, icon: g.icon, groupLabel: g.label })));
const ASSET_TYPES = ["Real Property", "Equipment & Appliances", "Investments"];
const LIABILITY_TYPES = ["Loan / Note Payable", "Credit Card Balance", "Deferred Revenue", "Accrued Expense", "Other"];
const CONTACT_TYPES = ["Customer", "Vendor/Merchant", "Service Provider"];
const INVENTORY_SUBCATEGORIES = ["Supplies", "Raw Material", "Maintenance, Repair & Operations"];
const STORAGE_KEYS = {
entities: "fcc-entities",
transactions: "fcc-transactions",
rules: "fcc-rules",
assets: "fcc-assets",
goals: "fcc-goals",
bids: "fcc-bids",
contacts: "fcc-contacts",
payables: "fcc-payables",
receivables: "fcc-receivables",
inventory: "fcc-inventory",
taxReturns: "fcc-tax-returns",
liabilities: "fcc-liabilities"
};
/* ----------------------------- helpers ----------------------------- */
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
function fmt(n) {
const v = Number(n) || 0;
return (v < 0 ? "-$" : "$") + Math.abs(v).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function vendorKey(s) { return (s || "").trim().toLowerCase().replace(/\s+/g, " "); }
async function safeGet(key) {
try {
const r = await window.storage.get(key, false);
return r ? JSON.parse(r.value) : null;
} catch (e) { return null; }
}
async function safeSet(key, value) {
try { await window.storage.set(key, JSON.stringify(value), false); } catch (e) { /* ignore in prototype */ }
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result).split(",")[1]);
r.onerror = reject;
r.readAsDataURL(file);
});
}
async function callClaude(contentBlocks, systemPrompt) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "claude-sonnet-4-6",
max_tokens: 1000,
system: systemPrompt,
messages: [{ role: "user", content: contentBlocks }]
})
});
const data = await res.json();
if (!data || !data.content) throw new Error("No response from model");
return data.content.map((b) => b.text || "").join("\n");
}
function parseAiJson(text) {
const cleaned = text.replace(/```json|```/g, "").trim();
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start === -1 || end === -1) throw new Error("No JSON object found in model response");
return JSON.parse(cleaned.slice(start, end + 1));
}
const TAXONOMY_PROMPT_LIST = Object.entries(CATEGORY_TAXONOMY)
.map(([dir, cats]) => `${dir}: ${Object.keys(cats).join(", ")}`)
.join("\n");
function buildSystemPrompt(folderKey) {
let folderNote = "";
const k = folderKey.toLowerCase();
if (k.includes("deposit")) {
folderNote = "This is a deposit ticket. Pay special attention to any handwritten memo describing the SOURCE of the funds (e.g. cash sales, a person's name, a reimbursement). Put your best-effort reading of that handwriting in handwrittenNote, and lower your confidence score if the handwriting is hard to read.";
} else if (k.includes("receipt")) {
folderNote = "If this is a grocery, pantry-stock, or concession-supply receipt, populate lineItems with every distinct product and its price, in addition to the single total transaction.";
} else if (folderKey === "cash") {
folderNote = "This may be a handwritten cash log rather than a printed document. Extract each line as its own transaction.";
} else if (k.includes("vendor")) {
folderNote = "This is a bill from a vendor or merchant that is owed, not yet paid \u2014 treat it as a payable, direction is always \"expense\". Read the invoice number and due date if present. Also read any vendor contact information printed on the invoice (phone, email, mailing address) and put it in vendorContact.";
} else if (k.includes("customer")) {
folderNote = "This is an invoice owed TO the business or nonprofit by a customer, member, or donor \u2014 treat it as a receivable, direction is always \"income\". Put the customer's, member's, or pledger's name in the vendor field, and any contact info into vendorContact. If this is a multi-installment pledge, use the amount currently due rather than the full multi-year pledge total. A membership or subscription renewal usually belongs under \"Earned Revenue\"; a pledge or giving commitment usually belongs under \"Contributions & Grants\" or \"Fundraising Campaign Proceeds\".";
}
return `You are a financial document analyst inside a bookkeeping tool called Financial Command Center. You will be given the contents of a document uploaded to the "${folderKey}" intake folder. Extract every distinct financial transaction.
Respond with ONLY valid JSON (no markdown fences, no commentary), exactly this shape:
{"transactions":[{"date":"YYYY-MM-DD or null","description":"short description","vendor":"best-guess vendor, payer, payee, customer, or member name","amount":number (always positive),"direction":"income"|"expense","suggestedCategory":"one of the categories listed below","suggestedSubcategory":"a matching subcategory or empty string","confidence":number between 0 and 1,"handwrittenNote":"string or null","lineItems":[{"name":"string","amount":number}] or null,"invoiceNumber":"string or null","dueDate":"YYYY-MM-DD or null","vendorContact":{"phone":"string or null","email":"string or null","address":"string or null"} or null}]}
Categories available:
${TAXONOMY_PROMPT_LIST}
If you cannot confidently determine a category, set suggestedCategory to "Uncategorized" and confidence below 0.5. ${folderNote}`;
}
function buildSalesReportPrompt() {
return `You are a financial document analyst inside a bookkeeping tool called Financial Command Center. You will be given a sales or point-of-sale report (e.g. a daily concession or register summary). Extract every distinct item sold, with the quantity sold \u2014 this will be matched against an inventory ledger to calculate cost of goods sold, so quantity matters more than price here.
Respond with ONLY valid JSON (no markdown fences, no commentary), exactly this shape:
{"salesLines":[{"itemName":"product name as printed, e.g. \\"Popcorn Large\\"","unitsSold":number,"revenue":number or null,"date":"YYYY-MM-DD or null"}]}
Include one line per distinct product. If the report only shows a grand total with no per-item breakdown, return an empty salesLines array rather than guessing.`;
}
function buildTaxReturnPrompt() {
return `You are a financial document analyst inside a bookkeeping tool called Financial Command Center. You will be given a filed tax return (e.g. Form 990, Form 1040, Schedule C, Form 1065, Form 1120, 1120-S, or a state equivalent). Extract only the bottom-line summary figures for the year it covers \u2014 do not attempt to recompute or audit the return.
Respond with ONLY valid JSON (no markdown fences, no commentary), exactly this shape:
{"returns":[{"taxYear":number or null,"filingType":"string, e.g. \\"Form 990\\" or \\"Form 1040\\"","totalIncome":number or null,"totalExpenses":number or null,"netIncome":number or null,"taxLiability":number or null,"refundAmount":number or null}]}
Use the return's own reported totals (e.g. Form 990 Part I total revenue/expenses/net assets, or Form 1040 total income/AGI and tax owed or refunded). If a figure isn't present on the document, use null rather than estimating it.`;
}
function buildPropertyAssessmentPrompt(assetName) {
return `You are a financial document analyst inside a bookkeeping tool called Financial Command Center. You will be given a property tax assessment notice or a property tax bill for a property called "${assetName}". Extract its key figures \u2014 do not recompute or audit anything.
Respond with ONLY valid JSON (no markdown fences, no commentary), exactly this shape:
{"taxYear":number or null,"assessedLandValue":number or null,"assessedImprovementValue":number or null,"assessedTotalValue":number or null,"annualTaxAmount":number or null,"dueDate":"YYYY-MM-DD or null","taxingAuthority":"string or null","parcelId":"string or null"}
If the document breaks the assessed value into land and improvements (building), populate both assessedLandValue and assessedImprovementValue. If it only gives one combined figure, put it in assessedTotalValue and leave the land/improvement fields null. If this is purely an assessment notice with no tax amount due, leave annualTaxAmount null rather than guessing.`;
}
async function extractDocxText(file) {
const buf = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer: buf });
return result.value || "";
}
function parseCsvFile(file) {
return new Promise((resolve, reject) => {
Papa.parse(file, { header: true, skipEmptyLines: true, complete: (r) => resolve(r.data), error: reject });
});
}
function safeDateStr(v) {
try {
const d = new Date(v);
if (isNaN(d.getTime())) return null;
return d.toISOString().slice(0, 10);
} catch (e) { return null; }
}
function mapFundraisingRows(rows) {
const out = [];
for (const row of rows) {
const keys = Object.keys(row);
const find = (cands) => keys.find((k) => cands.some((c) => k.toLowerCase().includes(c)));
const amountKey = find(["amount", "total", "donation"]);
const dateKey = find(["date", "created"]);
const nameKey = find(["donor", "name", "supporter"]);
const campaignKey = find(["campaign", "fund", "event"]);
const rawAmount = amountKey ? String(row[amountKey]).replace(/[^0-9.\-]/g, "") : "";
const amount = parseFloat(rawAmount);
if (!amount || isNaN(amount)) continue;
out.push({
date: dateKey ? safeDateStr(row[dateKey]) : null,
description: campaignKey ? `Fundraising \u2013 ${row[campaignKey]}` : "Fundraising platform contribution",
vendor: nameKey ? row[nameKey] : (campaignKey ? row[campaignKey] : "Fundraising platform"),
amount: Math.abs(amount),
direction: "income",
suggestedCategory: "Fundraising Campaign Proceeds",
suggestedSubcategory: "Crowdfunding Platform",
confidence: 0.85,
handwrittenNote: null,
lineItems: null
});
}
return out;
}
function computeDepreciationSchedule(asset) {
const cost = Number(asset.cost) || 0;
const salvage = Number(asset.salvageValue) || 0;
const life = Number(asset.usefulLifeYears) || 7;
const purchase = new Date(asset.purchaseDate || Date.now());
const monthlyDep = (cost - salvage) / Math.max(life * 12, 1);
const monthsElapsed = Math.max(0, (Date.now() - purchase.getTime()) / (30.4375 * 24 * 3600 * 1000));
const accumulatedDep = Math.min(cost - salvage, monthlyDep * monthsElapsed);
const bookValue = Math.max(salvage, cost - accumulatedDep);
const fullyDepreciatedDate = new Date(purchase);
fullyDepreciatedDate.setMonth(fullyDepreciatedDate.getMonth() + Math.round(life * 12));
const isFullyDepreciated = bookValue <= salvage + 0.01;
return { cost, salvage, monthlyDep, accumulatedDep, bookValue, fullyDepreciatedDate, isFullyDepreciated };
}
function isSplitRealProperty(asset) {
return asset.type === "Real Property" && Number(asset.improvementValue) > 0;
}
function realPropertyImprovementSchedule(asset) {
return computeDepreciationSchedule({
cost: Number(asset.improvementValue) || 0,
salvageValue: asset.salvageValue || 0,
usefulLifeYears: asset.usefulLifeYears || 27.5,
purchaseDate: asset.purchaseDate
});
}
function computeAssetValue(asset) {
if (asset.type === "Equipment & Appliances") {
return computeDepreciationSchedule(asset).bookValue;
}
if (isSplitRealProperty(asset)) {
return (Number(asset.landValue) || 0) + realPropertyImprovementSchedule(asset).bookValue;
}
return asset.currentMarketValue != null && asset.currentMarketValue !== "" ? Number(asset.currentMarketValue) : Number(asset.cost) || 0;
}
function groupAssetsByType(assets) {
const map = {};
ASSET_TYPES.forEach((t) => { map[t] = 0; });
assets.forEach((a) => { map[a.type] = (map[a.type] || 0) + computeAssetValue(a); });
return map;
}
function inventoryValue(item) { return (Number(item.quantityOnHand) || 0) * (Number(item.unitCost) || 0); }
function groupInventoryBySubcategory(inventory) {
const map = {};
INVENTORY_SUBCATEGORIES.forEach((s) => { map[s] = 0; });
inventory.forEach((i) => { map[i.subcategory] = (map[i.subcategory] || 0) + inventoryValue(i); });
return map;
}
const AGING_BUCKETS = ["0\u201330 days", "31\u201360 days", "61\u201390 days", "90+ days"];
function agingBuckets(items) {
const buckets = { "0\u201330 days": 0, "31\u201360 days": 0, "61\u201390 days": 0, "90+ days": 0 };
const today = new Date();
items.filter((i) => i.status === "open").forEach((i) => {
const ref = i.dueDate || i.date;
if (!ref) { buckets["90+ days"] += Number(i.amount) || 0; return; }
const days = Math.floor((today - new Date(ref)) / (24 * 3600 * 1000));
if (days <= 30) buckets["0\u201330 days"] += Number(i.amount) || 0;
else if (days <= 60) buckets["31\u201360 days"] += Number(i.amount) || 0;
else if (days <= 90) buckets["61\u201390 days"] += Number(i.amount) || 0;
else buckets["90+ days"] += Number(i.amount) || 0;
});
return buckets;
}
function computePantryEstimate(transactions) {
const history = {};
transactions.forEach((t) => {
if (t.subcategory === "Pantry Restock" && Array.isArray(t.lineItems)) {
t.lineItems.forEach((li) => {
const key = vendorKey(li.name);
if (!key) return;
if (!history[key]) history[key] = [];
history[key].push({ date: t.date || new Date().toISOString().slice(0, 10), amount: Number(li.amount) || 0, label: li.name });
});
}
});
const breakdown = [];
let total = 0;
Object.values(history).forEach((purchases) => {
purchases.sort((a, b) => new Date(a.date) - new Date(b.date));
const avgAmount = purchases.reduce((s, p) => s + p.amount, 0) / purchases.length;
let avgIntervalDays = 14;
if (purchases.length >= 2) {
const intervals = [];
for (let i = 1; i < purchases.length; i++) {
intervals.push((new Date(purchases[i].date) - new Date(purchases[i - 1].date)) / (24 * 3600 * 1000));
}
const validIntervals = intervals.filter((v) => v >= 0);
if (validIntervals.length) avgIntervalDays = validIntervals.reduce((s, v) => s + v, 0) / validIntervals.length;
}
const last = purchases[purchases.length - 1];
const daysSince = (Date.now() - new Date(last.date).getTime()) / (24 * 3600 * 1000);
const remainingFraction = Math.max(0, Math.min(1, 1 - daysSince / Math.max(avgIntervalDays, 1)));
const estValue = avgAmount * remainingFraction;
total += estValue;
breakdown.push({
name: last.label, estValue, avgAmount, avgIntervalDays: Math.round(avgIntervalDays),
daysSince: Math.round(daysSince), purchaseCount: purchases.length
});
});
breakdown.sort((a, b) => b.estValue - a.estValue);
return { total, breakdown };
}
/* ----------------------------- small UI atoms ----------------------------- */
function GlobalStyle() {
return (
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Spectral:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap');
.fcc-root { font-family: 'Inter', sans-serif; background: #16241B; }
.fcc-display { font-family: 'Spectral', serif; }
.fcc-mono { font-family: 'IBM Plex Mono', monospace; }
.fcc-paper { background: #EEF1E4; }
.fcc-rule { border-bottom: 1px solid #C5CCB6; }
.fcc-double { border-bottom: 3px double #1F2E22; }
.fcc-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
.fcc-scroll::-webkit-scrollbar-thumb { background: #B9C2A8; border-radius: 4px; }
.fcc-dropzone { transition: background 0.15s, border-color 0.15s; }
.fcc-dropzone.over { background: #E3E8D4; border-color: #A6792B !important; }
`}</style>
);
}
function StatCard({ label, value, accent, sub }) {
return (
<div className="fcc-paper rounded-sm border border-[#C5CCB6] p-4">
<div className="text-[11px] uppercase tracking-wider text-[#5B6650] font-medium">{label}</div>
<div className="fcc-mono text-2xl mt-1" style={{ color: accent || "#1F2E22" }}>{value}</div>
{sub ? <div className="text-xs text-[#7A8568] mt-1">{sub}</div> : null}
</div>
);
}
function FlagBadge() {
return (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full border" style={{ color: "#7A2E2E", borderColor: "#7A2E2E", background: "#F6E9E9" }}>
<AlertTriangle size={11} /> NEEDS REVIEW
</span>
);
}
function CategorySelect({ direction, category, subcategory, onChange }) {
const handleCategory = (e) => {
const [dir, cat] = e.target.value.split("::");
onChange({ direction: dir, category: cat, subcategory: "" });
};
const subs = direction && category ? (CATEGORY_TAXONOMY[direction === "income" ? "Income" : "Expense"][category] || []) : [];
return (
<div className="flex gap-1.5">
<select
className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white max-w-[160px]"
value={direction && category ? `${direction}::${category}` : ""}
onChange={handleCategory}
>
<option value="">Uncategorized</option>
<optgroup label="Income">
{Object.keys(CATEGORY_TAXONOMY.Income).map((c) => (
<option key={c} value={`income::${c}`}>{c}</option>
))}
</optgroup>
<optgroup label="Expense">
{Object.keys(CATEGORY_TAXONOMY.Expense).map((c) => (
<option key={c} value={`expense::${c}`}>{c}</option>
))}
</optgroup>
</select>
{subs.length > 0 && (
<select
className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white max-w-[140px]"
value={subcategory || ""}
onChange={(e) => onChange({ direction, category, subcategory: e.target.value })}
>
<option value="">(subcategory)</option>
{subs.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
</div>
);
}
function InlineField({ value, placeholder, onSave, className, type }) {
const [v, setV] = useState(value || "");
useEffect(() => { setV(value || ""); }, [value]);
return (
<input
type={type || "text"}
placeholder={placeholder}
className={className || "text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white"}
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={() => { if (v !== (value || "")) onSave(v); }}
/>
);
}
function StatusBadge({ status, dueDate }) {
const overdue = status === "open" && dueDate && new Date(dueDate) < new Date(new Date().toDateString());
const label = overdue ? "overdue" : status;
const color = overdue ? "#7A2E2E" : status === "paid" ? "#2E5339" : "#A6792B";
return <span className="text-[11px] px-2 py-0.5 rounded-full border whitespace-nowrap" style={{ color, borderColor: color }}>{label}</span>;
}
/* ----------------------------- App ----------------------------- */
export default function App() {
const [loaded, setLoaded] = useState(false);
const [entities, setEntities] = useState([]);
const [transactions, setTransactions] = useState([]);
const [rules, setRules] = useState({});
const [assets, setAssets] = useState([]);
const [goals, setGoals] = useState([]);
const [bids, setBids] = useState([]);
const [contacts, setContacts] = useState([]);
const [payables, setPayables] = useState([]);
const [receivables, setReceivables] = useState([]);
const [liabilities, setLiabilities] = useState([]);
const [inventory, setInventory] = useState([]);
const [taxReturns, setTaxReturns] = useState([]);
const [selectedEntity, setSelectedEntity] = useState("all");
const [activeTab, setActiveTab] = useState("dashboard");
const [activeFolder, setActiveFolder] = useState("bank");
const [newEntityName, setNewEntityName] = useState("");
const [newEntityType, setNewEntityType] = useState("Individual");
useEffect(() => {
(async () => {
const [e, t, r, a, g, b, c, p, rec, li, inv, tr] = await Promise.all([
safeGet(STORAGE_KEYS.entities), safeGet(STORAGE_KEYS.transactions),
safeGet(STORAGE_KEYS.rules), safeGet(STORAGE_KEYS.assets),
safeGet(STORAGE_KEYS.goals), safeGet(STORAGE_KEYS.bids),
safeGet(STORAGE_KEYS.contacts), safeGet(STORAGE_KEYS.payables),
safeGet(STORAGE_KEYS.receivables), safeGet(STORAGE_KEYS.liabilities),
safeGet(STORAGE_KEYS.inventory), safeGet(STORAGE_KEYS.taxReturns)
]);
if (e) setEntities(e);
if (t) setTransactions(t);
if (r) setRules(r);
if (a) setAssets(a);
if (g) setGoals(g);
if (b) setBids(b);
if (c) setContacts(c);
if (p) setPayables(p);
if (rec) setReceivables(rec);
if (li) setLiabilities(li);
if (inv) setInventory(inv);
if (tr) setTaxReturns(tr);
setLoaded(true);
})();
}, []);
const persist = {
transactions: (next) => { setTransactions(next); safeSet(STORAGE_KEYS.transactions, next); },
rules: (next) => { setRules(next); safeSet(STORAGE_KEYS.rules, next); },
entities: (next) => { setEntities(next); safeSet(STORAGE_KEYS.entities, next); },
assets: (next) => { setAssets(next); safeSet(STORAGE_KEYS.assets, next); },
goals: (next) => { setGoals(next); safeSet(STORAGE_KEYS.goals, next); },
bids: (next) => { setBids(next); safeSet(STORAGE_KEYS.bids, next); },
contacts: (next) => { setContacts(next); safeSet(STORAGE_KEYS.contacts, next); },
payables: (next) => { setPayables(next); safeSet(STORAGE_KEYS.payables, next); },
receivables: (next) => { setReceivables(next); safeSet(STORAGE_KEYS.receivables, next); },
liabilities: (next) => { setLiabilities(next); safeSet(STORAGE_KEYS.liabilities, next); },
inventory: (next) => { setInventory(next); safeSet(STORAGE_KEYS.inventory, next); },
taxReturns: (next) => { setTaxReturns(next); safeSet(STORAGE_KEYS.taxReturns, next); }
};
const visibleTx = useMemo(
() => selectedEntity === "all" ? transactions : transactions.filter((t) => t.entityId === selectedEntity),
[transactions, selectedEntity]
);
const scoped = (list) => selectedEntity === "all" ? list : list.filter((x) => x.entityId === selectedEntity);
function findOrCreateContact(name, type, contactInfo, contactList) {
const key = vendorKey(name);
if (!key) return { contact: null, list: contactList };
const existing = contactList.find((c) => c.entityId === selectedEntity && vendorKey(c.name) === key);
if (existing) return { contact: existing, list: contactList };
const created = {
id: uid(), entityId: selectedEntity, name, type,
email: contactInfo?.email || "", phone: contactInfo?.phone || "", address: contactInfo?.address || "", notes: ""
};
return { contact: created, list: [...contactList, created] };
}
function categorize(r, rulesDict) {
const vKey = vendorKey(r.vendor || r.description);
const rule = rulesDict[vKey];
const category = rule ? rule.category : (r.suggestedCategory === "Uncategorized" ? "" : r.suggestedCategory);
const subcategory = rule ? rule.subcategory : (r.suggestedSubcategory || "");
const direction = rule ? rule.direction : r.direction;
const flagged = rule ? false : (!category || (r.confidence != null && r.confidence < 0.65));
return { vKey, category: category || "", subcategory: subcategory || "", direction: direction || "expense", flagged };
}
function applyRule(vKey, category, subcategory, direction) {
const next = { ...rules, [vKey]: { category, subcategory, direction } };
persist.rules(next);
const updatedTx = transactions.map((t) => vendorKey(t.vendor) === vKey ? { ...t, category, subcategory, direction, flagged: false } : t);
persist.transactions(updatedTx);
const updatedPay = payables.map((p) => vendorKey(p.vendorName) === vKey ? { ...p, category, subcategory, flagged: false } : p);
persist.payables(updatedPay);
const updatedRec = receivables.map((r) => vendorKey(r.customerName) === vKey ? { ...r, category, subcategory, flagged: false } : r);
persist.receivables(updatedRec);
}
function ingestTransactions(raw, folderKey, fileName) {
if (selectedEntity === "all") { alert("Pick a specific entity above before processing files \u2014 every transaction needs a home."); return; }
const additions = raw.map((r) => {
const c = categorize(r, rules);
return {
id: uid(), entityId: selectedEntity, folder: folderKey, date: r.date || null,
description: r.description || "", vendor: r.vendor || r.description || "",
amount: Math.abs(Number(r.amount) || 0), direction: c.direction,
category: c.category, subcategory: c.subcategory,
flagged: c.flagged, confidence: r.confidence != null ? r.confidence : 0.7,
source: "ai", sourceFile: fileName, handwrittenNote: r.handwrittenNote || null,
lineItems: r.lineItems || null, capitalized: false, cashSettled: true, cashDate: r.date || null
};
});
persist.transactions([...transactions, ...additions]);
}
function ingestPayables(raw, fileName, folderKey) {
if (selectedEntity === "all") { alert("Pick a specific entity above before processing files \u2014 every invoice needs a home."); return; }
let contactList = contacts;
const newPayables = [];
const newTx = [];
raw.forEach((r) => {
const c = categorize(r, rules);
const found = findOrCreateContact(r.vendor, "Vendor/Merchant", r.vendorContact, contactList);
contactList = found.list;
const txId = uid();
const amount = Math.abs(Number(r.amount) || 0);
newPayables.push({
id: uid(), entityId: selectedEntity, contactId: found.contact ? found.contact.id : null,
vendorName: r.vendor || "", invoiceNumber: r.invoiceNumber || "", date: r.date || null,
dueDate: r.dueDate || null, amount,
category: c.category, subcategory: c.subcategory, flagged: c.flagged,
confidence: r.confidence != null ? r.confidence : 0.7, status: "open",
notes: "", sourceFile: fileName, folder: folderKey || "vendorInvoices", linkedTransactionId: txId
});
newTx.push({
id: txId, entityId: selectedEntity, folder: folderKey || "vendorInvoices",
date: r.date || new Date().toISOString().slice(0, 10),
description: `${r.vendor || "Vendor"}${r.invoiceNumber ? ` \u2013 invoice ${r.invoiceNumber}` : ""}`,
vendor: r.vendor || "", amount, direction: "expense",
category: c.category, subcategory: c.subcategory, flagged: c.flagged,
confidence: r.confidence != null ? r.confidence : 0.7, source: "ap-accrual",
sourceFile: fileName, handwrittenNote: null, lineItems: null, capitalized: false,
cashSettled: false, cashDate: null
});
});
persist.contacts(contactList);
persist.payables([...payables, ...newPayables]);
persist.transactions([...transactions, ...newTx]);
}
function ingestReceivablesFromFolder(raw, fileName, folderKey) {
if (selectedEntity === "all") { alert("Pick a specific entity above before processing files \u2014 every invoice needs a home."); return; }
let contactList = contacts;
const newReceivables = [];
const newTx = [];
raw.forEach((r) => {
const c = categorize({ ...r, direction: "income" }, rules);
const found = findOrCreateContact(r.vendor, "Customer", r.vendorContact, contactList);
contactList = found.list;
const txId = uid();
const amount = Math.abs(Number(r.amount) || 0);
newReceivables.push({
id: uid(), entityId: selectedEntity, contactId: found.contact ? found.contact.id : null,
customerName: r.vendor || "", invoiceNumber: r.invoiceNumber || "", date: r.date || null,
dueDate: r.dueDate || null, amount,
category: c.category, subcategory: c.subcategory, flagged: c.flagged,
confidence: r.confidence != null ? r.confidence : 0.7, status: "open",
notes: "", sourceFile: fileName, folder: folderKey || "customerInvoices", linkedTransactionId: txId
});
newTx.push({
id: txId, entityId: selectedEntity, folder: folderKey || "customerInvoices",
date: r.date || new Date().toISOString().slice(0, 10),
description: `${r.vendor || "Customer"}${r.invoiceNumber ? ` \u2013 invoice ${r.invoiceNumber}` : ""}`,
vendor: r.vendor || "", amount, direction: "income",
category: c.category, subcategory: c.subcategory, flagged: c.flagged,
confidence: r.confidence != null ? r.confidence : 0.7, source: "ar-accrual",
sourceFile: fileName, handwrittenNote: null, lineItems: null, capitalized: false,
cashSettled: false, cashDate: null
});
});
persist.contacts(contactList);
persist.receivables([...receivables, ...newReceivables]);
persist.transactions([...transactions, ...newTx]);
}
function ingestTaxReturns(raw, fileName) {
if (selectedEntity === "all") { alert("Pick a specific entity above before processing files \u2014 every return needs a home."); return; }
const additions = raw.map((r) => ({
id: uid(), entityId: selectedEntity, taxYear: r.taxYear || null, filingType: r.filingType || "",
totalIncome: r.totalIncome != null ? Number(r.totalIncome) : null,
totalExpenses: r.totalExpenses != null ? Number(r.totalExpenses) : null,
netIncome: r.netIncome != null ? Number(r.netIncome) : null,
taxLiability: r.taxLiability != null ? Number(r.taxLiability) : null,
refundAmount: r.refundAmount != null ? Number(r.refundAmount) : null,
sourceFile: fileName, notes: ""
}));
persist.taxReturns([...taxReturns, ...additions]);
}
// Paying a bill or collecting on an invoice is a cash-only event \u2014 the expense/income was already
// recognized when the bill/invoice was created. So this settles the existing linked transaction's cash
// status rather than recording a second P&L event (which would double-count the expense or income).
function markPayablePaid(payable, paidDate) {
if (payable.linkedTransactionId && transactions.some((t) => t.id === payable.linkedTransactionId)) {
persist.transactions(transactions.map((t) => t.id === payable.linkedTransactionId ? { ...t, cashSettled: true, cashDate: paidDate } : t));
} else {
const tx = {
id: uid(), entityId: payable.entityId, folder: payable.folder || "vendorInvoices", date: payable.date || paidDate,
description: `${payable.vendorName}${payable.invoiceNumber ? ` \u2013 invoice ${payable.invoiceNumber}` : ""}`,
vendor: payable.vendorName, amount: Number(payable.amount) || 0, direction: "expense",
category: payable.category, subcategory: payable.subcategory, flagged: !payable.category,
confidence: 1, source: "ap-settlement", sourceFile: payable.sourceFile, handwrittenNote: null,
lineItems: null, capitalized: false, cashSettled: true, cashDate: paidDate
};
persist.transactions([...transactions, tx]);
}
persist.payables(payables.map((p) => p.id === payable.id ? { ...p, status: "paid" } : p));
}
function markReceivablePaid(receivable, paidDate) {
if (receivable.linkedTransactionId && transactions.some((t) => t.id === receivable.linkedTransactionId)) {
persist.transactions(transactions.map((t) => t.id === receivable.linkedTransactionId ? { ...t, cashSettled: true, cashDate: paidDate } : t));
} else {
const tx = {
id: uid(), entityId: receivable.entityId, folder: receivable.folder || "customerInvoices", date: receivable.date || paidDate,
description: `${receivable.customerName}${receivable.invoiceNumber ? ` \u2013 invoice ${receivable.invoiceNumber}` : ""}`,
vendor: receivable.customerName, amount: Number(receivable.amount) || 0, direction: "income",
category: receivable.category, subcategory: receivable.subcategory, flagged: !receivable.category,
confidence: 1, source: "ar-settlement", sourceFile: receivable.sourceFile || null, handwrittenNote: null,
lineItems: null, capitalized: false, cashSettled: true, cashDate: paidDate
};
persist.transactions([...transactions, tx]);
}
persist.receivables(receivables.map((r) => r.id === receivable.id ? { ...r, status: "paid" } : r));
}
function addManualReceivable(form) {
if (selectedEntity === "all" || !form.customerName || !form.amount) return;
const txId = uid();
const amount = Number(form.amount) || 0;
const receivable = {
id: uid(), entityId: selectedEntity, contactId: null, customerName: form.customerName,
invoiceNumber: form.invoiceNumber || "", date: form.date || null, dueDate: form.dueDate || null,
amount, category: form.category || "", subcategory: form.subcategory || "", flagged: !form.category,
confidence: 1, status: "open", notes: "", sourceFile: null, folder: null, linkedTransactionId: txId
};
const tx = {
id: txId, entityId: selectedEntity, folder: "customerInvoices", date: form.date || new Date().toISOString().slice(0, 10),
description: `${form.customerName}${form.invoiceNumber ? ` \u2013 invoice ${form.invoiceNumber}` : ""}`,
vendor: form.customerName, amount, direction: "income", category: form.category || "", subcategory: form.subcategory || "",
flagged: !form.category, confidence: 1, source: "ar-accrual", sourceFile: null, handwrittenNote: null,
lineItems: null, capitalized: false, cashSettled: false, cashDate: null
};
persist.receivables([...receivables, receivable]);
persist.transactions([...transactions, tx]);
}
function updateEntity(id, fields) {
persist.entities(entities.map((e) => e.id === id ? { ...e, ...fields } : e));
}
function capitalizeAsAsset(tx) {
const asset = {
id: uid(), entityId: tx.entityId, name: tx.description || tx.vendor, type: "Equipment & Appliances",
purchaseDate: tx.date || new Date().toISOString().slice(0, 10), cost: tx.amount,
usefulLifeYears: "5", salvageValue: "0", currentMarketValue: ""
};
persist.assets([...assets, asset]);
persist.transactions(transactions.map((t) => t.id === tx.id ? { ...t, capitalized: true, category: "Capital Improvements", subcategory: "" } : t));
}
function addToInventory(tx, opts) {
const qty = Math.max(0.0001, Number(opts.quantity) || 1);
const unitCost = tx.amount / qty;
const key = vendorKey(tx.vendor || tx.description);
const existingIdx = inventory.findIndex((i) => i.entityId === tx.entityId && vendorKey(i.name) === key);
let next;
if (existingIdx === -1) {
next = [...inventory, {
id: uid(), entityId: tx.entityId, name: tx.vendor || tx.description, subcategory: opts.subcategory,
unit: opts.unit || "unit", unitCost, quantityOnHand: qty, reorderPoint: ""
}];
} else {
const item = inventory[existingIdx];
const oldQty = Number(item.quantityOnHand) || 0;
const oldCost = Number(item.unitCost) || 0;
const newQty = oldQty + qty;
const newCost = newQty > 0 ? ((oldQty * oldCost) + (qty * unitCost)) / newQty : unitCost;
next = inventory.map((i, ix) => ix === existingIdx ? { ...i, quantityOnHand: newQty, unitCost: newCost, subcategory: opts.subcategory } : i);
}
persist.inventory(next);
persist.transactions(transactions.map((t) => t.id === tx.id ? { ...t, capitalized: true, category: "Program & Supplies", subcategory: "" } : t));
}
function applyAssessment(asset, parsed, fileName) {
const updatedAsset = { ...asset };
if (parsed.assessedLandValue != null || parsed.assessedImprovementValue != null) {
updatedAsset.landValue = parsed.assessedLandValue != null ? Number(parsed.assessedLandValue) : (Number(asset.landValue) || 0);
updatedAsset.improvementValue = parsed.assessedImprovementValue != null ? Number(parsed.assessedImprovementValue) : (Number(asset.improvementValue) || 0);
if (!updatedAsset.usefulLifeYears) updatedAsset.usefulLifeYears = "27.5";
if (!updatedAsset.salvageValue) updatedAsset.salvageValue = "0";
} else if (parsed.assessedTotalValue != null) {
updatedAsset.currentMarketValue = Number(parsed.assessedTotalValue);
}
if (parsed.taxYear) updatedAsset.lastAssessedYear = parsed.taxYear;
persist.assets(assets.map((a) => a.id === asset.id ? updatedAsset : a));
if (parsed.annualTaxAmount != null && Number(parsed.annualTaxAmount) > 0) {
const authorityName = parsed.taxingAuthority || "Property Tax Authority";
const found = findOrCreateContact(authorityName, "Service Provider", null, contacts);
persist.contacts(found.list);
const payable = {
id: uid(), entityId: asset.entityId, contactId: found.contact ? found.contact.id : null,
vendorName: authorityName, invoiceNumber: parsed.parcelId || "",
date: new Date().toISOString().slice(0, 10), dueDate: parsed.dueDate || null,
amount: Number(parsed.annualTaxAmount), category: "Taxes & Licensing", subcategory: "",
flagged: false, confidence: 1, status: "open", notes: `Property tax \u2013 ${asset.name}`,
sourceFile: fileName, folder: "vendorBills", linkedTransactionId: null
};
persist.payables([...payables, payable]);
}
}
function ingestSalesReport(rawLines, fileName) {
if (selectedEntity === "all") { alert("Pick a specific entity above before processing files \u2014 every sale needs a home."); return; }
const unmatched = [];
const newTx = [];
let nextInventory = inventory;
rawLines.forEach((line) => {
const key = vendorKey(line.itemName);
const idx = nextInventory.findIndex((i) => i.entityId === selectedEntity && vendorKey(i.name) === key);
const unitsSold = Math.abs(Number(line.unitsSold) || 0);
if (idx === -1) { if (line.itemName) unmatched.push(line.itemName); return; }
if (unitsSold <= 0) return;
const item = nextInventory[idx];
const cogs = unitsSold * (Number(item.unitCost) || 0);
nextInventory = nextInventory.map((i, ix) => ix === idx ? { ...i, quantityOnHand: Math.max(0, (Number(i.quantityOnHand) || 0) - unitsSold) } : i);
newTx.push({
id: uid(), entityId: selectedEntity, folder: "salesReports", date: line.date || null,
description: `Cost of goods sold \u2013 ${item.name} (${unitsSold} ${item.unit || "units"} sold)`, vendor: item.name,
amount: cogs, direction: "expense", category: "Program & Supplies", subcategory: "Cost of Goods Sold",
flagged: false, confidence: 1, source: "cogs-calc", sourceFile: fileName, handwrittenNote: null,
lineItems: null, capitalized: false, cashSettled: true, cashDate: line.date || null
});
});
persist.inventory(nextInventory);
if (newTx.length) persist.transactions([...transactions, ...newTx]);
return { count: newTx.length, note: unmatched.length ? `${unmatched.length} item${unmatched.length === 1 ? "" : "s"} had no matching inventory item: ${unmatched.join(", ")}` : null };
}
const entityName = (id) => entities.find((e) => e.id === id)?.name || "\u2014";
const selectedEntityObj = selectedEntity === "all" ? null : (entities.find((e) => e.id === selectedEntity) || null);
const selectedEntityType = selectedEntityObj?.type || null;
if (!loaded) {
return <div className="fcc-root min-h-screen flex items-center justify-center text-[#E8E6D9]"><Loader2 className="animate-spin mr-2" size={18}/> Opening the ledger\u2026</div>;
}
return (
<div className="fcc-root min-h-screen flex text-[#1F2E22]">
<GlobalStyle />
<Sidebar activeTab={activeTab} setActiveTab={setActiveTab} />
<div className="flex-1 min-h-screen flex flex-col">
<TopBar
entities={entities} selectedEntity={selectedEntity} setSelectedEntity={setSelectedEntity}
newEntityName={newEntityName} setNewEntityName={setNewEntityName}
newEntityType={newEntityType} setNewEntityType={setNewEntityType}
addEntity={() => {
if (!newEntityName.trim()) return;
const e = { id: uid(), name: newEntityName.trim(), type: newEntityType };
persist.entities([...entities, e]);
setNewEntityName(""); setSelectedEntity(e.id);
}}
/>
<div className="flex-1 overflow-y-auto fcc-scroll p-6">
{activeTab === "dashboard" && (
<Dashboard
transactions={visibleTx} assets={scoped(assets)} inventory={scoped(inventory)}
goals={scoped(goals)}
bids={scoped(bids)}
payables={scoped(payables)} receivables={scoped(receivables)}
entityName={entityName} entityType={selectedEntityType} updateTx={(tx)=>persist.transactions(transactions.map(t=>t.id===tx.id?tx:t))}
applyRule={applyRule}
/>
)}
{activeTab === "balanceSheet" && (
<BalanceSheetTab
transactions={visibleTx} assets={scoped(assets)} payables={scoped(payables)} inventory={scoped(inventory)}
receivables={scoped(receivables)} liabilities={scoped(liabilities)} entityType={selectedEntityType}
entity={selectedEntityObj}
selectedEntity={selectedEntity}
/>
)}
{activeTab === "folders" && (
<FoldersTab
activeFolder={activeFolder} setActiveFolder={setActiveFolder}
transactions={visibleTx} ingestTransactions={ingestTransactions} ingestPayables={ingestPayables} ingestReceivablesFromFolder={ingestReceivablesFromFolder} ingestSalesReport={ingestSalesReport} ingestTaxReturns={ingestTaxReturns} applyRule={applyRule}
updateTx={(tx)=>persist.transactions(transactions.map(t=>t.id===tx.id?tx:t))}
deleteTx={(id)=>persist.transactions(transactions.filter(t=>t.id!==id))}
capitalizeAsAsset={capitalizeAsAsset} addToInventory={addToInventory}
payables={scoped(payables)}
updatePayable={(p)=>persist.payables(payables.map(x=>x.id===p.id?p:x))}
deletePayable={(id)=>persist.payables(payables.filter(x=>x.id!==id))}
markPayablePaid={markPayablePaid}
receivables={scoped(receivables)}
updateReceivable={(r)=>persist.receivables(receivables.map(x=>x.id===r.id?r:x))}
deleteReceivable={(id)=>persist.receivables(receivables.filter(x=>x.id!==id))}
markReceivablePaid={markReceivablePaid}
taxReturns={scoped(taxReturns)}
updateTaxReturn={(r)=>persist.taxReturns(taxReturns.map(x=>x.id===r.id?r:x))}
deleteTaxReturn={(id)=>persist.taxReturns(taxReturns.filter(x=>x.id!==id))}
selectedEntity={selectedEntity}
/>
)}
{activeTab === "arap" && (
<ReceivablesPayablesTab
payables={scoped(payables)} receivables={scoped(receivables)} contacts={scoped(contacts)}
addManualReceivable={addManualReceivable}
updatePayable={(p)=>persist.payables(payables.map(x=>x.id===p.id?p:x))}
deletePayable={(id)=>persist.payables(payables.filter(x=>x.id!==id))}
deleteReceivable={(id)=>persist.receivables(receivables.filter(x=>x.id!==id))}
markPayablePaid={markPayablePaid} markReceivablePaid={markReceivablePaid}
liabilities={scoped(liabilities)}
setLiabilities={(next)=>{ const others = liabilities.filter(l=>l.entityId!==selectedEntity); persist.liabilities(selectedEntity==="all"?next:[...others,...next]); }}
entity={selectedEntityObj} updateEntity={updateEntity}
selectedEntity={selectedEntity}
/>
)}
{activeTab === "assets" && (
<AssetsTab
assets={selectedEntity === "all" ? assets : assets.filter(a=>a.entityId===selectedEntity)}
setAssets={(next)=>{
if (selectedEntity==="all"){ persist.assets(next); return; }
const others = assets.filter(a=>a.entityId!==selectedEntity);
persist.assets([...others, ...next]);
}}
applyAssessment={applyAssessment}
transactions={visibleTx} selectedEntity={selectedEntity}
/>
)}
{activeTab === "inventory" && (
<InventoryTab
inventory={scoped(inventory)}
setInventory={(next)=>{
if (selectedEntity==="all"){ persist.inventory(next); return; }
const others = inventory.filter(i=>i.entityId!==selectedEntity);
persist.inventory([...others, ...next]);
}}
transactions={visibleTx} selectedEntity={selectedEntity}
/>
)}
{activeTab === "goals" && (
<GoalsTab
goals={selectedEntity === "all" ? goals : goals.filter(g=>g.entityId===selectedEntity)}
setGoals={(next)=>{
if (selectedEntity==="all"){ persist.goals(next); return; }
const others = goals.filter(g=>g.entityId!==selectedEntity);
persist.goals([...others, ...next]);
}}
transactions={visibleTx} selectedEntity={selectedEntity}
/>
)}
{activeTab === "bids" && (
<BidsTab
bids={selectedEntity === "all" ? bids : bids.filter(b=>b.entityId===selectedEntity)}
setBids={(next)=>{
if (selectedEntity==="all"){ persist.bids(next); return; }
const others = bids.filter(b=>b.entityId!==selectedEntity);
persist.bids([...others, ...next]);
}}
selectedEntity={selectedEntity}
/>
)}
{activeTab === "contacts" && (
<ContactsTab
contacts={scoped(contacts)}
setContacts={(next)=>{
if (selectedEntity==="all"){ persist.contacts(next); return; }
const others = contacts.filter(c=>c.entityId!==selectedEntity);
persist.contacts([...others, ...next]);
}}
selectedEntity={selectedEntity}
/>
)}
{activeTab === "entities" && (
<EntitiesTab entities={entities} setEntities={persist.entities} rules={rules} setRules={persist.rules} />
)}
</div>
</div>
</div>
);
}
/* ----------------------------- layout pieces ----------------------------- */
function Sidebar({ activeTab, setActiveTab }) {
const items = [
{ key: "dashboard", label: "Compilation", icon: LayoutDashboard },
{ key: "balanceSheet", label: "Balance Sheet", icon: Landmark },
{ key: "folders", label: "Folders", icon: Upload },
{ key: "arap", label: "Receivables & Payables", icon: ArrowLeftRight },
{ key: "assets", label: "Assets & Depreciation", icon: Building2 },
{ key: "inventory", label: "Inventory & COGS", icon: Boxes },
{ key: "contacts", label: "Contacts", icon: Users },
{ key: "goals", label: "Goals & Campaigns", icon: Target },
{ key: "bids", label: "Bids & Estimates", icon: ClipboardList },
{ key: "entities", label: "Entities & Memory", icon: Wallet }
];
return (
<div className="w-56 shrink-0 min-h-screen px-3 py-5 flex flex-col" style={{ background: "#16241B", borderRight: "1px solid #24382B" }}>
<div className="fcc-display text-[#E8E6D9] text-lg px-2 mb-1">Financial Command Center</div>
<div className="text-[11px] text-[#8A9A82] px-2 mb-6">a working ledger, by entity</div>
<div className="flex flex-col gap-1">
{items.map((it) => {
const Icon = it.icon;
const active = activeTab === it.key;
return (
<button
key={it.key}
onClick={() => setActiveTab(it.key)}
className="flex items-center gap-2.5 px-3 py-2 rounded-sm text-sm text-left"
style={{ background: active ? "#2B3F2C" : "transparent", color: active ? "#F2EFE2" : "#B7C2AC" }}
>
<Icon size={15} /> {it.label}
</button>
);
})}
</div>
<div className="mt-auto text-[10px] text-[#5B6A53] px-2 leading-relaxed">
Files are read in your browser session only and are not stored. Everything else \u2014 transactions, rules, assets, inventory, payables, receivables, tax return summaries, goals, and bids \u2014 persists automatically.
</div>
</div>
);
}
function TopBar({ entities, selectedEntity, setSelectedEntity, newEntityName, setNewEntityName, newEntityType, setNewEntityType, addEntity }) {
return (
<div className="fcc-paper px-6 py-3 flex items-center justify-between fcc-rule">
<div className="flex items-center gap-3">
<span className="text-xs uppercase tracking-wide text-[#5B6650] font-medium">Viewing</span>
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 bg-white" value={selectedEntity} onChange={(e) => setSelectedEntity(e.target.value)}>
<option value="all">All Entities (combined)</option>
{entities.map((e) => <option key={e.id} value={e.id}>{e.name} \u2014 {e.type}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<input
className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 bg-white w-44"
placeholder="New entity name\u2026" value={newEntityName} onChange={(e) => setNewEntityName(e.target.value)}
/>
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 bg-white" value={newEntityType} onChange={(e) => setNewEntityType(e.target.value)}>
<option>Individual</option><option>Family</option><option>Business</option><option>Nonprofit</option><option>Municipality</option>
</select>
<button onClick={addEntity} className="text-sm flex items-center gap-1 px-2.5 py-1 rounded-sm" style={{ background: "#1F2E22", color: "#EEF1E4" }}>
<Plus size={14} /> Add
</button>
</div>
</div>
);
}
/* ----------------------------- Dashboard ----------------------------- */
function Dashboard({ transactions, assets, inventory, goals, bids, payables, receivables, entityName, entityType, updateTx, applyRule }) {
const [groupBy, setGroupBy] = useState("category");
const pnlTx = transactions.filter((t) => !t.capitalized);
const income = pnlTx.filter((t) => t.direction === "income").reduce((s, t) => s + t.amount, 0);
const expense = pnlTx.filter((t) => t.direction === "expense").reduce((s, t) => s + t.amount, 0);
const net = income - expense;
const flagged = transactions.filter((t) => t.flagged);
const assetValue = assets.reduce((s, a) => s + computeAssetValue(a), 0);
const inventoryTotal = inventory.reduce((s, i) => s + inventoryValue(i), 0);
const pantry = computePantryEstimate(transactions);
const campaignProgress = goals.reduce((s, g) => s + (Number(g.currentAmount) || 0), 0);
const campaignTarget = goals.reduce((s, g) => s + (Number(g.targetAmount) || 0), 0);
const pendingBids = bids.filter((b) => b.status === "pending").reduce((s, b) => s + Number(b.amount || 0), 0);
const apOpen = payables.filter((p) => p.status === "open").reduce((s, p) => s + Number(p.amount || 0), 0);
const arOpen = receivables.filter((r) => r.status === "open").reduce((s, r) => s + Number(r.amount || 0), 0);
const filingType = filingTypeForEntityType(entityType);
const byCategory = useMemo(() => {
const map = {};
pnlTx.forEach((t) => {
if (!t.category) return;
const key = groupBy === "taxline" && filingType ? taxLineFor(filingType, t.direction, t.category) : t.category;
map[key] = map[key] || { name: key, income: 0, expense: 0 };
map[key][t.direction] += t.amount;
});
return Object.values(map).sort((a, b) => (b.income + b.expense) - (a.income + a.expense)).slice(0, 8);
}, [transactions, groupBy, filingType]);
return (
<div className="max-w-5xl">
<h1 className="fcc-display text-2xl mb-1">The Compilation</h1>
<p className="text-sm text-[#5B6650] mb-5">Every folder rolled into one picture of economic health.</p>
<div className="grid grid-cols-4 gap-3 mb-6">
<StatCard label="Total Income" value={fmt(income)} accent="#2E5339" />
<StatCard label="Total Expense" value={fmt(expense)} accent="#7A2E2E" />
<StatCard label="Net Position" value={fmt(net)} accent={net >= 0 ? "#2E5339" : "#7A2E2E"} />
<StatCard label="Needs Review" value={flagged.length} accent="#A6792B" sub={flagged.length ? "scroll down to resolve" : "all clear"} />
</div>
<div className="grid grid-cols-4 gap-3 mb-6">
<StatCard label="Asset Value" value={fmt(assetValue + inventoryTotal + pantry.total)} sub={`incl. ${fmt(inventoryTotal)} tracked inventory, ${fmt(pantry.total)} estimated pantry`} />
<StatCard label="Receivable / Payable" value={`${fmt(arOpen)} / ${fmt(apOpen)}`} sub="open AR / open AP \u2014 see full ledger" />
<StatCard label="Campaign / Goal Progress" value={`${fmt(campaignProgress)} of ${fmt(campaignTarget)}`} sub={campaignTarget ? `${Math.round(100*campaignProgress/campaignTarget)}% funded` : "no goals yet"} />
<StatCard label="Pending Bid Exposure" value={fmt(pendingBids)} sub="quotes awaiting decision" />
</div>
{byCategory.length > 0 && (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-4 mb-6">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="text-sm font-medium">Income & Expense by {groupBy === "taxline" ? `${filingType} Line` : "Category"}</div>
{filingType ? (
<div className="flex gap-1">
<button onClick={() => setGroupBy("category")} className="text-[11px] px-2 py-1 rounded-sm border" style={{ background: groupBy === "category" ? "#1F2E22" : "transparent", color: groupBy === "category" ? "#EEF1E4" : "#5B6650", borderColor: "#C5CCB6" }}>Category</button>
<button onClick={() => setGroupBy("taxline")} className="text-[11px] px-2 py-1 rounded-sm border" style={{ background: groupBy === "taxline" ? "#1F2E22" : "transparent", color: groupBy === "taxline" ? "#EEF1E4" : "#5B6650", borderColor: "#C5CCB6" }}>{filingType} Line</button>
</div>
) : (
<div className="text-[11px] text-[#9AA688]">{entityType ? `IRS line grouping isn't applicable to ${entityType} books \u2014 municipalities and individuals don't map onto a 990 or Schedule C.` : "Pick a Nonprofit or Business entity above to group this by IRS tax return line."}</div>
)}
</div>
<ResponsiveContainer width="100%" height={Math.max(220, byCategory.length * 34)}>
<BarChart data={byCategory} layout="vertical" margin={{ left: 10, right: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#D7DCC6" horizontal={false} />
<XAxis type="number" tickFormatter={(v) => `$${v}`} fontSize={11} />
<YAxis type="category" dataKey="name" width={170} fontSize={11} />
<Tooltip formatter={(v) => fmt(v)} />
<Bar dataKey="income" stackId="a" fill="#5C8264" name="Income" />
<Bar dataKey="expense" stackId="a" fill="#A6463F" name="Expense" />
</BarChart>
</ResponsiveContainer>
{groupBy === "taxline" && <p className="text-[11px] text-[#9AA688] mt-2">Approximate mapping for organizing records, not a filing position \u2014 confirm exact line placement with your preparer.</p>}
</div>
)}
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-3"><AlertTriangle size={15} className="text-[#A6792B]" /> Needs Review ({flagged.length})</div>
{flagged.length === 0 ? (
<div className="text-sm text-[#7A8568]">Nothing waiting on a category right now.</div>
) : (
<div className="flex flex-col gap-2">
{flagged.map((t) => (
<div key={t.id} className="flex items-center justify-between gap-3 py-1.5 fcc-rule text-sm">
<div className="flex-1 min-w-0">
<div className="truncate">{t.description || t.vendor}</div>
<div className="text-[11px] text-[#7A8568]">{t.date || "no date"} \u00b7 {FOLDERS_FLAT.find(f=>f.key===t.folder)?.label || t.folder} \u00b7 {entityName(t.entityId)}</div>
</div>
<div className="fcc-mono text-sm w-24 text-right">{fmt(t.amount)}</div>
<CategorySelect
direction={t.direction} category={t.category} subcategory={t.subcategory}
onChange={({ direction, category, subcategory }) => {
const updated = { ...t, direction, category, subcategory, flagged: false };
updateTx(updated);
applyRule(vendorKey(t.vendor), category, subcategory, direction);
}}
/>
</div>
))}
</div>
)}
</div>
{pantry.breakdown.length > 0 && (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-4 mt-6">
<div className="text-sm font-medium mb-1">Pantry Stock Estimate</div>
<div className="text-[11px] text-[#7A8568] mb-3">Estimated value = average purchase amount \u00d7 (1 \u2212 days since last purchase \u00f7 average days between purchases). Items with only one purchase assume a 14-day cycle.</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
{pantry.breakdown.slice(0, 12).map((b, i) => (
<div key={i} className="flex justify-between fcc-rule py-1">
<span className="truncate pr-2">{b.name} <span className="text-[11px] text-[#9AA688]">\u00d7{b.purchaseCount}</span></span>
<span className="fcc-mono">{fmt(b.estValue)}</span>
</div>
))}
</div>
<div className="flex justify-end fcc-double mt-2 pt-1 text-sm font-medium">Total: {fmt(pantry.total)}</div>
</div>
)}
</div>
);
}
/* ----------------------------- Folders ----------------------------- */
function FoldersTab({ activeFolder, setActiveFolder, transactions, ingestTransactions, ingestPayables, ingestReceivablesFromFolder, ingestSalesReport, ingestTaxReturns, applyRule, updateTx, deleteTx, capitalizeAsAsset, addToInventory, payables, updatePayable, deletePayable, markPayablePaid, receivables, updateReceivable, deleteReceivable, markReceivablePaid, taxReturns, updateTaxReturn, deleteTaxReturn, selectedEntity }) {
const folder = FOLDERS_FLAT.find((f) => f.key === activeFolder);
const activeGroup = FOLDER_GROUPS.find((g) => leavesOf(g).some((c) => c.key === activeFolder)) || FOLDER_GROUPS[0];
const isPayableFolder = activeFolder === "vendorInvoices" || activeFolder === "vendorBills";
const isReceivableFolder = activeFolder === "customerInvoices" || activeFolder === "customerPledges";
const isSalesReportFolder = activeFolder === "salesReports";
const isTaxFolder = activeFolder === "taxReturns";
const isSpecialFolder = isPayableFolder || isReceivableFolder || isSalesReportFolder || isTaxFolder;
const folderTx = transactions.filter((t) => t.folder === activeFolder);
function onExtracted(raw, fileName) {
if (isPayableFolder) return ingestPayables(raw, fileName, activeFolder);
if (isReceivableFolder) return ingestReceivablesFromFolder(raw, fileName, activeFolder);
if (isSalesReportFolder) return ingestSalesReport(raw, fileName);
if (isTaxFolder) return ingestTaxReturns(raw, fileName);
return ingestTransactions(raw, activeFolder, fileName);
}
return (
<div className="max-w-5xl">
<h1 className="fcc-display text-2xl mb-1">Folders</h1>
<p className="text-sm text-[#5B6650] mb-5">Drop a file where it belongs. It feeds straight into the compilation.</p>
<div className="flex gap-1.5 mb-2 flex-wrap">
{FOLDER_GROUPS.map((g) => {
const Icon = g.icon;
const active = g.key === activeGroup.key;
return (
<button key={g.key} onClick={() => setActiveFolder(leavesOf(g)[0].key)}
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-sm border"
style={{ background: active ? "#1F2E22" : "#EEF1E4", color: active ? "#EEF1E4" : "#1F2E22", borderColor: "#C5CCB6" }}>
<Icon size={14} /> {g.label}
</button>
);
})}
</div>
{activeGroup.children && (
<div className="flex gap-1.5 mb-5 flex-wrap pl-1">
{activeGroup.children.map((c) => {
const active = c.key === activeFolder;
return (
<button key={c.key} onClick={() => setActiveFolder(c.key)}
className="text-xs px-2.5 py-1 rounded-full border"
style={{ background: active ? "#2B3F2C" : "transparent", color: active ? "#F2EFE2" : "#5B6650", borderColor: "#C5CCB6" }}>
{c.label}
</button>
);
})}
</div>
)}
{!activeGroup.children && <div className="mb-5" />}
{selectedEntity === "all" && (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#F6E9E9", color: "#7A2E2E" }}>
Pick a specific entity at the top of the page before uploading \u2014 every {isPayableFolder ? "bill" : isReceivableFolder ? "invoice" : isSalesReportFolder ? "sale" : isTaxFolder ? "return" : "transaction"} needs a home.
</div>
)}
{isPayableFolder && (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#EEF1E4", color: "#5B6650", border: "1px solid #C5CCB6" }}>
A vendor bill isn't paid yet \u2014 it becomes an open Accounts Payable balance until someone marks it paid, here or on the Receivables & Payables page.
</div>
)}
{isReceivableFolder && (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#EEF1E4", color: "#5B6650", border: "1px solid #C5CCB6" }}>
A customer invoice, membership renewal, or pledge becomes an open Accounts Receivable balance until it's marked paid, here or on the Receivables & Payables page.
</div>
)}
{isSalesReportFolder && (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#EEF1E4", color: "#5B6650", border: "1px solid #C5CCB6" }}>
Units sold are matched by name against your Inventory & COGS ledger to calculate cost of goods sold automatically. Items with no matching inventory entry won't be deducted until you add them there with a unit cost.
</div>
)}
{isTaxFolder && (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#EEF1E4", color: "#5B6650", border: "1px solid #C5CCB6" }}>
This reads the bottom-line totals off a filed return for recordkeeping and year-over-year comparison. It isn't tax preparation or advice \u2014 confirm anything that matters with your accountant.
</div>
)}
<Dropzone folder={folder} onExtracted={onExtracted} disabled={selectedEntity === "all"} />
{!isSpecialFolder && (
<ManualAddTransaction folderKey={activeFolder} onAdd={(tx) => ingestTransactions([tx], activeFolder, "manual entry")} disabled={selectedEntity === "all"} />
)}
{isPayableFolder ? (
<PayablesFolderView payables={payables} updatePayable={updatePayable} deletePayable={deletePayable} markPayablePaid={markPayablePaid} applyRule={applyRule} />
) : isReceivableFolder ? (
<ReceivablesFolderView receivables={receivables} updateReceivable={updateReceivable} deleteReceivable={deleteReceivable} markReceivablePaid={markReceivablePaid} applyRule={applyRule} />
) : isTaxFolder ? (
<TaxReturnsFolderView taxReturns={taxReturns} updateTaxReturn={updateTaxReturn} deleteTaxReturn={deleteTaxReturn} transactions={transactions} />
) : (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm mt-5">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">{folder.groupLabel && folder.groupLabel !== folder.label ? `${folder.groupLabel} \u2014 ${folder.label}` : folder.label} \u2014 {folderTx.length} transaction{folderTx.length === 1 ? "" : "s"}</div>
{folderTx.length === 0 ? (
<div className="px-4 py-6 text-sm text-[#7A8568]">Nothing here yet.</div>
) : (
<div className="divide-y divide-[#D7DCC6]">
{folderTx.map((t) => (
<div key={t.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-[160px]">
<div className="truncate flex items-center gap-2">
{t.description || t.vendor}
{t.flagged && <FlagBadge />}
{t.capitalized && (
<span className="text-[11px] font-semibold px-2 py-0.5 rounded-full border" style={{ color: "#2E5339", borderColor: "#2E5339" }}>
{t.category === "Capital Improvements" ? "CAPITALIZED \u2192 ASSET" : "\u2192 INVENTORY"}
</span>
)}
</div>
<div className="text-[11px] text-[#7A8568] truncate">
{t.date || "no date"} \u00b7 {t.vendor}
{t.handwrittenNote ? ` \u00b7 note: \u201c${t.handwrittenNote}\u201d` : ""}
{t.sourceFile ? ` \u00b7 ${t.sourceFile}` : ""}
</div>
</div>
<div className="fcc-mono w-24 text-right" style={{ color: t.direction === "income" ? "#2E5339" : "#7A2E2E" }}>{fmt(t.amount)}</div>
<CategorySelect
direction={t.direction} category={t.category} subcategory={t.subcategory}
onChange={({ direction, category, subcategory }) => {
const updated = { ...t, direction, category, subcategory, flagged: false };
updateTx(updated);
applyRule(vendorKey(t.vendor), category, subcategory, direction);
}}
/>
{!t.capitalized && t.direction === "expense" && !isSalesReportFolder && (
<>
<button onClick={() => capitalizeAsAsset(t)} title="Treat this as an equipment purchase instead of a one-time expense, and start depreciating it" className="text-[11px] px-2 py-1 rounded-sm border" style={{ color: "#5B6650", borderColor: "#C5CCB6" }}>\u2192 Asset</button>
<AddToInventoryButton tx={t} onAdd={addToInventory} />
</>
)}
<button onClick={() => deleteTx(t.id)} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
function PayablesFolderView({ payables, updatePayable, deletePayable, markPayablePaid, applyRule }) {
return (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm mt-5">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">Accounts Payable \u2014 {payables.length} bill{payables.length === 1 ? "" : "s"}</div>
{payables.length === 0 ? (
<div className="px-4 py-6 text-sm text-[#7A8568]">Nothing here yet. Drop in a vendor bill \u2014 a soda delivery invoice for the concession stand, a printer repair bill, anything owed but not yet paid.</div>
) : (
<div className="divide-y divide-[#D7DCC6]">
{payables.map((p) => (
<div key={p.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-[160px]">
<div className="truncate flex items-center gap-2">
{p.vendorName}{p.invoiceNumber ? ` \u00b7 #${p.invoiceNumber}` : ""}
{p.flagged && <FlagBadge />}
</div>
<div className="text-[11px] text-[#7A8568] truncate">
{p.date || "no date"}{p.dueDate ? ` \u00b7 due ${p.dueDate}` : ""}{p.sourceFile ? ` \u00b7 ${p.sourceFile}` : ""}{p.folder === "vendorBills" ? " \u00b7 bill" : " \u00b7 invoice"}
</div>
</div>
<StatusBadge status={p.status} dueDate={p.dueDate} />
<div className="fcc-mono w-24 text-right text-[#7A2E2E]">{fmt(p.amount)}</div>
<CategorySelect
direction="expense" category={p.category} subcategory={p.subcategory}
onChange={({ category, subcategory }) => {
updatePayable({ ...p, category, subcategory, flagged: false });
applyRule(vendorKey(p.vendorName), category, subcategory, "expense");
}}
/>
<InlineField value={p.notes} placeholder="notes\u2026" className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white w-32" onSave={(v) => updatePayable({ ...p, notes: v })} />
{p.status === "open" ? (
<button onClick={() => markPayablePaid(p, new Date().toISOString().slice(0, 10))} className="text-[11px] px-2 py-1 rounded-sm" style={{ background: "#1F2E22", color: "#EEF1E4" }}>Mark Paid</button>
) : (
<span className="text-[11px] text-[#7A8568]">paid</span>
)}
<button onClick={() => deletePayable(p.id)} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
</div>
);
}
function ReceivablesFolderView({ receivables, updateReceivable, deleteReceivable, markReceivablePaid, applyRule }) {
return (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm mt-5">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">Accounts Receivable \u2014 {receivables.length} invoice{receivables.length === 1 ? "" : "s"}</div>
{receivables.length === 0 ? (
<div className="px-4 py-6 text-sm text-[#7A8568]">Nothing here yet. Drop in a membership renewal, a pledge commitment, or a customer invoice \u2014 anything owed to you but not yet paid.</div>
) : (
<div className="divide-y divide-[#D7DCC6]">
{receivables.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-[160px]">
<div className="truncate flex items-center gap-2">
{r.customerName}{r.invoiceNumber ? ` \u00b7 #${r.invoiceNumber}` : ""}
{r.flagged && <FlagBadge />}
</div>
<div className="text-[11px] text-[#7A8568] truncate">
{r.date || "no date"}{r.dueDate ? ` \u00b7 due ${r.dueDate}` : ""}{r.sourceFile ? ` \u00b7 ${r.sourceFile}` : ""}{r.folder === "customerPledges" ? " \u00b7 pledge/membership" : " \u00b7 invoice"}
</div>
</div>
<StatusBadge status={r.status} dueDate={r.dueDate} />
<div className="fcc-mono w-24 text-right text-[#2E5339]">{fmt(r.amount)}</div>
<CategorySelect
direction="income" category={r.category} subcategory={r.subcategory}
onChange={({ category, subcategory }) => {
updateReceivable({ ...r, category, subcategory, flagged: false });
applyRule(vendorKey(r.customerName), category, subcategory, "income");
}}
/>
<InlineField value={r.notes} placeholder="notes\u2026" className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white w-32" onSave={(v) => updateReceivable({ ...r, notes: v })} />
{r.status === "open" ? (
<button onClick={() => markReceivablePaid(r, new Date().toISOString().slice(0, 10))} className="text-[11px] px-2 py-1 rounded-sm" style={{ background: "#1F2E22", color: "#EEF1E4" }}>Mark Paid</button>
) : (
<span className="text-[11px] text-[#7A8568]">paid</span>
)}
<button onClick={() => deleteReceivable(r.id)} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
</div>
);
}
function TaxReturnsFolderView({ taxReturns, updateTaxReturn, deleteTaxReturn, transactions }) {
const [estRate, setEstRate] = useState("");
const sorted = [...taxReturns].sort((a, b) => (b.taxYear || 0) - (a.taxYear || 0));
const mostRecent = sorted[0];
const thisYear = new Date().getFullYear();
const ytdTx = transactions.filter((t) => !t.capitalized && t.date && new Date(t.date).getFullYear() === thisYear);
const ytdIncome = ytdTx.filter((t) => t.direction === "income").reduce((s, t) => s + t.amount, 0);
const ytdExpense = ytdTx.filter((t) => t.direction === "expense").reduce((s, t) => s + t.amount, 0);
const ytdNet = ytdIncome - ytdExpense;
const rate = Number(estRate) || 0;
const setAside = ytdNet > 0 ? ytdNet * (rate / 100) : 0;
const pctChange = mostRecent && mostRecent.netIncome ? ((ytdNet - mostRecent.netIncome) / Math.abs(mostRecent.netIncome)) * 100 : null;
return (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm mt-5">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">Filed Returns \u2014 {taxReturns.length} year{taxReturns.length === 1 ? "" : "s"} on record</div>
{taxReturns.length === 0 ? (
<div className="px-4 py-6 text-sm text-[#7A8568]">Nothing here yet. Drop a prior year's filed return \u2014 Form 990, 1040, Schedule C, whatever applies \u2014 to start building a year-over-year picture.</div>
) : (
<div className="divide-y divide-[#D7DCC6]">
{sorted.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 px-4 py-2.5 text-sm">
<div className="w-16 fcc-mono font-medium">{r.taxYear || "?"}</div>
<div className="w-28 text-[11px] text-[#7A8568]">{r.filingType || "unspecified"}</div>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">Income</div><div className="fcc-mono">{r.totalIncome != null ? fmt(r.totalIncome) : "\u2014"}</div></div>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">Expenses</div><div className="fcc-mono">{r.totalExpenses != null ? fmt(r.totalExpenses) : "\u2014"}</div></div>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">Net</div><div className="fcc-mono">{r.netIncome != null ? fmt(r.netIncome) : "\u2014"}</div></div>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">Tax / Refund</div><div className="fcc-mono">{r.taxLiability != null ? fmt(r.taxLiability) : r.refundAmount != null ? `(${fmt(r.refundAmount)})` : "\u2014"}</div></div>
<InlineField value={r.notes} placeholder="notes\u2026" className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white flex-1 min-w-[100px]" onSave={(v) => updateTaxReturn({ ...r, notes: v })} />
<button onClick={() => deleteTaxReturn(r.id)} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
<div className="px-4 py-3 fcc-rule">
<div className="text-sm font-medium mb-1">This Year So Far ({thisYear})</div>
<div className="text-sm flex flex-wrap gap-4">
<span>Income: <span className="fcc-mono">{fmt(ytdIncome)}</span></span>
<span>Expense: <span className="fcc-mono">{fmt(ytdExpense)}</span></span>
<span>Net: <span className="fcc-mono">{fmt(ytdNet)}</span></span>
{pctChange != null && <span className="text-[#7A8568]">{pctChange >= 0 ? "up" : "down"} {Math.abs(pctChange).toFixed(0)}% vs. {mostRecent.taxYear} filed net</span>}
</div>
</div>
<div className="px-4 py-3">
<div className="text-sm font-medium mb-1">Rough Set-Aside Estimate</div>
<div className="flex items-center gap-2 text-sm flex-wrap">
<span>If this year nets out the same, setting aside</span>
<input type="number" placeholder="rate %" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-20" value={estRate} onChange={(e) => setEstRate(e.target.value)} />
<span>% comes to <span className="fcc-mono">{fmt(setAside)}</span></span>
</div>
<p className="text-[11px] text-[#9AA688] mt-2">This uses a rate you supply \u2014 it isn't a tax calculation. Actual liability depends on entity type, deductions, and the current year's tax law. Confirm with your accountant or tax preparer each season.</p>
</div>
</div>
);
}
function AddToInventoryButton({ tx, onAdd }) {
const [open, setOpen] = useState(false);
const [qty, setQty] = useState("1");
const [subcategory, setSubcategory] = useState("Raw Material");
if (!open) {
return (
<button onClick={() => setOpen(true)} title="Treat this as inventory on hand instead of an immediate expense; it's expensed later as Cost of Goods Sold when it's sold" className="text-[11px] px-2 py-1 rounded-sm border" style={{ color: "#5B6650", borderColor: "#C5CCB6" }}>\u2192 Inventory</button>
);
}
return (
<div className="flex items-center gap-1.5 fcc-paper border border-[#C5CCB6] rounded-sm px-2 py-1">
<select className="text-[11px] border border-[#C5CCB6] rounded-sm px-1 py-0.5 bg-white" value={subcategory} onChange={(e) => setSubcategory(e.target.value)}>
{INVENTORY_SUBCATEGORIES.map((s) => <option key={s}>{s}</option>)}
</select>
<input type="number" className="text-[11px] border border-[#C5CCB6] rounded-sm px-1 py-0.5 w-14" placeholder="qty" value={qty} onChange={(e) => setQty(e.target.value)} />
<button onClick={() => { onAdd(tx, { subcategory, quantity: qty }); setOpen(false); }} className="text-[11px] px-2 py-0.5 rounded-sm" style={{ background: "#1F2E22", color: "#EEF1E4" }}>Add</button>
<button onClick={() => setOpen(false)} className="text-[11px] text-[#7A8568]">\u2715</button>
</div>
);
}
function Dropzone({ folder, onExtracted, disabled }) {
const [over, setOver] = useState(false);
const [queue, setQueue] = useState([]);
const inputRef = useRef(null);
const Icon = folder.icon;
async function handleFiles(fileList) {
const files = Array.from(fileList);
for (const file of files) {
const id = uid();
setQueue((q) => [...q, { id, name: file.name, status: "processing" }]);
try {
const ext = file.name.split(".").pop().toLowerCase();
let raw;
if (folder.key === "fundraising" && ext === "csv") {
const rows = await parseCsvFile(file);
raw = mapFundraisingRows(rows);
} else {
let block;
if (["png", "jpg", "jpeg", "webp"].includes(ext)) {
const b64 = await fileToBase64(file);
const mediaType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
block = { type: "image", source: { type: "base64", media_type: mediaType, data: b64 } };
} else if (ext === "pdf") {
const b64 = await fileToBase64(file);
block = { type: "document", source: { type: "base64", media_type: "application/pdf", data: b64 } };
} else if (ext === "docx") {
const text = await extractDocxText(file);
block = { type: "text", text: `Document text:\n${text.slice(0, 6000)}` };
} else {
const text = await file.text();
block = { type: "text", text: `Document text:\n${text.slice(0, 6000)}` };
}
const instruction = { type: "text", text: `Analyze this ${folder.label} document and extract its ${folder.key === "salesReports" ? "sales lines" : folder.key === "taxReturns" ? "summary figures" : "transactions"}.` };
if (folder.key === "salesReports") {
const aiText = await callClaude([block, instruction], buildSalesReportPrompt());
const parsed = parseAiJson(aiText);
raw = parsed.salesLines || [];
} else if (folder.key === "taxReturns") {
const aiText = await callClaude([block, instruction], buildTaxReturnPrompt());
const parsed = parseAiJson(aiText);
raw = parsed.returns || [];
} else {
const aiText = await callClaude([block, instruction], buildSystemPrompt(folder.key));
const parsed = parseAiJson(aiText);
raw = parsed.transactions || [];
}
}
const outcome = onExtracted(raw, file.name);
const count = outcome && outcome.count != null ? outcome.count : raw.length;
const note = outcome && outcome.note;
setQueue((q) => q.map((item) => item.id === id ? { ...item, status: "done", count, note } : item));
} catch (err) {
setQueue((q) => q.map((item) => item.id === id ? { ...item, status: "error", error: String(err.message || err) } : item));
}
}
}
return (
<div>
<div
className={`fcc-dropzone fcc-paper border-2 border-dashed rounded-sm p-8 text-center ${over ? "over" : ""}`}
style={{ borderColor: "#C5CCB6", opacity: disabled ? 0.5 : 1, pointerEvents: disabled ? "none" : "auto" }}
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); handleFiles(e.dataTransfer.files); }}
onClick={() => inputRef.current?.click()}
>
<Icon className="mx-auto mb-2 text-[#5B6650]" size={26} />
<div className="text-sm font-medium">Drop {folder.groupLabel && folder.groupLabel !== folder.label ? `${folder.groupLabel} \u2014 ${folder.label}`.toLowerCase() : folder.label.toLowerCase()} here, or click to browse</div>
<div className="text-[11px] text-[#7A8568] mt-1">Accepts {folder.accept.replace(/\./g, " ").trim()}</div>
<input ref={inputRef} type="file" multiple accept={folder.accept} className="hidden" onChange={(e) => handleFiles(e.target.files)} />
</div>
{queue.length > 0 && (
<div className="mt-3 flex flex-col gap-1.5">
{queue.map((item) => (
<div key={item.id} className="flex items-center gap-2 text-xs">
{item.status === "processing" && <Loader2 size={13} className="animate-spin text-[#A6792B]" />}
{item.status === "done" && <CheckCircle2 size={13} className="text-[#2E5339]" />}
{item.status === "error" && <AlertTriangle size={13} className="text-[#7A2E2E]" />}
<span className="truncate">{item.name}</span>
<span className="text-[#7A8568]">
{item.status === "processing" && "reading\u2026"}
{item.status === "done" && `\u2014 ${item.count} item${item.count === 1 ? "" : "s"} added${item.note ? ` \u2014 ${item.note}` : ""}`}
{item.status === "error" && `\u2014 ${item.error}`}
</span>
</div>
))}
</div>
)}
</div>
);
}
function ManualAddTransaction({ folderKey, onAdd, disabled }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ date: "", description: "", vendor: "", amount: "", direction: "expense", handwrittenNote: "" });
function submit() {
if (!form.description || !form.amount) return;
onAdd({
date: form.date || null, description: form.description, vendor: form.vendor || form.description,
amount: Math.abs(Number(form.amount)) || 0, direction: form.direction,
suggestedCategory: "Uncategorized", suggestedSubcategory: "", confidence: 1,
handwrittenNote: folderKey === "bankDeposits" ? (form.handwrittenNote || null) : null, lineItems: null
});
setForm({ date: "", description: "", vendor: "", amount: "", direction: "expense", handwrittenNote: "" });
setOpen(false);
}
if (disabled) return null;
return (
<div className="mt-3">
{!open ? (
<button onClick={() => setOpen(true)} className="text-sm flex items-center gap-1.5 text-[#1F2E22]">
<Plus size={14} /> Add a transaction by hand
</button>
) : (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3 flex flex-wrap gap-2 items-end">
<input type="date" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
<input placeholder="Description" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[140px]" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<input placeholder="Vendor / payer" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-36" value={form.vendor} onChange={(e) => setForm({ ...form, vendor: e.target.value })} />
<input placeholder="Amount" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-24" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.direction} onChange={(e) => setForm({ ...form, direction: e.target.value })}>
<option value="expense">Expense</option><option value="income">Income</option>
</select>
{folderKey === "bankDeposits" && (
<input placeholder="What the handwritten note said\u2026" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[160px]" value={form.handwrittenNote} onChange={(e) => setForm({ ...form, handwrittenNote: e.target.value })} />
)}
<button onClick={submit} className="text-sm px-3 py-1 rounded-sm" style={{ background: "#1F2E22", color: "#EEF1E4" }}>Save</button>
<button onClick={() => setOpen(false)} className="text-sm px-2 py-1 text-[#7A8568]">Cancel</button>
</div>
)}
</div>
);
}
/* ----------------------------- Assets & Depreciation ----------------------------- */
function AssetsTab({ assets, setAssets, applyAssessment, transactions, selectedEntity }) {
const [form, setForm] = useState({ name: "", type: "Equipment & Appliances", purchaseDate: "", cost: "", usefulLifeYears: "7", salvageValue: "0", currentMarketValue: "" });
const [expandedId, setExpandedId] = useState(null);
const pantry = computePantryEstimate(transactions);
function addAsset() {
if (!form.name || !form.cost || selectedEntity === "all") return;
setAssets([...assets, { id: uid(), entityId: selectedEntity, ...form }]);
setForm({ name: "", type: "Equipment & Appliances", purchaseDate: "", cost: "", usefulLifeYears: "7", salvageValue: "0", currentMarketValue: "" });
}
const total = assets.reduce((s, a) => s + computeAssetValue(a), 0) + pantry.total;
const depreciable = assets.filter((a) => a.type === "Equipment & Appliances" || isSplitRealProperty(a));
const totalAccumDep = depreciable.reduce((s, a) => s + (a.type === "Equipment & Appliances" ? computeDepreciationSchedule(a).accumulatedDep : realPropertyImprovementSchedule(a).accumulatedDep), 0);
return (
<div className="max-w-5xl">
<h1 className="fcc-display text-2xl mb-1">Assets & Depreciation</h1>
<p className="text-sm text-[#5B6650] mb-5">Equipment depreciates on a straight line. Real estate depreciates on the building/improvement portion once a land/improvement split is known \u2014 land itself never depreciates. Investments carry a market value you update by hand. Click an asset to open it \u2014 photo, notes, and full detail live there.</p>
{selectedEntity === "all" ? (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#F6E9E9", color: "#7A2E2E" }}>Pick a specific entity to add assets.</div>
) : (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3 mb-5 flex flex-wrap gap-2 items-end">
<input placeholder="Asset name" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[140px]" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
{ASSET_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
<input type="date" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.purchaseDate} onChange={(e) => setForm({ ...form, purchaseDate: e.target.value })} />
<input placeholder="Cost" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-24" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
{form.type === "Equipment & Appliances" ? (
<>
<input placeholder="Useful life (yrs)" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={form.usefulLifeYears} onChange={(e) => setForm({ ...form, usefulLifeYears: e.target.value })} />
<input placeholder="Salvage value" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={form.salvageValue} onChange={(e) => setForm({ ...form, salvageValue: e.target.value })} />
</>
) : (
<input placeholder="Current market value (optional)" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-44" value={form.currentMarketValue} onChange={(e) => setForm({ ...form, currentMarketValue: e.target.value })} />
)}
<button onClick={addAsset} className="text-sm px-3 py-1 rounded-sm flex items-center gap-1" style={{ background: "#1F2E22", color: "#EEF1E4" }}><Plus size={13} /> Add</button>
</div>
)}
{selectedEntity !== "all" && (
<p className="text-[11px] text-[#9AA688] mb-4">For Real Property, add it first with a total market value, then open it and use \u201cUpdate from Assessment,\u201d or enter the land/improvement split by hand, to start depreciating the building portion.</p>
)}
<div className="fcc-paper border border-[#C5CCB6] rounded-sm">
<div className="divide-y divide-[#D7DCC6]">
{assets.map((a) => {
const isEquip = a.type === "Equipment & Appliances";
const isSplitProperty = isSplitRealProperty(a);
const sched = isEquip ? computeDepreciationSchedule(a) : isSplitProperty ? realPropertyImprovementSchedule(a) : null;
const expanded = expandedId === a.id;
return (
<div key={a.id}>
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5 text-sm cursor-pointer" onClick={() => setExpandedId(expanded ? null : a.id)}>
<span className="text-[#9AA688] text-xs w-3">{expanded ? "\u25BE" : "\u25B8"}</span>
<div className="flex-1 min-w-[160px]">
<div>{a.name}</div>
<div className="text-[11px] text-[#7A8568]">
{a.type} {a.purchaseDate ? `\u00b7 purchased ${a.purchaseDate}` : ""}
{isSplitProperty ? ` \u00b7 land ${fmt(a.landValue)} + improvement ${fmt(a.improvementValue)}` : ""}
{a.lastAssessedYear ? ` \u00b7 last assessed ${a.lastAssessedYear}` : ""}
</div>
</div>
{sched ? (
<>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">{isSplitProperty ? "Improvement" : "Cost"}</div><div className="fcc-mono">{fmt(sched.cost)}</div></div>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">Accum. Dep.</div><div className="fcc-mono text-[#A6792B]">{fmt(sched.accumulatedDep)}</div></div>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">Book Value</div><div className="fcc-mono">{fmt(isSplitProperty ? (Number(a.landValue) || 0) + sched.bookValue : sched.bookValue)}</div></div>
<div className="text-right w-28"><div className="text-[10px] text-[#7A8568] uppercase">Fully Dep. By</div><div className="text-xs">{sched.isFullyDepreciated ? "fully depreciated" : sched.fullyDepreciatedDate.toISOString().slice(0, 10)}</div></div>
</>
) : (
<div className="fcc-mono w-28 text-right">{fmt(computeAssetValue(a))}</div>
)}
<button onClick={(e) => { e.stopPropagation(); setAssets(assets.filter((x) => x.id !== a.id)); }} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
{expanded && (
<AssetDetail
asset={a}
onUpdate={(fields) => setAssets(assets.map((x) => x.id === a.id ? { ...x, ...fields } : x))}
applyAssessment={applyAssessment}
/>
)}
</div>
);
})}
{pantry.total > 0 && (
<div className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="flex-1">
<div>Pantry & Grocery Stock (estimated)</div>
<div className="text-[11px] text-[#7A8568]">derived automatically from grocery receipts \u2014 see Compilation for the line-item breakdown</div>
</div>
<div className="fcc-mono w-28 text-right">{fmt(pantry.total)}</div>
</div>
)}
</div>
<div className="flex flex-wrap justify-end gap-6 mx-4 mt-1 mb-3 pt-1 text-sm">
{depreciable.length > 0 && <span>Total accumulated depreciation: <span className="fcc-mono">{fmt(totalAccumDep)}</span></span>}
<span className="fcc-double font-medium">Total asset value: {fmt(total)}</span>
</div>
</div>
</div>
);
}
function AssetDetail({ asset, onUpdate, applyAssessment }) {
const [image, setImage] = useState(null);
const [loadingImage, setLoadingImage] = useState(true);
const inputRef = useRef(null);
const isEquip = asset.type === "Equipment & Appliances";
const isProperty = asset.type === "Real Property";
useEffect(() => {
let active = true;
(async () => {
try {
const r = await window.storage.get(`asset-image:${asset.id}`, false);
if (active && r) setImage(r.value);
} catch (e) { /* no image saved yet */ }
if (active) setLoadingImage(false);
})();
return () => { active = false; };
}, [asset.id]);
async function handleImage(file) {
const b64 = await fileToBase64(file);
const ext = file.name.split(".").pop().toLowerCase();
const mediaType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
const dataUrl = `data:${mediaType};base64,${b64}`;
setImage(dataUrl);
try { await window.storage.set(`asset-image:${asset.id}`, dataUrl, false); } catch (e) { /* prototype best-effort */ }
}
return (
<div className="px-4 py-4 flex flex-wrap gap-5" style={{ background: "#E3E8D4", borderBottom: "1px solid #D7DCC6" }}>
<div className="w-40 shrink-0">
{loadingImage ? (
<div className="h-28 w-40 rounded-sm bg-[#D7DCC6]" />
) : image ? (
<img src={image} alt={asset.name} className="h-28 w-40 object-cover rounded-sm border border-[#C5CCB6]" />
) : (
<div className="h-28 w-40 rounded-sm border border-dashed border-[#C5CCB6] flex items-center justify-center text-center text-[11px] text-[#9AA688] px-2">No photo yet</div>
)}
<button onClick={() => inputRef.current?.click()} className="text-[11px] mt-1.5 px-2 py-1 rounded-sm border w-full" style={{ color: "#5B6650", borderColor: "#C5CCB6", background: "#EEF1E4" }}>{image ? "Replace photo" : "Add photo"}</button>
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={(e) => { if (e.target.files[0]) handleImage(e.target.files[0]); }} />
</div>
<div className="flex-1 min-w-[260px] flex flex-col gap-2.5">
{isProperty && (
<div className="flex flex-wrap gap-2 items-end">
<div className="text-xs text-[#5B6650] w-full mb-0.5">Land / improvement split \u2014 enter by hand, or read it off an assessment</div>
<InlineField type="number" value={asset.landValue != null ? String(asset.landValue) : ""} placeholder="Land value $" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-32 bg-white" onSave={(v) => onUpdate({ landValue: Number(v) || 0 })} />
<InlineField type="number" value={asset.improvementValue != null ? String(asset.improvementValue) : ""} placeholder="Improvement value $" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-36 bg-white" onSave={(v) => onUpdate({ improvementValue: Number(v) || 0, usefulLifeYears: asset.usefulLifeYears || "27.5", salvageValue: asset.salvageValue || "0" })} />
<InlineField type="number" value={asset.usefulLifeYears != null ? String(asset.usefulLifeYears) : ""} placeholder="Improvement life (yrs)" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-36 bg-white" onSave={(v) => onUpdate({ usefulLifeYears: v })} />
<AssessmentUpload asset={asset} onApply={applyAssessment} />
</div>
)}
{isEquip && (
<div className="flex flex-wrap gap-2 items-end">
<InlineField type="number" value={asset.usefulLifeYears != null ? String(asset.usefulLifeYears) : ""} placeholder="Useful life (yrs)" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-32 bg-white" onSave={(v) => onUpdate({ usefulLifeYears: v })} />
<InlineField type="number" value={asset.salvageValue != null ? String(asset.salvageValue) : ""} placeholder="Salvage value $" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-32 bg-white" onSave={(v) => onUpdate({ salvageValue: v })} />
</div>
)}
{!isEquip && !isProperty && (
<InlineField type="number" value={asset.currentMarketValue != null ? String(asset.currentMarketValue) : ""} placeholder="Current market value $" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-44 bg-white" onSave={(v) => onUpdate({ currentMarketValue: Number(v) || 0 })} />
)}
<InlineField value={asset.notes || ""} placeholder="Notes about this asset\u2026" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 bg-white w-full" onSave={(v) => onUpdate({ notes: v })} />
</div>
</div>
);
}
function AssessmentUpload({ asset, onApply }) {
const [status, setStatus] = useState("idle");
const [message, setMessage] = useState("");
const inputRef = useRef(null);
async function handleFile(file) {
setStatus("processing"); setMessage("");
try {
const ext = file.name.split(".").pop().toLowerCase();
let block;
if (["png", "jpg", "jpeg", "webp"].includes(ext)) {
const b64 = await fileToBase64(file);
const mediaType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
block = { type: "image", source: { type: "base64", media_type: mediaType, data: b64 } };
} else if (ext === "pdf") {
const b64 = await fileToBase64(file);
block = { type: "document", source: { type: "base64", media_type: "application/pdf", data: b64 } };
} else if (ext === "docx") {
const text = await extractDocxText(file);
block = { type: "text", text: `Document text:\n${text.slice(0, 6000)}` };
} else {
const text = await file.text();
block = { type: "text", text: `Document text:\n${text.slice(0, 6000)}` };
}
const instruction = { type: "text", text: `Read this property tax assessment or tax bill for "${asset.name}" and extract its figures.` };
const aiText = await callClaude([block, instruction], buildPropertyAssessmentPrompt(asset.name));
const parsed = parseAiJson(aiText);
onApply(asset, parsed, file.name);
setStatus("done");
setMessage(parsed.annualTaxAmount != null ? "Value updated; tax bill added to Vendor \u2192 Bills" : "Asset value updated");
} catch (err) {
setStatus("error");
setMessage(String(err.message || err));
}
}
return (
<div className="flex items-center gap-1.5">
<button onClick={() => inputRef.current?.click()} className="text-[11px] px-2 py-1 rounded-sm border flex items-center gap-1" style={{ color: "#5B6650", borderColor: "#C5CCB6" }}>
{status === "processing" && <Loader2 size={11} className="animate-spin" />} Update from Assessment
</button>
<input ref={inputRef} type="file" accept=".pdf,.jpg,.jpeg,.png,.docx" className="hidden" onChange={(e) => { if (e.target.files[0]) handleFile(e.target.files[0]); }} />
{message && <span className="text-[11px] text-[#7A8568]">{message}</span>}
</div>
);
}
/* ----------------------------- Inventory & COGS ----------------------------- */
function InventoryTab({ inventory, setInventory, transactions, selectedEntity }) {
const [form, setForm] = useState({ name: "", subcategory: "Raw Material", unit: "each", unitCost: "", quantityOnHand: "", reorderPoint: "" });
function addItem() {
if (!form.name || selectedEntity === "all") return;
setInventory([...inventory, { id: uid(), entityId: selectedEntity, ...form, unitCost: Number(form.unitCost) || 0, quantityOnHand: Number(form.quantityOnHand) || 0 }]);
setForm({ name: "", subcategory: "Raw Material", unit: "each", unitCost: "", quantityOnHand: "", reorderPoint: "" });
}
const bySub = groupInventoryBySubcategory(inventory);
const total = inventory.reduce((s, i) => s + inventoryValue(i), 0);
const cogsTx = transactions.filter((t) => t.subcategory === "Cost of Goods Sold");
const cogsTotal = cogsTx.reduce((s, t) => s + t.amount, 0);
return (
<div className="max-w-5xl">
<h1 className="fcc-display text-2xl mb-1">Inventory & COGS</h1>
<p className="text-sm text-[#5B6650] mb-5">Supplies, raw material, and maintenance/repair/operations stock \u2014 valued at weighted-average cost. Drop a Sales Report to recognize Cost of Goods Sold automatically as items sell.</p>
<div className="grid grid-cols-3 gap-3 mb-6">
<StatCard label="Inventory on Hand" value={fmt(total)} />
<StatCard label="Cost of Goods Sold (all time)" value={fmt(cogsTotal)} accent="#7A2E2E" sub={`${cogsTx.length} sale${cogsTx.length === 1 ? "" : "s"} recognized`} />
<StatCard label="Distinct Items Tracked" value={inventory.length} />
</div>
{selectedEntity === "all" ? (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#F6E9E9", color: "#7A2E2E" }}>Pick a specific entity to add inventory.</div>
) : (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3 mb-5 flex flex-wrap gap-2 items-end">
<input placeholder="Item name (e.g. Popcorn Large)" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[160px]" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.subcategory} onChange={(e) => setForm({ ...form, subcategory: e.target.value })}>
{INVENTORY_SUBCATEGORIES.map((s) => <option key={s}>{s}</option>)}
</select>
<input placeholder="Unit (each, lb, case)" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} />
<input placeholder="Unit cost $" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={form.unitCost} onChange={(e) => setForm({ ...form, unitCost: e.target.value })} />
<input placeholder="Qty on hand" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={form.quantityOnHand} onChange={(e) => setForm({ ...form, quantityOnHand: e.target.value })} />
<button onClick={addItem} className="text-sm px-3 py-1 rounded-sm flex items-center gap-1" style={{ background: "#1F2E22", color: "#EEF1E4" }}><Plus size={13} /> Add</button>
</div>
)}
{INVENTORY_SUBCATEGORIES.map((sub) => {
const items = inventory.filter((i) => i.subcategory === sub);
if (items.length === 0) return null;
return (
<div key={sub} className="fcc-paper border border-[#C5CCB6] rounded-sm mb-4">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">{sub}</div>
<div className="divide-y divide-[#D7DCC6]">
{items.map((i) => (
<div key={i.id} className="flex flex-wrap items-center gap-3 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-[140px]">{i.name}</div>
<div className="text-right w-20"><div className="text-[10px] text-[#7A8568] uppercase">Qty</div><div className="fcc-mono">{Number(i.quantityOnHand).toLocaleString()} {i.unit}</div></div>
<div className="text-right w-24"><div className="text-[10px] text-[#7A8568] uppercase">Unit Cost</div><div className="fcc-mono">{fmt(i.unitCost)}</div></div>
<div className="text-right w-28"><div className="text-[10px] text-[#7A8568] uppercase">Total Value</div><div className="fcc-mono">{fmt(inventoryValue(i))}</div></div>
<button onClick={() => setInventory(inventory.filter((x) => x.id !== i.id))} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
<div className="flex justify-end fcc-rule mx-4 mt-1 pt-1 pb-2 text-sm">Subtotal: <span className="fcc-mono ml-2">{fmt(bySub[sub])}</span></div>
</div>
);
})}
{inventory.length === 0 && <div className="text-sm text-[#7A8568] mb-4">No inventory tracked yet. Add an item here, or use the \u2192 Inventory action on a transaction in Folders.</div>}
{inventory.length > 0 && <div className="flex justify-end fcc-double text-base font-semibold py-2">Total Inventory Value: {fmt(total)}</div>}
</div>
);
}
/* ----------------------------- Goals ----------------------------- */
function GoalsTab({ goals, setGoals, transactions, selectedEntity }) {
const [form, setForm] = useState({ name: "", type: "Personal", targetAmount: "", currentAmount: "", deadline: "" });
function addGoal() {
if (!form.name || !form.targetAmount || selectedEntity === "all") return;
setGoals([...goals, { id: uid(), entityId: selectedEntity, ...form }]);
setForm({ name: "", type: "Personal", targetAmount: "", currentAmount: "", deadline: "" });
}
return (
<div className="max-w-4xl">
<h1 className="fcc-display text-2xl mb-1">Goals & Campaigns</h1>
<p className="text-sm text-[#5B6650] mb-5">Dreams get the same bookkeeping as everything else \u2014 a target, what's saved so far, and a date.</p>
{selectedEntity === "all" ? (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#F6E9E9", color: "#7A2E2E" }}>Pick a specific entity to add a goal.</div>
) : (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3 mb-5 flex flex-wrap gap-2 items-end">
<input placeholder="Name (e.g. Theatre HVAC, trip to Lisbon)" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[180px]" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option>Personal</option><option>Campaign</option>
</select>
<input placeholder="Target $" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={form.targetAmount} onChange={(e) => setForm({ ...form, targetAmount: e.target.value })} />
<input placeholder="Raised so far $" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-32" value={form.currentAmount} onChange={(e) => setForm({ ...form, currentAmount: e.target.value })} />
<input type="date" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.deadline} onChange={(e) => setForm({ ...form, deadline: e.target.value })} />
<button onClick={addGoal} className="text-sm px-3 py-1 rounded-sm flex items-center gap-1" style={{ background: "#1F2E22", color: "#EEF1E4" }}><Plus size={13} /> Add</button>
</div>
)}
<div className="grid grid-cols-2 gap-3">
{goals.map((g) => {
const pct = g.targetAmount ? Math.min(100, Math.round(100 * (Number(g.currentAmount) || 0) / Number(g.targetAmount))) : 0;
return (
<div key={g.id} className="fcc-paper border border-[#C5CCB6] rounded-sm p-3.5">
<div className="flex justify-between items-start">
<div>
<div className="font-medium text-sm">{g.name}</div>
<div className="text-[11px] text-[#7A8568]">{g.type}{g.deadline ? ` \u00b7 by ${g.deadline}` : ""}</div>
</div>
<button onClick={() => setGoals(goals.filter((x) => x.id !== g.id))} className="text-[#A6792B]"><Trash2 size={13} /></button>
</div>
<div className="mt-2.5 h-2 rounded-full bg-[#D7DCC6] overflow-hidden">
<div className="h-full bg-[#5C8264]" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-between text-xs mt-1.5">
<span className="fcc-mono">{fmt(g.currentAmount || 0)} of {fmt(g.targetAmount)}</span>
<span>{pct}%</span>
</div>
</div>
);
})}
</div>
</div>
);
}
/* ----------------------------- Balance Sheet ----------------------------- */
const BALANCE_SHEET_990_LINES = {
"Cash & Bank (derived from logged transactions)": "Part X, Line 1",
"Accounts Receivable, net of allowance": "Part X, Line 3",
"Equipment & Appliances, net of depreciation": "Part X, Line 10c",
"Real Property": "Part X, Line 10c",
"Investments": "Part X, Lines 11\u201312",
"Inventory \u2014 Supplies": "Part X, Line 9",
"Inventory \u2014 Raw Material (tracked)": "Part X, Line 9",
"Inventory \u2014 Maintenance, Repair & Operations": "Part X, Line 9",
"Inventory \u2014 household pantry (estimated, untracked)": "Part X, Line 9",
"Accounts Payable (open)": "Part X, Line 17"
};
function liabilityTerm(l) { return l.term || (l.type === "Loan / Note Payable" ? "Long-term" : "Current"); }
function BalanceSheetTab({ transactions, assets, payables, receivables, liabilities, inventory, entityType, entity, selectedEntity }) {
const todayStr = new Date().toISOString().slice(0, 10);
const todaysTx = transactions.filter((t) => t.date === todayStr);
const todayIncome = todaysTx.filter((t) => t.direction === "income" && !t.capitalized).reduce((s, t) => s + t.amount, 0);
const todayExpense = todaysTx.filter((t) => t.direction === "expense" && !t.capitalized).reduce((s, t) => s + t.amount, 0);
const show990Lines = entityType === "Nonprofit";
const withLine = (label) => show990Lines && BALANCE_SHEET_990_LINES[label] ? `${label} (${BALANCE_SHEET_990_LINES[label]})` : label;
const pantry = computePantryEstimate(transactions);
const byType = groupAssetsByType(assets);
const bySub = groupInventoryBySubcategory(inventory);
const arGross = receivables.filter((r) => r.status === "open").reduce((s, r) => s + Number(r.amount || 0), 0);
const allowance = Math.min(arGross, Number(entity?.allowanceForDoubtfulAccounts) || 0);
const arNet = arGross - allowance;
const apOpen = payables.filter((p) => p.status === "open").reduce((s, p) => s + Number(p.amount || 0), 0);
// Only cash-settled transactions move actual cash. A payable/receivable's expense or income is recognized
// when the bill/invoice arrives; paying it later is a cash-only event, not a second income-statement hit.
const cashPosition = transactions.filter((t) => t.cashSettled !== false).reduce((s, t) => s + (t.direction === "income" ? t.amount : -t.amount), 0);
const currentAssetLines = [
{ label: withLine("Cash & Bank (derived from logged transactions)"), value: cashPosition },
{ label: withLine("Accounts Receivable, net of allowance"), value: arNet },
{ label: withLine("Inventory \u2014 Supplies"), value: bySub["Supplies"] || 0 },
{ label: withLine("Inventory \u2014 Raw Material (tracked)"), value: bySub["Raw Material"] || 0 },
{ label: withLine("Inventory \u2014 Maintenance, Repair & Operations"), value: bySub["Maintenance, Repair & Operations"] || 0 },
{ label: withLine("Inventory \u2014 household pantry (estimated, untracked)"), value: pantry.total }
];
const nonCurrentAssetLines = [
{ label: withLine("Equipment & Appliances, net of depreciation"), value: byType["Equipment & Appliances"] || 0 },
{ label: withLine("Real Property"), value: byType["Real Property"] || 0 },
{ label: withLine("Investments"), value: byType["Investments"] || 0 }
];
const totalCurrentAssets = currentAssetLines.reduce((s, l) => s + l.value, 0);
const totalNonCurrentAssets = nonCurrentAssetLines.reduce((s, l) => s + l.value, 0);
const totalAssets = totalCurrentAssets + totalNonCurrentAssets;
const currentLiabilityLines = [
{ label: withLine("Accounts Payable (open)"), value: apOpen },
...liabilities.filter((l) => liabilityTerm(l) === "Current").map((l) => ({ label: `${l.type}${l.name ? ` \u2013 ${l.name}` : ""}`, value: Number(l.balance) || 0 }))
];
const longTermLiabilityLines = liabilities.filter((l) => liabilityTerm(l) === "Long-term").map((l) => ({ label: `${l.type}${l.name ? ` \u2013 ${l.name}` : ""}`, value: Number(l.balance) || 0 }));
const totalCurrentLiabilities = currentLiabilityLines.reduce((s, l) => s + l.value, 0);
const totalLongTermLiabilities = longTermLiabilityLines.reduce((s, l) => s + l.value, 0);
const totalLiabilities = totalCurrentLiabilities + totalLongTermLiabilities;
const netAssets = totalAssets - totalLiabilities;
const currentRatio = totalCurrentLiabilities > 0 ? totalCurrentAssets / totalCurrentLiabilities : null;
return (
<div className="max-w-4xl">
<h1 className="fcc-display text-2xl mb-1">Balance Sheet</h1>
<p className="text-sm text-[#5B6650] mb-5">What's owned, what's owed, and what's left \u2014 right now, for {selectedEntity === "all" ? "all entities combined" : "this entity"}.</p>
<div className="grid grid-cols-4 gap-3 mb-6">
<StatCard label="Today's Income" value={fmt(todayIncome)} accent="#2E5339" sub={todayStr} />
<StatCard label="Today's Expense" value={fmt(todayExpense)} accent="#7A2E2E" sub={todayStr} />
<StatCard label="Net Assets Right Now" value={fmt(netAssets)} accent={netAssets >= 0 ? "#2E5339" : "#7A2E2E"} sub="assets minus liabilities" />
<StatCard label="Current Ratio" value={currentRatio != null ? currentRatio.toFixed(2) : "\u2014"} sub="current assets \u00f7 current liabilities" />
</div>
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-4">
<div className="text-sm font-medium mb-2">Current Assets</div>
{currentAssetLines.map((l, i) => (
<div key={i} className="flex justify-between fcc-rule py-1.5 text-sm">
<span className="text-[#5B6650]">{l.label}</span>
<span className="fcc-mono">{fmt(l.value)}</span>
</div>
))}
<div className="flex justify-between py-1.5 text-sm font-medium">
<span>Total Current Assets</span>
<span className="fcc-mono">{fmt(totalCurrentAssets)}</span>
</div>
<div className="text-sm font-medium mb-2 mt-5">Non-Current Assets</div>
{nonCurrentAssetLines.map((l, i) => (
<div key={i} className="flex justify-between fcc-rule py-1.5 text-sm">
<span className="text-[#5B6650]">{l.label}</span>
<span className="fcc-mono">{fmt(l.value)}</span>
</div>
))}
<div className="flex justify-between py-1.5 text-sm font-medium">
<span>Total Non-Current Assets</span>
<span className="fcc-mono">{fmt(totalNonCurrentAssets)}</span>
</div>
<div className="flex justify-between py-1.5 text-sm font-semibold mt-1">
<span>Total Assets</span>
<span className="fcc-mono">{fmt(totalAssets)}</span>
</div>
<div className="text-sm font-medium mb-2 mt-5">Current Liabilities</div>
{currentLiabilityLines.map((l, i) => (
<div key={i} className="flex justify-between fcc-rule py-1.5 text-sm">
<span className="text-[#5B6650]">{l.label}</span>
<span className="fcc-mono">{fmt(l.value)}</span>
</div>
))}
<div className="flex justify-between py-1.5 text-sm font-medium">
<span>Total Current Liabilities</span>
<span className="fcc-mono">{fmt(totalCurrentLiabilities)}</span>
</div>
{longTermLiabilityLines.length > 0 && (
<>
<div className="text-sm font-medium mb-2 mt-5">Long-Term Liabilities</div>
{longTermLiabilityLines.map((l, i) => (
<div key={i} className="flex justify-between fcc-rule py-1.5 text-sm">
<span className="text-[#5B6650]">{l.label}</span>
<span className="fcc-mono">{fmt(l.value)}</span>
</div>
))}
<div className="flex justify-between py-1.5 text-sm font-medium">
<span>Total Long-Term Liabilities</span>
<span className="fcc-mono">{fmt(totalLongTermLiabilities)}</span>
</div>
</>
)}
<div className="flex justify-between py-1.5 text-sm font-semibold mt-1">
<span>Total Liabilities</span>
<span className="fcc-mono">{fmt(totalLiabilities)}</span>
</div>
<div className="flex justify-between fcc-double mt-3 pt-2 text-base font-semibold">
<span>Net Assets (Assets \u2212 Liabilities)</span>
<span className="fcc-mono">{fmt(netAssets)}</span>
</div>
</div>
<p className="text-[11px] text-[#9AA688] mt-3">Cash & Bank only counts transactions that have actually settled in cash \u2014 a bill or invoice is recognized on the income statement when it arrives, but doesn't move Cash & Bank until it's marked paid. Accounts Receivable is shown net of the allowance for doubtful accounts set on the Receivables & Payables page. Investments are treated as non-current here as a simplification; pantry stock is an estimate (see the Compilation page for its methodology).{show990Lines ? " Part X line references are approximate, for organizing records \u2014 confirm exact placement with your preparer." : ""}</p>
</div>
);
}
/* ----------------------------- Receivables & Payables ----------------------------- */
function ReceivablesPayablesTab({ payables, receivables, contacts, addManualReceivable, updatePayable, deletePayable, deleteReceivable, markPayablePaid, markReceivablePaid, liabilities, setLiabilities, entity, updateEntity, selectedEntity }) {
const [recForm, setRecForm] = useState({ customerName: "", invoiceNumber: "", date: "", dueDate: "", amount: "", category: "Earned Revenue", subcategory: "" });
const [liForm, setLiForm] = useState({ type: "Loan / Note Payable", name: "", balance: "", term: "Long-term" });
const apOpen = payables.filter((p) => p.status === "open").reduce((s, p) => s + Number(p.amount || 0), 0);
const arGross = receivables.filter((r) => r.status === "open").reduce((s, r) => s + Number(r.amount || 0), 0);
const allowance = Math.min(arGross, Number(entity?.allowanceForDoubtfulAccounts) || 0);
const arNet = arGross - allowance;
const arAging = agingBuckets(receivables);
const apAging = agingBuckets(payables);
function addReceivable() {
if (!recForm.customerName || !recForm.amount || selectedEntity === "all") return;
addManualReceivable(recForm);
setRecForm({ customerName: "", invoiceNumber: "", date: "", dueDate: "", amount: "", category: "Earned Revenue", subcategory: "" });
}
function addLiability() {
if (!liForm.balance || selectedEntity === "all") return;
setLiabilities([...liabilities, { id: uid(), entityId: selectedEntity, ...liForm, balance: Number(liForm.balance) }]);
setLiForm({ type: "Loan / Note Payable", name: "", balance: "", term: "Long-term" });
}
return (
<div className="max-w-5xl">
<h1 className="fcc-display text-2xl mb-1">Receivables & Payables</h1>
<p className="text-sm text-[#5B6650] mb-5">Money owed to you, money you owe, and any standing liability that isn't a day-to-day bill.</p>
<div className="grid grid-cols-3 gap-3 mb-3">
<StatCard label="Accounts Receivable, gross" value={fmt(arGross)} accent="#2E5339" />
<StatCard label="Accounts Receivable, net" value={fmt(arNet)} sub={allowance > 0 ? `after ${fmt(allowance)} allowance` : "no allowance set"} />
<StatCard label="Accounts Payable (open)" value={fmt(apOpen)} accent="#7A2E2E" />
</div>
{selectedEntity !== "all" && (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3 mb-6 flex items-center gap-2 flex-wrap">
<span className="text-sm">Allowance for doubtful accounts</span>
<InlineField
type="number" value={entity?.allowanceForDoubtfulAccounts != null ? String(entity.allowanceForDoubtfulAccounts) : ""}
placeholder="$0" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28 bg-white"
onSave={(v) => updateEntity(entity.id, { allowanceForDoubtfulAccounts: Number(v) || 0 })}
/>
<span className="text-[11px] text-[#9AA688]">a judgment call on how much of gross AR you don't actually expect to collect \u2014 nets against AR on the Balance Sheet</span>
</div>
)}
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3">
<div className="text-sm font-medium mb-2">AR Aging (open invoices)</div>
<div className="grid grid-cols-4 gap-2 text-center">
{AGING_BUCKETS.map((b) => (
<div key={b}>
<div className="text-[10px] text-[#7A8568] uppercase">{b}</div>
<div className="fcc-mono text-sm">{fmt(arAging[b])}</div>
</div>
))}
</div>
</div>
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3">
<div className="text-sm font-medium mb-2">AP Aging (open bills)</div>
<div className="grid grid-cols-4 gap-2 text-center">
{AGING_BUCKETS.map((b) => (
<div key={b}>
<div className="text-[10px] text-[#7A8568] uppercase">{b}</div>
<div className="fcc-mono text-sm">{fmt(apAging[b])}</div>
</div>
))}
</div>
</div>
</div>
<p className="text-[11px] text-[#9AA688] -mt-4 mb-6">Aged by due date when one's on file, otherwise by invoice date.</p>
<div className="fcc-paper border border-[#C5CCB6] rounded-sm mb-6">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">Accounts Receivable \u2014 invoices owed to you</div>
{selectedEntity !== "all" && (
<div className="flex flex-wrap gap-2 items-end p-3 fcc-rule">
<input placeholder="Customer name" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[140px]" value={recForm.customerName} onChange={(e) => setRecForm({ ...recForm, customerName: e.target.value })} />
<input placeholder="Invoice #" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-24" value={recForm.invoiceNumber} onChange={(e) => setRecForm({ ...recForm, invoiceNumber: e.target.value })} />
<input type="date" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={recForm.date} onChange={(e) => setRecForm({ ...recForm, date: e.target.value })} />
<input type="date" placeholder="Due" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={recForm.dueDate} onChange={(e) => setRecForm({ ...recForm, dueDate: e.target.value })} />
<input placeholder="Amount $" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={recForm.amount} onChange={(e) => setRecForm({ ...recForm, amount: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={recForm.category} onChange={(e) => setRecForm({ ...recForm, category: e.target.value, subcategory: "" })}>
{Object.keys(CATEGORY_TAXONOMY.Income).map((c) => <option key={c}>{c}</option>)}
</select>
<button onClick={addReceivable} className="text-sm px-3 py-1 rounded-sm flex items-center gap-1" style={{ background: "#1F2E22", color: "#EEF1E4" }}><Plus size={13} /> Add</button>
</div>
)}
<div className="divide-y divide-[#D7DCC6]">
{receivables.length === 0 && <div className="px-4 py-4 text-sm text-[#7A8568]">No customer invoices on record.</div>}
{receivables.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-[140px]">
<div>{r.customerName}{r.invoiceNumber ? ` \u00b7 #${r.invoiceNumber}` : ""}</div>
<div className="text-[11px] text-[#7A8568]">{r.date || "no date"}{r.dueDate ? ` \u00b7 due ${r.dueDate}` : ""} \u00b7 {r.category}</div>
</div>
<StatusBadge status={r.status} dueDate={r.dueDate} />
<div className="fcc-mono w-24 text-right text-[#2E5339]">{fmt(r.amount)}</div>
{r.status === "open" ? (
<button onClick={() => markReceivablePaid(r, new Date().toISOString().slice(0, 10))} className="text-[11px] px-2 py-1 rounded-sm" style={{ background: "#1F2E22", color: "#EEF1E4" }}>Mark Paid</button>
) : <span className="text-[11px] text-[#7A8568]">paid</span>}
<button onClick={() => deleteReceivable(r.id)} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
</div>
<div className="fcc-paper border border-[#C5CCB6] rounded-sm mb-6">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">Accounts Payable \u2014 bills you owe</div>
<div className="px-4 py-2 text-[11px] text-[#7A8568] fcc-rule">New bills are easiest to add by dropping the document into the Vendor folder \u2014 this view is for tracking what's already there.</div>
<div className="divide-y divide-[#D7DCC6]">
{payables.length === 0 && <div className="px-4 py-4 text-sm text-[#7A8568]">No vendor bills on record.</div>}
{payables.map((p) => (
<div key={p.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-[140px]">
<div>{p.vendorName}{p.invoiceNumber ? ` \u00b7 #${p.invoiceNumber}` : ""}</div>
<div className="text-[11px] text-[#7A8568]">{p.date || "no date"}{p.dueDate ? ` \u00b7 due ${p.dueDate}` : ""} \u00b7 {p.category || "uncategorized"}</div>
</div>
<StatusBadge status={p.status} dueDate={p.dueDate} />
<div className="fcc-mono w-24 text-right text-[#7A2E2E]">{fmt(p.amount)}</div>
{p.status === "open" ? (
<button onClick={() => markPayablePaid(p, new Date().toISOString().slice(0, 10))} className="text-[11px] px-2 py-1 rounded-sm" style={{ background: "#1F2E22", color: "#EEF1E4" }}>Mark Paid</button>
) : <span className="text-[11px] text-[#7A8568]">paid</span>}
<button onClick={() => deletePayable(p.id)} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
</div>
<div className="fcc-paper border border-[#C5CCB6] rounded-sm">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">Other Liabilities \u2014 loans, credit lines, deferred revenue</div>
{selectedEntity !== "all" && (
<div className="flex flex-wrap gap-2 items-end p-3 fcc-rule">
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={liForm.type} onChange={(e) => setLiForm({ ...liForm, type: e.target.value })}>
{LIABILITY_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
<input placeholder="Name (e.g. SBA loan)" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[140px]" value={liForm.name} onChange={(e) => setLiForm({ ...liForm, name: e.target.value })} />
<input placeholder="Balance $" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={liForm.balance} onChange={(e) => setLiForm({ ...liForm, balance: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={liForm.term} onChange={(e) => setLiForm({ ...liForm, term: e.target.value })}>
<option>Current</option><option>Long-term</option>
</select>
<button onClick={addLiability} className="text-sm px-3 py-1 rounded-sm flex items-center gap-1" style={{ background: "#1F2E22", color: "#EEF1E4" }}><Plus size={13} /> Add</button>
</div>
)}
<div className="divide-y divide-[#D7DCC6]">
{liabilities.length === 0 && <div className="px-4 py-4 text-sm text-[#7A8568]">No other liabilities on record.</div>}
{liabilities.map((l) => (
<div key={l.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="flex-1"><div>{l.name || l.type}</div><div className="text-[11px] text-[#7A8568]">{l.type} \u00b7 {liabilityTerm(l)}</div></div>
<div className="fcc-mono w-28 text-right text-[#7A2E2E]">{fmt(l.balance)}</div>
<button onClick={() => setLiabilities(liabilities.filter((x) => x.id !== l.id))} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
</div>
</div>
);
}
/* ----------------------------- Contacts ----------------------------- */
function ContactsTab({ contacts, setContacts, selectedEntity }) {
const [form, setForm] = useState({ name: "", type: "Customer", email: "", phone: "", address: "" });
function addContact() {
if (!form.name || selectedEntity === "all") return;
setContacts([...contacts, { id: uid(), entityId: selectedEntity, ...form, notes: "" }]);
setForm({ name: "", type: "Customer", email: "", phone: "", address: "" });
}
return (
<div className="max-w-4xl">
<h1 className="fcc-display text-2xl mb-1">Contacts</h1>
<p className="text-sm text-[#5B6650] mb-5">Customers, vendors and service providers \u2014 filled in automatically from an invoice when possible, or entered by hand any time.</p>
{selectedEntity === "all" ? (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#F6E9E9", color: "#7A2E2E" }}>Pick a specific entity to add a contact.</div>
) : (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3 mb-5 flex flex-wrap gap-2 items-end">
<input placeholder="Name" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[140px]" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
{CONTACT_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
<input placeholder="Email" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-44" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input placeholder="Phone" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-32" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<input placeholder="Address" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[140px]" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
<button onClick={addContact} className="text-sm px-3 py-1 rounded-sm flex items-center gap-1" style={{ background: "#1F2E22", color: "#EEF1E4" }}><Plus size={13} /> Add</button>
</div>
)}
{CONTACT_TYPES.map((type) => {
const list = contacts.filter((c) => c.type === type);
if (list.length === 0) return null;
return (
<div key={type} className="fcc-paper border border-[#C5CCB6] rounded-sm mb-4">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">{type === "Customer" ? "Customers" : type === "Vendor/Merchant" ? "Vendors & Merchants" : "Service Providers"}</div>
<div className="divide-y divide-[#D7DCC6]">
{list.map((c) => (
<div key={c.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-[120px] font-medium">{c.name}</div>
<InlineField value={c.email} placeholder="email" className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white w-40" onSave={(v) => setContacts(contacts.map((x) => x.id === c.id ? { ...x, email: v } : x))} />
<InlineField value={c.phone} placeholder="phone" className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white w-32" onSave={(v) => setContacts(contacts.map((x) => x.id === c.id ? { ...x, phone: v } : x))} />
<InlineField value={c.address} placeholder="address" className="text-xs border border-[#C5CCB6] rounded-sm px-1.5 py-1 bg-white flex-1 min-w-[140px]" onSave={(v) => setContacts(contacts.map((x) => x.id === c.id ? { ...x, address: v } : x))} />
<button onClick={() => setContacts(contacts.filter((x) => x.id !== c.id))} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
</div>
);
})}
{contacts.length === 0 && <div className="text-sm text-[#7A8568]">No contacts yet.</div>}
</div>
);
}
/* ----------------------------- Bids ----------------------------- */
function BidsTab({ bids, setBids, selectedEntity }) {
const [form, setForm] = useState({ project: "", contractor: "", amount: "", date: "", status: "pending" });
function addBid() {
if (!form.project || !form.amount || selectedEntity === "all") return;
setBids([...bids, { id: uid(), entityId: selectedEntity, ...form }]);
setForm({ project: "", contractor: "", amount: "", date: "", status: "pending" });
}
const totals = { pending: 0, accepted: 0, rejected: 0 };
bids.forEach((b) => { totals[b.status] = (totals[b.status] || 0) + Number(b.amount || 0); });
return (
<div className="max-w-4xl">
<h1 className="fcc-display text-2xl mb-1">Bids & Estimates</h1>
<p className="text-sm text-[#5B6650] mb-5">A quote isn't a transaction yet \u2014 it's a number you're planning around. Roof, HVAC, electrical, a campaign vendor, anything.</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<StatCard label="Pending" value={fmt(totals.pending)} accent="#A6792B" />
<StatCard label="Accepted" value={fmt(totals.accepted)} accent="#2E5339" />
<StatCard label="Rejected" value={fmt(totals.rejected)} accent="#7A8568" />
</div>
{selectedEntity === "all" ? (
<div className="text-sm px-3 py-2 mb-4 rounded-sm" style={{ background: "#F6E9E9", color: "#7A2E2E" }}>Pick a specific entity to add a bid.</div>
) : (
<div className="fcc-paper border border-[#C5CCB6] rounded-sm p-3 mb-5 flex flex-wrap gap-2 items-end">
<input placeholder="Project (e.g. Theatre roof)" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 flex-1 min-w-[160px]" value={form.project} onChange={(e) => setForm({ ...form, project: e.target.value })} />
<input placeholder="Contractor / vendor" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-40" value={form.contractor} onChange={(e) => setForm({ ...form, contractor: e.target.value })} />
<input placeholder="Amount $" type="number" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1 w-28" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} />
<input type="date" className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
<select className="text-sm border border-[#C5CCB6] rounded-sm px-2 py-1" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
<option value="pending">Pending</option><option value="accepted">Accepted</option><option value="rejected">Rejected</option>
</select>
<button onClick={addBid} className="text-sm px-3 py-1 rounded-sm flex items-center gap-1" style={{ background: "#1F2E22", color: "#EEF1E4" }}><Plus size={13} /> Add</button>
</div>
)}
<div className="fcc-paper border border-[#C5CCB6] rounded-sm divide-y divide-[#D7DCC6]">
{bids.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="flex-1">
<div>{b.project}</div>
<div className="text-[11px] text-[#7A8568]">{b.contractor}{b.date ? ` \u00b7 ${b.date}` : ""}</div>
</div>
<span className="text-[11px] px-2 py-0.5 rounded-full border" style={{
color: b.status === "accepted" ? "#2E5339" : b.status === "rejected" ? "#7A8568" : "#A6792B",
borderColor: b.status === "accepted" ? "#2E5339" : b.status === "rejected" ? "#7A8568" : "#A6792B"
}}>{b.status}</span>
<div className="fcc-mono w-24 text-right">{fmt(b.amount)}</div>
<button onClick={() => setBids(bids.filter((x) => x.id !== b.id))} className="text-[#A6792B]"><Trash2 size={14} /></button>
</div>
))}
</div>
</div>
);
}
/* ----------------------------- Entities & Memory ----------------------------- */
function EntitiesTab({ entities, setEntities, rules, setRules }) {
return (
<div className="max-w-4xl">
<h1 className="fcc-display text-2xl mb-1">Entities & Memory</h1>
<p className="text-sm text-[#5B6650] mb-5">Every book being kept, and every category the system has learned so far.</p>
<div className="fcc-paper border border-[#C5CCB6] rounded-sm mb-6">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule">Entities</div>
<div className="divide-y divide-[#D7DCC6]">
{entities.map((e) => (
<div key={e.id} className="flex items-center justify-between px-4 py-2 text-sm">
<span>{e.name}</span>
<span className="flex items-center gap-3">
<span className="text-[11px] text-[#7A8568]">{e.type}</span>
<button onClick={() => setEntities(entities.filter((x) => x.id !== e.id))} className="text-[#A6792B]"><Trash2 size={13} /></button>
</span>
</div>
))}
{entities.length === 0 && <div className="px-4 py-4 text-sm text-[#7A8568]">No entities yet \u2014 add one from the bar at the top of any page.</div>}
</div>
</div>
<div className="fcc-paper border border-[#C5CCB6] rounded-sm">
<div className="px-4 py-2.5 text-sm font-medium fcc-rule flex items-center gap-2"><PencilLine size={14} /> Learned category rules</div>
<div className="divide-y divide-[#D7DCC6]">
{Object.entries(rules).map(([key, r]) => (
<div key={key} className="flex items-center justify-between px-4 py-2 text-sm">
<span className="truncate">{key}</span>
<span className="flex items-center gap-3">
<span className="text-[11px] text-[#7A8568]">{r.direction === "income" ? "Income" : "Expense"} \u2192 {r.category}{r.subcategory ? ` \u2192 ${r.subcategory}` : ""}</span>
<button onClick={() => { const next = { ...rules }; delete next[key]; setRules(next); }} className="text-[#A6792B]"><Trash2 size={13} /></button>
</span>
</div>
))}
{Object.keys(rules).length === 0 && <div className="px-4 py-4 text-sm text-[#7A8568]">Nothing learned yet \u2014 categorize a transaction once and it remembers.</div>}
</div>
</div>
</div>
);
}