/* ══════════════════════════════════════════════════ FUTURE FIXED EXPENSES — Planning & Reminder Module Non-ledger · Cash-basis compliant · No AP/AR Becomes accounting only upon actual payment ══════════════════════════════════════════════════ */ // ── CONSTANTS ────────────────────────────────────── const FE_STATUSES = ["Planned", "Due Soon", "Overdue", "Paid", "Skipped", "Cancelled"]; const FE_EXPENSE_TYPES = ["recurring", "one-time"]; const FE_FREQUENCIES = ["monthly", "quarterly", "yearly", "custom"]; const FE_STATUS_BADGE = { Planned: "info", "Due Soon": "warning", Overdue: "danger", Paid: "success", Skipped: "neutral", Cancelled: "neutral" }; const FE_STATUS_ICON = { Planned: "📋", "Due Soon": "⏰", Overdue: "🔴", Paid: "✅", Skipped: "⏭️", Cancelled: "❌" }; // ── CATEGORIES (matches existing Chart of Accounts) ── const FE_CATEGORIES = [ // Office & Operations { code: "5100", label: "Office Rent" }, { code: "5101", label: "Warehouse / Storage Rent" }, { code: "5102", label: "Parking Fees" }, { code: "5103", label: "Security Deposit" }, // Utilities { code: "5110", label: "DEWA — Electricity & Water" }, { code: "5120", label: "Empower — Cooling" }, { code: "5130", label: "Internet & Wi-Fi" }, { code: "5131", label: "Landline / Telephone" }, { code: "5140", label: "Mobile Communication" }, // Office Supplies & Maintenance { code: "5150", label: "Cleaning Fees" }, { code: "5151", label: "Pest Control" }, { code: "5160", label: "Office Supplies & Stationery" }, { code: "5161", label: "Furniture & Equipment" }, { code: "5162", label: "Office Maintenance & Repairs" }, { code: "5163", label: "Printing & Photocopying" }, // Marketing & Advertising { code: "5200", label: "Property Finder Subscription" }, { code: "5210", label: "Bayut Advertising" }, { code: "5211", label: "Dubizzle Advertising" }, { code: "5220", label: "Marketing & Promotions" }, { code: "5221", label: "Social Media Advertising" }, { code: "5222", label: "Signage & Branding" }, { code: "5223", label: "Website Hosting & Domain" }, { code: "5224", label: "Photography & Videography" }, // Software & Technology { code: "5230", label: "CRM Software" }, { code: "5231", label: "Accounting Software" }, { code: "5232", label: "Microsoft / Google Workspace" }, { code: "5233", label: "IT Support & Maintenance" }, { code: "5234", label: "Cloud Storage & Backup" }, // Salaries & HR { code: "5300", label: "Salaries & Wages" }, { code: "5301", label: "Commission Payouts" }, { code: "5302", label: "End of Service Benefits (EOSB)" }, { code: "5303", label: "Staff Health Insurance" }, { code: "5304", label: "DEWS / Pension Contribution" }, { code: "5305", label: "Staff Training & Development" }, { code: "5306", label: "Recruitment & HR Services" }, // Transportation & Travel { code: "5310", label: "Transportation / Fuel" }, { code: "5311", label: "Vehicle Maintenance & Repair" }, { code: "5312", label: "Vehicle Insurance" }, { code: "5313", label: "Salik (Road Toll)" }, { code: "5314", label: "Vehicle Lease / Rental" }, { code: "5315", label: "Travel & Accommodation" }, // Professional Services { code: "5400", label: "Accountant Registration" }, { code: "5410", label: "Accountant Services" }, { code: "5411", label: "Audit Fees" }, { code: "5412", label: "Consultancy Fees" }, { code: "5420", label: "PRO Services" }, // Government & Licensing { code: "5430", label: "Trakheesi & Licensing" }, { code: "5431", label: "Trade License Renewal" }, { code: "5432", label: "RERA Registration" }, { code: "5433", label: "DLD Fees" }, { code: "5434", label: "Immigration & Visa Fees" }, { code: "5435", label: "Labour Card & Work Permit" }, { code: "5436", label: "Emirates ID Renewal" }, { code: "5437", label: "Municipality Fees" }, { code: "5438", label: "Chamber of Commerce" }, // Insurance { code: "5500", label: "General Insurance" }, { code: "5501", label: "Professional Indemnity Insurance" }, { code: "5502", label: "Property Insurance" }, // Banking & Financial { code: "5600", label: "Bank Fees & Charges" }, { code: "5601", label: "Credit Card Fees" }, { code: "5602", label: "Payment Gateway Fees" }, { code: "5603", label: "Loan / Finance Repayment" }, // Legal { code: "6000", label: "Legal Services" }, { code: "6001", label: "Court / Litigation Fees" }, { code: "6002", label: "Notary & Attestation" }, // Entertainment & Events { code: "6100", label: "Client Entertainment" }, { code: "6101", label: "Corporate Events" }, { code: "6102", label: "Gifts & Donations" }, // Miscellaneous { code: "6200", label: "Courier & Postage" }, { code: "6201", label: "Subscriptions & Memberships" }, { code: "6202", label: "Penalties & Fines" }, { code: "OTHER", label: "Other — Custom" }, ]; // ── HELPERS ───────────────────────────────────────── const feEmpty = () => ({ id: "", title: "", category: "5100", expenseType: "recurring", frequency: "monthly", startDate: todayStr(), nextDueDate: todayStr(), amountExpected: "", vatApplicable: false, vatRate: 5, amountIncludesVat: true, paymentMethod: "bank", preferredAccountCode: "1002", payeeName: "", status: "Planned", lastPaidDate: "", lastPaidTxnId: "", nextReminderDate: "", notes: "", createdBy: "", updatedBy: "", createdAt: "", updatedAt: "" }); const feComputeStatus = (item, today) => { if (item.status === "Paid" || item.status === "Skipped" || item.status === "Cancelled") return item.status; if (!item.nextDueDate) return "Planned"; const due = new Date(item.nextDueDate + "T12:00:00"); const diffDays = Math.floor((due - today) / (1000 * 60 * 60 * 24)); if (diffDays < 0) return "Overdue"; if (diffDays <= 7) return "Due Soon"; return "Planned"; }; const feAdvanceNextDueDate = (item) => { if (item.expenseType !== "recurring" || !item.nextDueDate) return null; const d = new Date(item.nextDueDate + "T12:00:00"); switch (item.frequency) { case "monthly": d.setMonth(d.getMonth() + 1); break; case "quarterly": d.setMonth(d.getMonth() + 3); break; case "yearly": d.setFullYear(d.getFullYear() + 1); break; default: d.setMonth(d.getMonth() + 1); break; } return d.toISOString().split("T")[0]; }; const feComputeMonthlyEquivalent = (item) => { const amt = item.amountExpected || 0; switch (item.frequency) { case "quarterly": return Math.round(amt / 3); case "yearly": return Math.round(amt / 12); default: return amt; } }; // ── FUTURE EXPENSES PAGE ──────────────────────────── function FutureExpensesPage({ accounts, ledger, plannedExpenses, setPlannedExpenses, journal, persistTxn, userRole, userEmail, dark }) { const [showModal, setShowModal] = useState(false); const [showPayModal, setShowPayModal] = useState(false); const [editItem, setEditItem] = useState(null); const [payItem, setPayItem] = useState(null); const [filter, setFilter] = useState("Active"); const [searchText, setSearchText] = useState(""); const isMobile = window.innerWidth <= 768; useEffect(() => { const h = () => { setEditItem(null); setShowModal(true); }; document.addEventListener("add-planned-expense", h); return () => document.removeEventListener("add-planned-expense", h); }, []); // Auto-compute statuses const enriched = useMemo(() => { const today = new Date(todayStr() + "T12:00:00"); return (plannedExpenses || []).map(item => ({ ...item, computedStatus: feComputeStatus(item, today) })); }, [plannedExpenses]); // Summary KPIs const kpis = useMemo(() => { const today = new Date(todayStr() + "T12:00:00"); const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); const next30 = new Date(today); next30.setDate(next30.getDate() + 30); const active = enriched.filter(e => !["Paid", "Skipped", "Cancelled"].includes(e.status)); const overdue = active.filter(e => e.computedStatus === "Overdue"); const dueThisMonth = active.filter(e => { if (!e.nextDueDate) return false; const d = new Date(e.nextDueDate + "T12:00:00"); return d >= today && d <= thisMonthEnd; }); const dueNext30 = active.filter(e => { if (!e.nextDueDate) return false; const d = new Date(e.nextDueDate + "T12:00:00"); return d >= today && d <= next30; }); const annualCommitments = active .filter(e => e.expenseType === "recurring") .reduce((sum, e) => sum + feComputeMonthlyEquivalent(e) * 12, 0); return { overdueCount: overdue.length, overdueTotal: overdue.reduce((s, e) => s + (e.amountExpected || 0), 0), dueThisMonthCount: dueThisMonth.length, dueThisMonthTotal: dueThisMonth.reduce((s, e) => s + (e.amountExpected || 0), 0), dueNext30Count: dueNext30.length, dueNext30Total: dueNext30.reduce((s, e) => s + (e.amountExpected || 0), 0), annualCommitments, activeCount: active.length }; }, [enriched]); // Filtering const filtered = useMemo(() => { let list = enriched; if (filter === "Active") list = list.filter(e => !["Paid", "Skipped", "Cancelled"].includes(e.status)); else if (filter === "Overdue") list = list.filter(e => e.computedStatus === "Overdue"); else if (filter === "Paid") list = list.filter(e => e.status === "Paid"); else if (filter === "Recurring") list = list.filter(e => e.expenseType === "recurring" && !["Paid", "Skipped", "Cancelled"].includes(e.status)); if (searchText.trim()) { const q = searchText.toLowerCase().trim(); list = list.filter(e => (e.title || "").toLowerCase().includes(q) || (e.payeeName || "").toLowerCase().includes(q) || (e.notes || "").toLowerCase().includes(q) || (e.category || "").toLowerCase().includes(q) ); } return list.sort((a, b) => { const statusOrder = { Overdue: 0, "Due Soon": 1, Planned: 2, Paid: 3, Skipped: 4, Cancelled: 5 }; const sa = statusOrder[a.computedStatus] ?? 9; const sb = statusOrder[b.computedStatus] ?? 9; if (sa !== sb) return sa - sb; return (a.nextDueDate || "").localeCompare(b.nextDueDate || ""); }); }, [enriched, filter, searchText]); // CRUD const handleSave = (item) => { const now = new Date().toISOString(); if (item.id) { setPlannedExpenses(prev => prev.map(e => e.id === item.id ? { ...item, updatedBy: userEmail, updatedAt: now } : e)); toast("Planned expense updated", "success"); logAudit("planned_expense_update", { itemId: item.id, title: item.title }, userRole, userEmail); } else { const newItem = { ...item, id: uid(), createdBy: userEmail, createdAt: now, updatedBy: userEmail, updatedAt: now }; setPlannedExpenses(prev => [...prev, newItem]); toast("Planned expense created", "success"); logAudit("planned_expense_create", { itemId: newItem.id, title: newItem.title }, userRole, userEmail); } setShowModal(false); setEditItem(null); }; const handleDelete = (item) => { if (!confirm(`Delete "${item.title || "Untitled"}"?\n\nThis removes the planned expense. No accounting entries will be affected.`)) return; setPlannedExpenses(prev => prev.filter(e => e.id !== item.id)); toast("Planned expense deleted", "success"); logAudit("planned_expense_delete", { itemId: item.id, title: item.title }, userRole, userEmail); }; const handleSkip = (item) => { const now = new Date().toISOString(); if (item.expenseType === "recurring") { const nextDate = feAdvanceNextDueDate(item); setPlannedExpenses(prev => prev.map(e => e.id === item.id ? { ...e, nextDueDate: nextDate, status: "Planned", updatedBy: userEmail, updatedAt: now } : e)); toast(`Skipped — next due date: ${fmtDate(nextDate)}`, "info"); } else { setPlannedExpenses(prev => prev.map(e => e.id === item.id ? { ...e, status: "Skipped", updatedBy: userEmail, updatedAt: now } : e)); toast("Expense marked as skipped", "info"); } logAudit("planned_expense_skip", { itemId: item.id, title: item.title }, userRole, userEmail); }; const handlePaymentComplete = (item, txnId) => { const now = new Date().toISOString(); if (item.expenseType === "recurring") { const nextDate = feAdvanceNextDueDate(item); setPlannedExpenses(prev => prev.map(e => e.id === item.id ? { ...e, lastPaidDate: todayStr(), lastPaidTxnId: txnId, nextDueDate: nextDate, status: "Planned", updatedBy: userEmail, updatedAt: now } : e)); toast(`Payment recorded — next due: ${fmtDate(nextDate)}`, "success"); } else { setPlannedExpenses(prev => prev.map(e => e.id === item.id ? { ...e, status: "Paid", lastPaidDate: todayStr(), lastPaidTxnId: txnId, updatedBy: userEmail, updatedAt: now } : e)); toast("Payment recorded — expense marked as Paid", "success"); } logAudit("planned_expense_payment", { itemId: item.id, title: item.title, txnId }, userRole, userEmail); setShowPayModal(false); setPayItem(null); }; // ── COA-based expense accounts (live, sorted by code) ── const expenseAccounts = useMemo(() => (accounts || []) .filter(a => a.type === "Expense") .sort((a, b) => (a.code || "").localeCompare(b.code || "")), [accounts] ); // Group expense accounts by code prefix for optgroup display const expenseAccountGroups = useMemo(() => { const groups = new Map(); expenseAccounts.forEach(a => { const prefix = (a.code || "").slice(0, 2); const groupName = prefix === "50" ? "Cost of Sales" : prefix === "51" ? "Office & Operations" : prefix === "52" ? "Marketing & Technology" : prefix === "53" ? "Salaries & HR" : prefix === "54" ? "Professional & Government" : prefix === "55" ? "Commission & Agency" : prefix === "56" ? "Banking & Finance" : prefix === "57" ? "Transportation" : prefix === "60" ? "Legal" : prefix === "61" ? "Entertainment" : prefix === "62" ? "Miscellaneous" : "Other Expenses"; if (!groups.has(groupName)) groups.set(groupName, []); groups.get(groupName).push(a); }); return [...groups.entries()]; }, [expenseAccounts]); const getCategoryLabel = (code) => { // First check live COA accounts const acct = expenseAccounts.find(a => a.code === code); if (acct) return `${acct.code} — ${acct.name}`; // Fallback to static FE_CATEGORIES for backward compatibility const cat = FE_CATEGORIES.find(c => c.code === code); return cat ? cat.label : code; }; // Summary cards const summaryCards = [ { label: "Due This Month", count: kpis.dueThisMonthCount, total: kpis.dueThisMonthTotal, color: "#2563EB", icon: "📅" }, { label: "Overdue", count: kpis.overdueCount, total: kpis.overdueTotal, color: "#DC2626", icon: "🔴" }, { label: "Next 30 Days", count: kpis.dueNext30Count, total: kpis.dueNext30Total, color: "#D97706", icon: "⏰" }, { label: "Annual Fixed Costs", count: null, total: kpis.annualCommitments, color: "#059669", icon: "📊" }, ]; return
setFilter(e.target.value)}> {hasPermission(userRole, 'planning.create') && } {/* Info banner */}
📋 This module tracks future and recurring obligations as operational reminders. No journal entries are created until you click "Record Payment" — preserving cash-basis accounting.
{/* Summary Cards */}
{summaryCards.map((card, i) =>
{card.icon}
{card.label}
{fmtAED(card.total)}
{card.count !== null &&
{card.count} expense{card.count !== 1 ? "s" : ""}
}
)}
{/* Search Bar */}
setSearchText(e.target.value)} placeholder="Search by title, payee, notes..." />
{/* Table */}
{filtered.length === 0 && } {filtered.map(item => { const status = item.computedStatus; return ; })}
Status Title Category Type Due Date Amount Payee Actions
{filter === "Overdue" ? "No overdue expenses. 🎉" : "No planned expenses found. Click \"+ New Expense\" to add one."}
{FE_STATUS_ICON[status] || ""} {status}
{item.title || "Untitled"}
{item.notes &&
{item.notes.substring(0, 60)}{item.notes.length > 60 ? "…" : ""}
}
{getCategoryLabel(item.category)} {item.expenseType === "recurring" ? `🔄 ${item.frequency || "monthly"}` : "One-time"} {item.nextDueDate ? fmtDate(item.nextDueDate) : "—"} {fmtAED(item.amountExpected || 0)} {item.vatApplicable &&
incl. VAT {item.vatRate || 5}%
}
{item.payeeName || "—"}
{hasPermission(userRole, 'expenses.create') && status !== "Paid" && status !== "Cancelled" && } {hasPermission(userRole, 'planning.edit') && status !== "Paid" && } {hasPermission(userRole, 'planning.edit') && } {hasPermission(userRole, 'planning.edit') && status !== "Paid" && }
{/* Paid History Link */} {filter !== "Paid" && enriched.some(e => e.status === "Paid") &&
} {/* Add/Edit Modal */} {showModal && { setShowModal(false); setEditItem(null); }} />} {/* Record Payment Modal */} {showPayModal && payItem && handlePaymentComplete(payItem, txnId)} onClose={() => { setShowPayModal(false); setPayItem(null); }} />}
; } // ── ADD/EDIT MODAL ────────────────────────────────── function FutureExpenseModal({ item, accounts, onSave, onClose }) { // Live COA expense accounts for the category selector const expenseAccounts = (accounts || []) .filter(a => a.type === "Expense") .sort((a, b) => (a.code || "").localeCompare(b.code || "")); const expenseAccountGroups = (() => { const groups = new Map(); expenseAccounts.forEach(a => { const prefix = (a.code || "").slice(0, 2); const groupName = prefix === "50" ? "Cost of Sales" : prefix === "51" ? "Office & Operations" : prefix === "52" ? "Marketing & Technology" : prefix === "53" ? "Salaries & HR" : prefix === "54" ? "Professional & Government" : prefix === "55" ? "Commission & Agency" : prefix === "56" ? "Banking & Finance" : prefix === "57" ? "Transportation" : prefix === "60" ? "Legal" : prefix === "61" ? "Entertainment" : prefix === "62" ? "Miscellaneous" : "Other Expenses"; if (!groups.has(groupName)) groups.set(groupName, []); groups.get(groupName).push(a); }); return [...groups.entries()]; })(); // Default category = first COA expense account code, or "5100" fallback const defaultCategory = expenseAccounts.length > 0 ? expenseAccounts[0].code : "5100"; const [form, setForm] = useState(() => item ? { ...item } : { ...feEmpty(), category: defaultCategory }); const up = (k, v) => setForm(prev => ({ ...prev, [k]: v })); // Raw text the user types for the amount; cents are derived from it (avoids // per-keystroke reformatting that made the field impossible to type into). const [amountText, setAmountText] = useState(() => item && item.amountExpected ? fromCents(item.amountExpected) : ""); const bankAccounts = accounts.filter(a => a.isBank || a.code === "1001"); const isEdit = !!(item && item.id); const handleSubmit = () => { if (!form.title.trim()) { toast("Title is required", "warning"); return; } if (!form.nextDueDate) { toast("Next due date is required", "warning"); return; } if (!form.amountExpected || form.amountExpected <= 0) { toast("Amount must be greater than zero", "warning"); return; } onSave(form); }; return
e.stopPropagation()}>
{isEdit ? "✏️ Edit Planned Expense" : "📅 New Planned Expense"}

{isEdit ? "Update the details of this planned expense. No accounting entries are created until payment." : "Schedule a future or recurring expense. This is a planning entry only — no journal posting until you record a payment."}

up("title", e.target.value)} placeholder="e.g. Office Rent — March 2026" />
up("category", e.target.value)}> {expenseAccounts.length === 0 ? FE_CATEGORIES.map(c => ) : expenseAccountGroups.map(([groupName, accts]) => {accts.map(a => )} ) }
up("expenseType", e.target.value)}>
{form.expenseType === "recurring" &&
up("frequency", e.target.value)}> {FE_FREQUENCIES.map(f => )}
}
up("nextDueDate", e.target.value)} />
{ setAmountText(e.target.value); up("amountExpected", toCents(e.target.value)); }} placeholder="e.g. 15000" />
up("vatApplicable", e.target.value === "yes")}>
{form.vatApplicable &&
up("vatRate", parseFloat(e.target.value) || 5)} />
} {form.vatApplicable &&
up("amountIncludesVat", e.target.value === "yes")}>
}
up("payeeName", e.target.value)} placeholder="e.g. Landlord, DEWA, etc." />
up("preferredAccountCode", e.target.value)}> {bankAccounts.map(a => )}
{isEdit &&
up("status", e.target.value)}> {FE_STATUSES.map(s => )}
}