/* ══════════════════════════════════════════════════ PAGE COMPONENTS ══════════════════════════════════════════════════ */ // ╔══════════════════════════════════════════════════╗ // DATE FILTER UTILITIES (shared across pages) // ╚══════════════════════════════════════════════════╝ function computeDateRange(preset) { const today = new Date(); const y = today.getFullYear(); const m = today.getMonth(); const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; const fd = (yr, mo) => new Date(yr, mo, 1); const ld = (yr, mo) => new Date(yr, mo + 1, 0); switch (preset) { case "this_month": return { from: fmt(fd(y, m)), to: fmt(ld(y, m)) }; case "last_month": return { from: fmt(fd(y, m - 1)), to: fmt(ld(y, m - 1)) }; case "last_quarter": { const curQStart = Math.floor(m / 3) * 3; const prevQMo = curQStart - 3; const pqYear = prevQMo < 0 ? y - 1 : y; const pqMo = prevQMo < 0 ? prevQMo + 12 : prevQMo; return { from: fmt(fd(pqYear, pqMo)), to: fmt(ld(pqYear, pqMo + 2)) }; } case "last_half": return { from: fmt(fd(y, m - 5)), to: fmt(ld(y, m)) }; case "this_year": return { from: `${y}-01-01`, to: `${y}-12-31` }; case "last_year": return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31` }; default: return { from: "", to: "" }; } } // Like usePersistedState but for the { preset, from, to } period filter: relative // presets (this_month, etc.) are recomputed on load so the range never goes stale; // only a "custom" range stores its actual from/to. function usePersistedDateFilter(key, defaultPreset = "this_month") { const [df, setDf] = useState(() => { const saved = ls_get(key, null); const preset = (saved && saved.preset) || defaultPreset; if (preset === "custom" && saved && saved.from && saved.to) return { preset: "custom", from: saved.from, to: saved.to }; return { preset, ...computeDateRange(preset) }; }); useEffect(() => { ls_set(key, df.preset === "custom" ? { preset: "custom", from: df.from, to: df.to } : { preset: df.preset }); }, [key, df]); return [df, setDf]; } function DateFilterBar({ dateFilter, setDateFilter }) { const { isMobile } = useWindowSize(); const PRESETS = [ { id: "this_month", label: "This Month" }, { id: "last_month", label: "Last Month" }, { id: "last_quarter", label: "Last Quarter" }, { id: "last_half", label: "Last 6 Months" }, { id: "this_year", label: "This Year" }, { id: "last_year", label: "Last Year" }, { id: "custom", label: "Custom" }, ]; return (
Period {PRESETS.map(p => { const active = dateFilter.preset === p.id; return ; })} {dateFilter.preset === "custom" && <> setDateFilter(prev => ({ ...prev, from: e.target.value }))} style={{ width: 138, marginLeft: 4 }} /> setDateFilter(prev => ({ ...prev, to: e.target.value }))} style={{ width: 138 }} /> } {dateFilter.preset !== "custom" && dateFilter.from && ( {dateFilter.from} → {dateFilter.to} )}
); } // Shared period-KPI calculator — used by filteredKpis and priorFilteredKpis function computePeriodKpis(ft, accounts) { const accountById = new Map(accounts.map(a => [a.id, a])); const outputVATA = accounts.find(a => a.isOutputVAT); const inputVATA = accounts.find(a => a.isInputVAT); let rev = 0, exp = 0, brokerPayout = 0, opCashIn = 0, opCashOut = 0; const expByAccount = new Map(); for (const t of ft) { const lines = t.lines || []; for (const l of lines) { const a = accountById.get(l.accountId); if (!a) continue; if (a.type === "Revenue") rev += (l.credit || 0) - (l.debit || 0); if (a.type === "Expense") { const amt = (l.debit || 0) - (l.credit || 0); exp += amt; expByAccount.set(a.id, (expByAccount.get(a.id) || 0) + amt); if ((a.name || "").toLowerCase().includes("broker") || (a.code || "").startsWith("55")) brokerPayout += amt; } } const bankLines = lines.filter(l => { const a = accountById.get(l.accountId); return a && (a.isBank || a.code === "1001"); }); const nonBankLines = lines.filter(l => { const a = accountById.get(l.accountId); return a && !(a.isBank || a.code === "1001"); }); const isTransfer = bankLines.length > 0 && nonBankLines.length === 0; const isOp = bankLines.length > 0 && !isTransfer && !["CI", "OD", "BT"].includes(t.txnType) && !(t.tags || "").includes("opening-balance"); if (isOp) { opCashIn += bankLines.reduce((s, l) => s + (l.debit || 0), 0); opCashOut += bankLines.reduce((s, l) => s + (l.credit || 0), 0); } } const vat = ft.filter(t => !isVATSettlementTxn(t, accounts)).reduce((sum, t) => { const out = (t.lines || []).filter(l => l.accountId === outputVATA?.id).reduce((s, l) => s + (l.credit || 0) - (l.debit || 0), 0); const inp = (t.lines || []).filter(l => l.accountId === inputVATA?.id).reduce((s, l) => s + (l.debit || 0) - (l.credit || 0), 0); return sum + (out - inp); }, 0); const topExpenseCategories = accounts.filter(a => a.type === "Expense") .map(a => ({ id: a.id, name: a.name, amount: expByAccount.get(a.id) || 0 })) .filter(e => e.amount > 0).sort((a, b) => b.amount - a.amount).slice(0, 5); return { rev, exp, grossCommissionCollected: rev, brokerShare: brokerPayout, companyNetCommissionRetained: rev - brokerPayout, operatingCashFlow: opCashIn - opCashOut, vat, topExpenseCategories, }; } // ╔══════════════════════════════════════════════════╗ // DASHBOARD // ╚══════════════════════════════════════════════════╝ function Dashboard({ accounts, txns, deals, kpis, ledger, setPage, dark, plannedExpenses, setCardDealId }) { const { isMobile, isTablet } = useWindowSize(); const reportingStartLabel = fmtDate(kpis.reportingStartDate || DEFAULT_REPORTING_START_DATE); const [dateFilter, setDateFilter] = usePersistedDateFilter("dash_period"); const inRange = t => (!dateFilter.from || (t.date || "") >= dateFilter.from) && (!dateFilter.to || (t.date || "") <= dateFilter.to); const recentTxns = [...txns].filter(t => !t.isVoid && inRange(t)).sort((a, b) => (b.date || "").localeCompare(a.date || "")).slice(0, 8); const cashAccounts = accounts.filter(a => a.isBank || a.code === "1001"); const maxCashFlow = Math.max(1, ...kpis.cashFlowSeries.map(item => Math.max(item.inflow, item.outflow, Math.abs(item.net)))); const maxPerformance = Math.max(1, ...kpis.monthlyPerformance.map(item => Math.max(item.revenue, item.expense, Math.abs(item.net)))); // Monthly income (net revenue) for the last 6 months vs the same month a year // earlier. Income = credits − debits on Revenue accounts (same as the KPI rev). const incomeYoY = useMemo(() => { const acctById = new Map((accounts || []).map(a => [a.id, a])); const byMonth = {}; (txns || []).forEach(t => { if (t.isVoid || !t.date) return; const key = String(t.date).slice(0, 7); let rev = 0; (t.lines || []).forEach(l => { const a = acctById.get(l.accountId); if (a && a.type === "Revenue") rev += (l.credit || 0) - (l.debit || 0); }); if (rev) byMonth[key] = (byMonth[key] || 0) + rev; }); const now = new Date(); const out = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const mm = String(d.getMonth() + 1).padStart(2, "0"); out.push({ label: d.toLocaleDateString("en-GB", { month: "short", year: "2-digit" }), year: d.getFullYear(), prevYear: d.getFullYear() - 1, current: byMonth[`${d.getFullYear()}-${mm}`] || 0, previous: byMonth[`${d.getFullYear() - 1}-${mm}`] || 0, }); } return out; }, [txns, accounts]); const maxIncomeYoY = Math.max(1, ...incomeYoY.flatMap(s => [s.current, s.previous])); const [includePending, setIncludePending] = usePersistedState("dash_includePending", false); const [showRecentTxns, setShowRecentTxns] = useState(false); const [expandedAlert, setExpandedAlert] = useState(null); const [dealInvoiceStatus, setDealInvoiceStatus] = useState(new Map()); useEffect(() => { const unsub = db.collection("invoices").onSnapshot(snap => { const map = new Map(); snap.docs.forEach(doc => { const inv = doc.data(); const status = inv.status || "draft"; if (status === "void") return; (inv.lineItems || []).forEach(li => { if (!li.dealId) return; if (!map.has(li.dealId) || status === "issued") map.set(li.dealId, status); }); }); setDealInvoiceStatus(map); }, err => console.error("Invoice listener (Dashboard):", err)); return () => unsub(); }, []); // Projected Runway assuming 50% collection of pipeline const projectedRunway = useMemo(() => { const effectiveCash = kpis.cash + (includePending ? (kpis.pendingPipelineCommission * 0.5) : 0); return kpis.avgMonthlyExpense > 0 ? effectiveCash / kpis.avgMonthlyExpense : Infinity; }, [kpis.cash, kpis.pendingPipelineCommission, kpis.avgMonthlyExpense, includePending]); const runwayAlertLevel = projectedRunway === Infinity ? null : projectedRunway < 1 ? "critical" : projectedRunway < 3 ? "warning" : null; // KPIs recomputed for the selected date period (responds to date filter) const filteredKpis = useMemo(() => { const ft = txns.filter(t => !t.isVoid && (!dateFilter.from || (t.date || "") >= dateFilter.from) && (!dateFilter.to || (t.date || "") <= dateFilter.to)); return computePeriodKpis(ft, accounts); }, [txns, accounts, dateFilter]); // ── Prior-year same period (YoY comparator) ────────────────────────────── const priorDateRange = useMemo(() => { const shiftY = d => d ? `${parseInt(d.slice(0, 4)) - 1}${d.slice(4)}` : null; return { from: shiftY(dateFilter.from), to: shiftY(dateFilter.to) }; }, [dateFilter.from, dateFilter.to]); const priorFilteredKpis = useMemo(() => { if (!priorDateRange.from || !priorDateRange.to) return { rev: 0, exp: 0, brokerShare: 0, companyNetCommissionRetained: 0, operatingCashFlow: 0 }; const ft = txns.filter(t => !t.isVoid && (t.date || "") >= priorDateRange.from && (t.date || "") <= priorDateRange.to); return computePeriodKpis(ft, accounts); }, [txns, accounts, priorDateRange.from, priorDateRange.to]); const priorCash = useMemo(() => { if (!priorDateRange.to) return null; const accountById = new Map(accounts.map(a => [a.id, a])); let cash = 0; txns.filter(t => !t.isVoid && (t.date || "") <= priorDateRange.to).forEach(t => { (t.lines || []).forEach(l => { const a = accountById.get(l.accountId); if (!a || !(a.isBank || a.isCash || a.code === "1001")) return; cash += (l.debit || 0) - (l.credit || 0); }); }); return cash; }, [txns, accounts, priorDateRange.to]); // Planned expenses KPIs for CEO snapshot const avgMonthlyFixed = (plannedExpenses || []).filter(e => e.expenseType === "recurring").reduce((s, e) => s + feComputeMonthlyEquivalent(e), 0); const feKpis = useMemo(() => { const today = new Date(todayStr() + "T12:00:00"); const next30 = new Date(today); next30.setDate(next30.getDate() + 30); const active = (plannedExpenses || []).filter(e => !["Paid", "Skipped", "Cancelled"].includes(e.status)); const overdue = active.filter(e => { if (!e.nextDueDate) return false; return new Date(e.nextDueDate + "T12:00:00") < today; }); const due30 = active.filter(e => { if (!e.nextDueDate) return false; const d = new Date(e.nextDueDate + "T12:00:00"); return d >= today && d <= next30; }); const totalObligations = overdue.reduce((s, e) => s + (e.amountExpected || 0), 0) + due30.reduce((s, e) => s + (e.amountExpected || 0), 0); const availableFunds = kpis.cash + (includePending ? (kpis.pendingPipelineCommission * 0.5) : 0); // Calculate historical coverage trend let runningFunds = availableFunds; const coverageSeries = [...kpis.cashFlowSeries].reverse().map((m, i) => { const closingCash = i === 0 ? runningFunds : (runningFunds -= kpis.cashFlowSeries[kpis.cashFlowSeries.length - i].net); const ratio = avgMonthlyFixed > 0 ? closingCash / avgMonthlyFixed : 0; return { label: m.label, ratio }; }).reverse(); const currentCoverage = totalObligations > 0 ? (availableFunds / totalObligations) : Infinity; return { overdueCount: overdue.length, overdueTotal: overdue.reduce((s, e) => s + (e.amountExpected || 0), 0), next30Count: due30.length, next30Total: due30.reduce((s, e) => s + (e.amountExpected || 0), 0), coverageRatio: currentCoverage, coverageSeries, maxRatio: Math.max(2, ...coverageSeries.map(s => s.ratio)) }; }, [plannedExpenses, includePending, kpis.cash, kpis.pendingPipelineCommission, kpis.cashFlowSeries]); const sectionTitle = (title, sub, actionLabel, actionPage) =>
{title}
{sub &&
{sub}
}
{actionLabel && }
; const categoryDivider = (title, sub, color) =>
{title}
{sub}
; const metricTile = ({ label, value, sub, accent, onClick, rawValue, prevValue, higherIsBetter, alertLevel, timeBasis }) => { const COMPANY_START = "2025-01-01"; // company did not exist before this date const priorRangeValid = priorDateRange.from && priorDateRange.to && priorDateRange.to >= COMPANY_START; // hide comparator if prior period is entirely before company start const hasComp = priorRangeValid && prevValue !== null && prevValue !== undefined && rawValue !== null && rawValue !== undefined; const variance = hasComp ? rawValue - prevValue : 0; const pct = hasComp && prevValue !== 0 ? (variance / Math.abs(prevValue)) * 100 : null; const varGood = higherIsBetter !== undefined ? (higherIsBetter ? variance >= 0 : variance <= 0) : variance >= 0; const varColor = variance === 0 ? "#98A2B3" : (varGood ? "#059669" : "#DC2626"); const varArrow = variance === 0 ? "●" : (variance > 0 ? "▲" : "▼"); // Show full prior date range so user knows exactly what dates are being compared const shortDate = d => { if (!d) return ""; const p = d.split("-"); return `${p[2] || ""}/${p[1] || ""}/${(p[0] || "").slice(2)}`; }; const priorFromY = priorDateRange.from ? priorDateRange.from.slice(0, 4) : ""; const priorToY = priorDateRange.to ? priorDateRange.to.slice(0, 4) : ""; const priorLabel = priorFromY === priorToY ? `${shortDate(priorDateRange.from)} – ${shortDate(priorDateRange.to)}` : `${shortDate(priorDateRange.from)} – ${shortDate(priorDateRange.to)}`; // Alert-level overrides const isCritical = alertLevel === "critical"; const isWarning = alertLevel === "warning"; const bgColor = isCritical ? "#FFF1F1" : isWarning ? "#FFFBEB" : "#ffffff"; const borderColor = isCritical ? "#FECACA" : isWarning ? "#FDE68A" : "#EAECF0"; const accentBar = isCritical ? "#DC2626" : isWarning ? "#F59E0B" : accent; const valueColor = isCritical ? "#991B1B" : isWarning ? "#92400E" : (dark ? "#E8EAF2" : NAVY); return
{ if (onClick) { e.currentTarget.style.boxShadow = "0 8px 24px rgba(16,24,40,.12)"; e.currentTarget.style.transform = "translateY(-1px)"; } }} onMouseLeave={e => { e.currentTarget.style.boxShadow = isCritical ? "0 0 0 2px #DC262620" : "0 1px 3px rgba(16,24,40,.06)"; e.currentTarget.style.transform = "none"; }}>
{label}
{timeBasis && {timeBasis === "balance" ? "As of today" : "Period"}} {(isCritical || isWarning) && {isCritical ? "🔴" : "⚠️"}}
{value}
{hasComp &&
Prior ({priorLabel}): {fmtAED(prevValue)} {varArrow} {variance >= 0 ? "+" : ""}{fmtAED(variance)} {pct !== null && ({pct >= 0 ? "+" : ""}{pct.toFixed(1)}%)}
}
{sub}
; }; const liabilitiesRatio = Math.max(0, Math.round((kpis.totalLiabilities / Math.max(kpis.totalAssets, 1)) * 100)); const collectedRatio = deals.length ? Math.round((kpis.collectedDealsCount / deals.length) * 100) : 0; const avgOpenDealCommission = kpis.openDealsCount > 0 ? fmtAED(Math.round(kpis.pendingPipelineCommission / kpis.openDealsCount)) : "AED 0.00"; const obligationCoverageLabel = feKpis.coverageRatio === Infinity ? "Fully covered" : `${feKpis.coverageRatio.toFixed(1)}x`; const periodLabel = dateFilter.from && dateFilter.to ? `${dateFilter.from} → ${dateFilter.to}` : "Selected period"; const liquidityMetrics = [ { label: "Cash & Bank", value: fmtAED(kpis.cash), sub: "Available liquidity — current balance", accent: "#2563EB", rawValue: kpis.cash, prevValue: priorCash, higherIsBetter: true, timeBasis: "balance" }, { label: "Operating Cash Flow", value: fmtAED(filteredKpis.operatingCashFlow), sub: periodLabel, accent: filteredKpis.operatingCashFlow >= 0 ? "#059669" : "#DC2626", rawValue: filteredKpis.operatingCashFlow, prevValue: priorFilteredKpis.operatingCashFlow, higherIsBetter: true, timeBasis: "period" }, { label: "Gross Commission", value: fmtAED(filteredKpis.rev), sub: periodLabel, accent: filteredKpis.rev >= 0 ? "#0F766E" : "#B91C1C", rawValue: filteredKpis.rev, prevValue: priorFilteredKpis.rev, higherIsBetter: true, timeBasis: "period" }, { label: includePending ? "Projected Runway" : "Cash Runway", value: projectedRunway === Infinity ? "✓ Healthy" : `${projectedRunway.toFixed(1)} months`, sub: projectedRunway !== Infinity && projectedRunway < 3 ? (projectedRunway < 1 ? "Immediate action required — less than 1 month left" : "Below safe threshold — target 3+ months") : (includePending ? "Assumes 50% pipeline collection" : "Cash ÷ avg monthly expense"), accent: runwayAlertLevel === "critical" ? "#DC2626" : runwayAlertLevel === "warning" ? "#F59E0B" : "#059669", alertLevel: runwayAlertLevel, timeBasis: "balance" }, ]; const profitabilityMetrics = [ { label: "Broker Share", value: fmtAED(filteredKpis.brokerShare), sub: "Commission paid to brokers — period", accent: "#D97706", rawValue: filteredKpis.brokerShare, prevValue: priorFilteredKpis.brokerShare, higherIsBetter: false, timeBasis: "period" }, { label: "Net Company Commission", value: fmtAED(filteredKpis.companyNetCommissionRetained), sub: "Gross commission retained — before overhead", accent: filteredKpis.companyNetCommissionRetained >= 0 ? "#059669" : "#DC2626", rawValue: filteredKpis.companyNetCommissionRetained, prevValue: priorFilteredKpis.companyNetCommissionRetained, higherIsBetter: true, timeBasis: "period" }, { label: "Total Expenses", value: fmtAED(filteredKpis.exp), sub: periodLabel, accent: "#6B7280", rawValue: filteredKpis.exp, prevValue: priorFilteredKpis.exp, higherIsBetter: false, timeBasis: "period" }, { label: "Net Income", value: fmtAED(filteredKpis.rev - filteredKpis.exp), sub: periodLabel, accent: (filteredKpis.rev - filteredKpis.exp) >= 0 ? NAVY : "#DC2626", rawValue: filteredKpis.rev - filteredKpis.exp, prevValue: priorFilteredKpis.rev - priorFilteredKpis.exp, higherIsBetter: true, timeBasis: "period" }, ]; const controlMetrics = [ { label: "Net VAT Position", value: fmtAED(filteredKpis.vat), sub: filteredKpis.vat >= 0 ? "Payable to FTA — review before filing" : "Recoverable from FTA", accent: filteredKpis.vat >= 0 ? "#DC2626" : "#059669", timeBasis: "period" }, { label: "Liabilities Load", value: liabilitiesRatio > 0 ? `${liabilitiesRatio}%` : "—", sub: liabilitiesRatio > 0 ? "Share of total assets funded by liabilities" : "No liabilities recorded yet", accent: liabilitiesRatio > 60 ? "#DC2626" : "#2563EB", timeBasis: "balance" }, { label: "Overdue Expenses", value: feKpis.overdueCount > 0 ? fmtAED(feKpis.overdueTotal) : "None", sub: feKpis.overdueCount > 0 ? `${feKpis.overdueCount} planned item${feKpis.overdueCount > 1 ? "s" : ""} past due date` : "All planned expenses on schedule", accent: feKpis.overdueCount > 0 ? "#DC2626" : "#059669", alertLevel: feKpis.overdueCount > 0 ? "warning" : null, onClick: () => setPage("futureExpenses") }, { label: "Due Next 30 Days", value: feKpis.next30Count > 0 ? fmtAED(feKpis.next30Total) : "None", sub: `${feKpis.next30Count} item${feKpis.next30Count !== 1 ? "s" : ""} due — Coverage ${obligationCoverageLabel}`, accent: feKpis.next30Count > 0 ? "#F59E0B" : "#059669", onClick: () => setPage("futureExpenses") }, ]; const pipelineMetrics = [ { label: "Pending Pipeline", value: fmtAED(kpis.pendingPipelineCommission), sub: "Projected — not yet collected", accent: GOLD, timeBasis: "balance" }, { label: "Open Deals", value: kpis.openDealsCount, sub: "Deals progressing through the funnel", accent: "#7C3AED", timeBasis: "balance" }, { label: "Collected Ratio", value: `${collectedRatio}%`, sub: `${kpis.collectedDealsCount} of ${deals.length} deals fully collected`, accent: collectedRatio >= 50 ? "#059669" : "#2563EB" }, { label: "Avg. Pending / Deal", value: avgOpenDealCommission, sub: "Avg. expected commission per open deal", accent: "#2563EB" }, ]; // ── Commission Aging & Management Alerts ───────────────────────────────── const todayMs = new Date(todayStr() + "T12:00:00").getTime(); const pendingDeals = (deals || []).filter(d => !["Commission Collected", "Cancelled"].includes(d.stage)); const dealsOverdue60 = pendingDeals.filter(d => { if (!d.created_at) return false; return Math.floor((todayMs - new Date(d.created_at + "T12:00:00").getTime()) / 86400000) > 60; }); const dealsEarnedNoReceipt = (deals || []).filter(d => d.stage === "Commission Earned" && !txns.some(t => t.deal_id === d.id && t.txnType === "SR" && !t.isVoid) ); const mgmtAlerts = []; if (dealsOverdue60.length > 0) { const tot = dealsOverdue60.reduce((s, d) => s + (d.expected_commission_net || 0), 0); mgmtAlerts.push({ level: tot > 100000 ? "critical" : "warning", title: `${dealsOverdue60.length} deal${dealsOverdue60.length !== 1 ? "s" : ""} overdue — over 60 days in pipeline`, detail: `${fmtAED(tot)} in expected commission has been in the pipeline for more than 60 days without collection. Chase these deals to protect cash flow.`, deals: dealsOverdue60, action: { label: "View Deals", page: "deals" } }); } if (dealsEarnedNoReceipt.length > 0) { const tot = dealsEarnedNoReceipt.reduce((s, d) => s + (d.expected_commission_net || 0), 0); mgmtAlerts.push({ level: "warning", title: `${dealsEarnedNoReceipt.length} deal${dealsEarnedNoReceipt.length !== 1 ? "s" : ""} at Commission Earned with no receipt recorded`, detail: `${fmtAED(tot)} has been marked as earned but no cash receipt exists. Confirm collection or update the deal stage.`, deals: dealsEarnedNoReceipt, action: { label: "Record Receipt", page: "receipts" } }); } // Overdue installments (Phase 3): deals whose payment plan has a past-due // amount not yet covered by collected cash. const dealsOverdueInstallments = (deals || []).map(d => { if (!Array.isArray(d.installment_schedule) || d.installment_schedule.length === 0) return null; const inst = installmentStatus(d, dealCollection(d, txns || [], accounts || []).collectedCents); return inst.overdueCents > 0 ? { deal: d, inst } : null; }).filter(Boolean); if (dealsOverdueInstallments.length > 0) { const tot = dealsOverdueInstallments.reduce((s, x) => s + x.inst.overdueCents, 0); const cnt = dealsOverdueInstallments.reduce((s, x) => s + x.inst.overdueCount, 0); mgmtAlerts.push({ level: tot > 100000 ? "critical" : "warning", title: `${cnt} installment${cnt !== 1 ? "s" : ""} overdue across ${dealsOverdueInstallments.length} deal${dealsOverdueInstallments.length !== 1 ? "s" : ""}`, detail: `${fmtAED(tot)} in scheduled commission payments is past its due date and not yet collected. Follow up to keep collections on plan.`, deals: dealsOverdueInstallments.map(x => x.deal), action: { label: "View Deals", page: "deals" } }); } const AGING_BUCKETS = [ { label: "0–30 days", min: 0, max: 30, color: "#059669", bg: "#ECFDF5" }, { label: "31–60 days", min: 31, max: 60, color: "#2563EB", bg: "#EFF6FF" }, { label: "61–90 days", min: 61, max: 90, color: "#D97706", bg: "#FFFBEB" }, { label: "90+ days", min: 91, max: Infinity, color: "#DC2626", bg: "#FEF2F2" }, ]; const agingData = AGING_BUCKETS.map(bucket => { const bDeals = pendingDeals.filter(d => { if (!d.created_at) return false; const days = Math.floor((todayMs - new Date(d.created_at + "T12:00:00").getTime()) / 86400000); return days >= bucket.min && days <= bucket.max; }); return { ...bucket, count: bDeals.length, total: bDeals.reduce((s, d) => s + (d.expected_commission_net || 0), 0) }; }); return
{/* ── Alert Strip ── */} {(() => { const alerts = []; if (runwayAlertLevel === "critical") alerts.push({ level: "critical", msg: `Cash Runway: ${projectedRunway.toFixed(1)} months — immediate action required. Review expenses or secure funding.`, action: { label: "Review Expenses", page: "futureExpenses" } }); else if (runwayAlertLevel === "warning") alerts.push({ level: "warning", msg: `Cash Runway: ${projectedRunway.toFixed(1)} months — below safe threshold of 3 months.`, action: { label: "Review Expenses", page: "futureExpenses" } }); if (kpis.cash < 0) alerts.push({ level: "critical", msg: `Negative total cash balance: ${fmtAED(kpis.cash)}. Check account entries immediately.` }); if (feKpis.overdueCount > 0) alerts.push({ level: "warning", msg: `${feKpis.overdueCount} planned expense${feKpis.overdueCount > 1 ? "s" : ""} are overdue — ${fmtAED(feKpis.overdueTotal)} total unpaid.`, action: { label: "View Overdue", page: "futureExpenses" } }); if (alerts.length === 0) return null; const hasCritical = alerts.some(a => a.level === "critical"); return
{alerts.map((a, i) =>
{a.level === "critical" ? "🔴" : "⚠️"} {a.msg} {a.action && }
)}
; })()} {/* ── Hero ── */}
Management Cockpit — {periodLabel}
{filteredKpis.rev - filteredKpis.exp >= 0 ? "Operations are profitable this period." : "Expenses are exceeding revenue this period."} {runwayAlertLevel === "critical" && ⚠ Runway critical} {runwayAlertLevel === "warning" && ⚠ Runway low}
{isMobile ?
{[ { label: "Net Income", value: fmtAED(filteredKpis.rev - filteredKpis.exp), color: (filteredKpis.rev - filteredKpis.exp) >= 0 ? "#6EE7B7" : "#FCA5A5" }, { label: "Cash Flow", value: fmtAED(filteredKpis.operatingCashFlow), color: filteredKpis.operatingCashFlow >= 0 ? "#6EE7B7" : "#FCA5A5" }, { label: "Pipeline", value: fmtAED(kpis.pendingPipelineCommission), color: GOLD }, { label: "Net Commission", value: fmtAED(filteredKpis.companyNetCommissionRetained), color: "#93C5FD" }, ].map(item => (
{item.label}
{item.value}
))}
:
Cash flow from operations: = 0 ? "#6EE7B7" : "#FCA5A5" }}>{fmtAED(filteredKpis.operatingCashFlow)}. Net commission retained: {fmtAED(filteredKpis.companyNetCommissionRetained)}. Net income: = 0 ? "#6EE7B7" : "#FCA5A5" }}>{fmtAED(filteredKpis.rev - filteredKpis.exp)}. Pipeline: {fmtAED(kpis.pendingPipelineCommission)} projected.
}
{[{ label: "Liquidity", tone: "#93C5FD" }, { label: "Profitability", tone: "#6EE7B7" }, { label: "Control", tone: "#FCA5A5" }, { label: "Pipeline", tone: GOLD }].map(item => {item.label})}
{categoryDivider("Liquidity", "Can the business fund itself and keep cash moving in the right direction?", "#2563EB")}
{liquidityMetrics.map(item =>
{metricTile({ ...item })}
)}
{categoryDivider("Profitability", "What the brokerage collects, pays out to brokers, and retains.", "#059669")}
{profitabilityMetrics.map(item =>
{metricTile({ ...item })}
)}
{categoryDivider("Control / Compliance", "VAT exposure, liabilities, and upcoming obligations.", "#DC2626")}
{controlMetrics.map(item =>
{metricTile({ ...item })}
)}
{categoryDivider("Pipeline Quality", "Commission funnel health and conversion into collected revenue.", "#7C3AED")}
{pipelineMetrics.map(item =>
{metricTile({ ...item })}
)}
{/* ── Management Alerts Panel ── */} {mgmtAlerts.length > 0 &&
{sectionTitle("Management Alerts", "Action required — commission and pipeline issues detected")}
{mgmtAlerts.map((alert, i) => { const expanded = expandedAlert === i; const hasDeals = alert.deals && alert.deals.length > 0; return
{alert.level === "critical" ? "🔴" : "⚠️"}
hasDeals && setExpandedAlert(expanded ? null : i)}>
{alert.title}{hasDeals && {expanded ? "▲ hide" : "▼ show deals"}}
{alert.detail}
{alert.action && }
{expanded && hasDeals &&
{["Property", "Client", "Broker", "In pipeline", "Expected", ""].map(h => )} {alert.deals.map(d => { const days = d.created_at ? Math.floor((todayMs - new Date(d.created_at + "T12:00:00").getTime()) / 86400000) : null; return { if (setCardDealId) { setCardDealId(d.id); setPage("deals"); } }} title="Open Deal Card" style={{ borderTop: "1px solid #F3F4F6", cursor: setCardDealId ? "pointer" : "default" }}> ; })}
{h}
{d.property_name || "—"}{d.unit_no ? · {d.unit_no} : null} {d.client_name || "—"} {d.broker_name || "—"} 60 ? "#DC2626" : "#6B7280" }}>{days != null ? `${days} days` : "—"} {fmtAED(d.expected_commission_net || 0)} 📋
}
; })}
}
{sectionTitle("Cash Flow Trend", "6 months · inflow vs outflow · net highlighted")}
{kpis.cashFlowSeries.map((item, i) =>
{item.label}
Net = 0 ? "#059669" : "#DC2626", fontVariantNumeric: "tabular-nums" }}>{item.net >= 0 ? "+" : ""}{fmtAED(item.net)}
↑ Cash in{fmtAED(item.inflow)}
↓ Cash out{fmtAED(item.outflow)}
item.inflow ? "#F87171" : "#FCA5A5", transition: "width .4s" }} />
)}
{sectionTitle("Revenue vs Expense", "6 months · profit highlighted in green, loss in red")}
{kpis.monthlyPerformance.map((item, i) =>
{item.label}
Net = 0 ? "#059669" : "#DC2626", fontVariantNumeric: "tabular-nums" }}>{item.net >= 0 ? "+" : ""}{fmtAED(item.net)}
Revenue{fmtAED(item.revenue)}
Expenses{fmtAED(item.expense)}
item.revenue ? "#F87171" : "#FBD38D", transition: "width .4s" }} />
)}
{sectionTitle("Income — This Year vs Last Year", "6 months · net commission income vs the same month a year earlier")}
{incomeYoY.map((s, i) => { const delta = s.previous > 0 ? Math.round(((s.current - s.previous) / s.previous) * 100) : null; return
{s.label}
YoY = 0 ? "#059669" : "#DC2626", fontVariantNumeric: "tabular-nums" }}>{delta == null ? "—" : (delta >= 0 ? "+" : "") + delta + "%"}
This year{fmtAED(s.current)}
0 ? 4 : 0, (s.current / maxIncomeYoY) * 100)}%`, height: "100%", background: "#38BDF8", transition: "width .4s" }} />
Last year{fmtAED(s.previous)}
0 ? 4 : 0, (s.previous / maxIncomeYoY) * 100)}%`, height: "100%", background: "#FBD38D", transition: "width .4s" }} />
; })}
{sectionTitle("Liquidity - Cash by Account", "Live balances from bank and cash ledger", "Go to Banking", "banking")}
{cashAccounts.map((a, i) => { const bal = accountBalance(a, ledger); return
{a.name} = 0 ? "#059669" : "#DC2626", whiteSpace: "nowrap" }}>{fmtAED(bal)}
; })}
Total Cash {fmtAED(kpis.cash)}
{sectionTitle("Pipeline Quality - By Type", "Expected commission still in the business pipeline", "Open Deals", "deals")}
{kpis.pipelineByType.map((row, i) =>
{row.type}
{row.count} deals
{fmtAED(row.expected)}
)}
{sectionTitle("Pipeline Quality - By Stage", "Where expected commission is currently sitting")}
{kpis.pipelineStageValue.map((row, i) =>
{row.stage}
{row.count} deals
{fmtAED(row.expected)}
)}
{sectionTitle("Control / Profitability - Top Expense Categories", `Spend concentration — ${periodLabel}`, "Open Payments", "payments")}
{filteredKpis.topExpenseCategories.length === 0 &&
No expense activity for this period.
} {filteredKpis.topExpenseCategories.map((row, i) => { const topBase = Math.max(filteredKpis.topExpenseCategories[0]?.amount || 1, 1); return
{row.name}
{fmtAED(row.amount)}
; })}
{/* ── Commission Collection Aging ── */} {pendingDeals.length > 0 &&
{sectionTitle("Commission Collection Aging", "Open deals by time in pipeline — sorted oldest first", "Open Deals", "deals")}
{agingData.map(bucket =>
{bucket.label}
{bucket.count}
deals
{fmtAED(bucket.total)}
expected
)}
{pendingDeals .map(d => { const days = d.created_at ? Math.floor((todayMs - new Date(d.created_at + "T12:00:00").getTime()) / 86400000) : null; const bucket = days === null ? null : days <= 30 ? AGING_BUCKETS[0] : days <= 60 ? AGING_BUCKETS[1] : days <= 90 ? AGING_BUCKETS[2] : AGING_BUCKETS[3]; return { ...d, days, bucket }; }) .sort((a, b) => (b.days || 0) - (a.days || 0)) .map(d => { const invStatus = dealInvoiceStatus.get(d.id); return ; })}
Property Client Stage Broker Commission Age Bucket Invoice
{d.property_name || "—"}
{d.unit_no &&
Unit {d.unit_no}
}
{d.client_name || "—"} {d.stage} {d.broker_name || "—"} {fmtAED(d.expected_commission_net || 0)} {d.days !== null ? {d.days}d : "—"} {d.bucket ? {d.bucket.label} : "—"} {invStatus === "issued" ? ✓ Issued : invStatus === "draft" ? Draft : None}
}
setShowRecentTxns(v => !v)}>
Recent Transactions
{recentTxns.length} latest entries for {periodLabel}
{showRecentTxns ? "▲" : "▼"}
{showRecentTxns &&
{recentTxns.length === 0 &&
No transactions yet. Start by recording a sale receipt or payment.
} {recentTxns.map((t, i) => { const typeInfo = TXN_TYPES[t.txnType] || { label: t.txnType || "JV" }; const total = (t.lines || []).reduce((sum, line) => sum + (line.debit || 0), 0); return
{t.description || "Manual journal entry"}
{fmtDate(t.date)} · {typeInfo.label} · {t.counterparty || "Internal"}
{t.ref}
{fmtAED(total || 0)}
; })}
}
; } // ╔══════════════════════════════════════════════════╗ // USER MANUAL PAGE // ╚══════════════════════════════════════════════════╝ function ManualPage() { return

📝 How to Enter Sales (Receipts)

  1. Navigate to the Receipts page from the sidebar.
  2. Click + Add New to create a new sale receipt.
  3. Select an existing Deal from the dropdown (or create a new deal first).
  4. Enter the Gross Amount received from the client.
  5. The system automatically calculates VAT (5%) and net revenue.
  6. Choose the Bank Account where the money was deposited.
  7. Add a Memo for reference.
  8. Click Save Receipt to post the transaction.
  9. The system creates a journal entry: DR Bank, CR Revenue, CR Output VAT.

💳 How to Enter Expenses (Payments)

  1. Navigate to the Payments page from the sidebar.
  2. Click + Add New to create a new payment voucher.
  3. Select the Expense Account from the dropdown (e.g., Office Rent, Salaries).
  4. Enter the Gross Amount of the expense.
  5. If applicable, enter the VAT Rate (usually 5% for recoverable VAT).
  6. Choose the Bank Account to pay from.
  7. Select the Counterparty (vendor or employee).
  8. Add a Memo describing the expense.
  9. Click Save Payment to post the transaction.
  10. The system creates: DR Expense (net), DR Input VAT (if applicable), CR Bank.

🗂️ Chart of Accounts Definitions

Assets (1000s)

Resources owned by the company.

  • 1001 Cash: Physical cash on hand.
  • 1002 Bank: Bank account balances.
  • 1004 Prepaid Expenses: Payments made for future services.
  • 1201 Input VAT: VAT paid on purchases, recoverable from government.
  • 1500-1510 Fixed Assets: Long-term tangible assets like furniture and computers.

Liabilities (2000s)

Amounts owed to others.

  • 2101 Output VAT: VAT collected on sales, payable to government.
  • 2105 VAT Rounding: Adjustment for VAT calculation rounding.
  • 2200 Loan Payable: Outstanding loan balances.

Equity (3000s)

Owner's stake in the company.

  • 3000 Capital Injection: Money invested by owners.
  • 3002 Retained Earnings: Accumulated profits.
  • 3100 Owner Drawings: Money taken out by owners.

Revenue (4000s)

Income from business activities.

  • 4000 Developer Commission: Fees from off-plan property sales.
  • 4010 Seller Commission: Fees from secondary market sales.
  • 4020 Rental Commission: Fees from rental property transactions.

Expenses (5000s-6000s)

Costs of running the business.

  • 5000-5020 Salaries: Compensation for employees and managers.
  • 5030 Broker Incentive: Bonuses for top performers.
  • 5100-5160 Office Expenses: Rent, utilities, supplies, cleaning.
  • 5200-5220 Marketing: Advertising and promotional costs.
  • 5300 Transportation: Travel and vehicle expenses.
  • 5400-5410 Accounting: Professional accounting services.
  • 5500-5510 Broker Payments: Commissions paid to external brokers.
  • 5600 Bank Fees: Charges from banking services.
  • 6000 Legal Services: Legal fees and consultations.
; } // ╔══════════════════════════════════════════════════╗ // DEALS PAGE // ╚══════════════════════════════════════════════════╝ function DealsPage({ deals, setDeals, customers, brokers, developers, txns, accounts, journal, persistTxn, userRole, userEmail, writeMeta, setInvoiceDeal, setPage, dealStageChanges, settings, cardDealId, setCardDealId }) { const [show, setShow] = useState(false); const [edit, setEdit] = useState(null); // Filter + sort persist across navigation; default sort = newest deals first. const [filter, setFilter] = usePersistedState("deals_filter", "All"); const [sortKey, setSortKey] = usePersistedState("deals_sortKey", "date"); const [sortDir, setSortDir] = usePersistedState("deals_sortDir", "desc"); const [cardDeal, setCardDeal] = useState(null); // Open a deal's card when another page (e.g. a dashboard alert) requests it. useEffect(() => { if (!cardDealId) return; const d = (deals || []).find(x => x.id === cardDealId); if (d) { setCardDeal(d); if (setCardDealId) setCardDealId(null); } }, [cardDealId, deals]); const [dealMutationLabel, setDealMutationLabel] = useState(""); const [dealInvoices, setDealInvoices] = useState([]); const [bpDeal, setBpDeal] = useState(null); const [bpForm, setBpForm] = useState({ date: todayStr(), amount: "", splitPct: "", paidFromCode: "1002", memo: "" }); const [bpSaving, setBpSaving] = useState(false); const brokerPaidDealIds = useMemo(() => { const fromTxns = (txns || []).filter(t => t.txnType === "BP" && !t.isVoid && t.deal_id).map(t => t.deal_id); const fromDeals = (deals || []).filter(d => (d.broker_paid_amount || 0) > 0).map(d => d.id); return new Set([...fromTxns, ...fromDeals]); }, [txns, deals]); useEffect(() => { const unsub = db.collection("invoices").onSnapshot(snap => { setDealInvoices(snap.docs.map(d => ({ id: d.id, ...d.data() }))); }, err => console.error("Invoice listener error:", err)); return () => unsub(); }, []); const empty = { type: "Off-Plan", stage: "Lead", property_name: "", developer: "", developer_id: "", broker_id: "", broker_name: "", customer_id: "", client_name: "", transaction_value: 0, commission_pct: "", expected_commission_net: 0, vat_applicable: true, unit_no: "", notes: "", created_at: todayStr() }; const pipelineSeedDeals = window.PASTED_DEALS || []; const dealWriteState = writeMeta?.deals || { status: "idle" }; const missingPipelineDeals = useMemo(() => findMissingPipelineDeals(deals, pipelineSeedDeals), [deals, pipelineSeedDeals]); const duplicateGroups = useMemo(() => { const groups = new Map(); (deals || []).forEach(deal => { const key = dealImportKey(deal); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(deal); }); return [...groups.values()].filter(group => group.length > 1); }, [deals]); const duplicateDealCount = duplicateGroups.reduce((sum, group) => sum + (group.length - 1), 0); const dedupePreview = useMemo(() => dedupeDealsByImportKey(deals, txns), [deals, txns]); const targetCountsMatch = ["Off-Plan", "Secondary", "Rental"].every(type => (dedupePreview.counts[type] || 0) === TARGET_DEAL_COUNTS[type]); useEffect(() => { const h = () => { setEdit(null); setShow(true); }; document.addEventListener("add-deal", h); return () => document.removeEventListener("add-deal", h); }, []); const inferLinkedRecord = (items, id, fallbackName) => { if (id) { const byId = items.find(x => x.id === id); if (byId) return byId; } const wanted = normDealText(fallbackName); if (!wanted) return null; const matches = items.filter(x => normDealText(x.name) === wanted); return matches.length === 1 ? matches[0] : null; }; const normalizeLinkedDealRefs = (deal) => { const normalized = { ...deal }; const selectedDeveloper = inferLinkedRecord(developers, normalized.developer_id, normalized.developer); const selectedBroker = inferLinkedRecord(brokers, normalized.broker_id, normalized.broker_name); const selectedCustomer = inferLinkedRecord(customers, normalized.customer_id, normalized.client_name); normalized.developer_id = selectedDeveloper ? selectedDeveloper.id : ""; normalized.developer = selectedDeveloper ? selectedDeveloper.name : ""; normalized.broker_id = selectedBroker ? selectedBroker.id : ""; normalized.broker_name = selectedBroker ? selectedBroker.name : ""; normalized.customer_id = selectedCustomer ? selectedCustomer.id : ""; normalized.client_name = selectedCustomer ? selectedCustomer.name : ""; return normalized; }; const normalizedDeals = useMemo(() => (deals || []).map(normalizeLinkedDealRefs), [deals, customers, brokers, developers]); const corruptedLinkCount = useMemo(() => (deals || []).reduce((count, deal, index) => { const normalized = normalizedDeals[index]; if (!normalized) return count; const changed = (deal.developer_id || "") !== (normalized.developer_id || "") || (deal.developer || "") !== (normalized.developer || "") || (deal.broker_id || "") !== (normalized.broker_id || "") || (deal.broker_name || "") !== (normalized.broker_name || "") || (deal.customer_id || "") !== (normalized.customer_id || "") || (deal.client_name || "") !== (normalized.client_name || ""); return count + (changed ? 1 : 0); }, 0), [deals, normalizedDeals]); const save = (d) => { const normalized = normalizeLinkedDealRefs(d); const isUpdate = !!normalized.id; setDealMutationLabel(isUpdate ? "Deal update" : "Deal creation"); if (isUpdate) { const existing = deals.find(x => x.id === normalized.id); if (existing && existing.stage !== normalized.stage) { logDealStageChange(normalized, existing.stage, normalized.stage, userRole, userEmail); } setDeals(prev => prev.map(x => x.id === normalized.id ? normalized : x)); } else { const newDeal = { ...normalized, id: uid() }; setDeals(prev => [...prev, newDeal]); logDealStageChange(newDeal, null, newDeal.stage, userRole, userEmail); } setShow(false); setEdit(null); toast(isUpdate ? "Deal updated" : "Deal created", "success"); logAudit(isUpdate ? "deal_update" : "deal_create", { dealId: normalized.id || null, deal: normalized }, userRole, userEmail); }; const seedMissingPipelineDeals = () => { if (!DEAL_RESEED_ENABLED) { toast("Deal reseed is disabled. Firestore is now the source of truth for deals.", "warning"); return; } if (!pipelineSeedDeals.length) { toast("No pipeline seed data loaded", "warning"); return; } if (!missingPipelineDeals.length) { toast("All pasted pipeline deals are already in the database", "success"); return; } setDealMutationLabel("Deal reseed"); setDeals(prev => [...prev, ...findMissingPipelineDeals(prev, pipelineSeedDeals)]); toast(`Seeded ${missingPipelineDeals.length} missing deals to Firestore`, "success"); logAudit("deal_reseed", { inserted: missingPipelineDeals.length }, userRole, userEmail); }; const handleDelete = async (deal) => { const linkedTxns = (txns || []).filter(t => t.deal_id === deal.id && !t.isVoid); const warningMessage = [ "Permanently delete this deal from the backend database?", `${deal.property_name || "Unnamed deal"}${deal.client_name ? ` | ${deal.client_name}` : ""}`, linkedTxns.length ? `Warning: ${linkedTxns.length} linked transaction(s) will NOT be deleted and may become orphaned.` : "Warning: this action cannot be undone." ].join("\n\n"); if (!confirm(warningMessage)) return; try { await archiveDeletedDeals([deal], "manual-delete", userRole, userEmail, { linked_transaction_ids: linkedTxns.map(t => t.id) }); } catch (err) { toast(`Delete archive failed: ${err.message}`, "error"); return; } setDealMutationLabel("Deal deletion"); setDeals(prev => prev.filter(x => x.id !== deal.id)); if (edit?.id === deal.id) { setEdit(null); setShow(false); } toast("Deal permanently deleted", "success"); logAudit("deal_delete", { dealId: deal.id, linkedTransactionIds: linkedTxns.map(t => t.id) }, userRole, userEmail); }; const handleDeduplicate = async () => { if (!duplicateDealCount) { toast("No duplicate deals found", "success"); return; } const preview = dedupeDealsByImportKey(deals, txns); const projectedCounts = formatDealCounts(preview.counts); const targetCounts = formatDealCounts(TARGET_DEAL_COUNTS); if (!["Off-Plan", "Secondary", "Rental"].every(type => (preview.counts[type] || 0) === TARGET_DEAL_COUNTS[type])) { toast(`Deduplication blocked. Projected counts are ${projectedCounts}, but target counts are ${targetCounts}.`, "warning"); return; } const linkedRemoved = preview.removed.filter(deal => (txns || []).some(t => !t.isVoid && t.deal_id === deal.id)).length; const warningMessage = [ `Deduplicate ${preview.duplicateGroups.length} duplicate deal groups?`, `This will permanently remove ${preview.removed.length} duplicate deal record(s) from Firestore.`, `Projected final counts: ${projectedCounts}.`, linkedRemoved ? `Warning: ${linkedRemoved} duplicate deal(s) have linked transactions. The dedupe logic keeps the deal records with the strongest transaction links first.` : "Only duplicate deal records will be removed.", ].join("\n\n"); if (!confirm(warningMessage)) return; try { await archiveDeletedDeals(preview.removed, "deduplicate", userRole, userEmail, { duplicate_group_count: preview.duplicateGroups.length }); } catch (err) { toast(`Deduplication archive failed: ${err.message}`, "error"); return; } setDealMutationLabel("Deal deduplication"); setDeals(preview.deduped); toast(`Deduplicated deals. Final counts: ${projectedCounts}.`, "success"); logAudit("deal_deduplicate", { removedIds: preview.removed.map(d => d.id), finalCounts: preview.counts }, userRole, userEmail); }; const handleRepairLinkedRecords = () => { if (!corruptedLinkCount) { toast("All linked deal names already match the master records", "success"); return; } const warningMessage = [ `Repair ${corruptedLinkCount} deal record(s) with corrupted broker, customer, or developer fields?`, "This will rewrite the deal names from the linked master records in Firestore.", "Linked IDs are treated as the source of truth, with exact-name recovery only when an ID is missing." ].join("\n\n"); if (!confirm(warningMessage)) return; setDealMutationLabel("Deal link repair"); setDeals(normalizedDeals); toast(`Repaired ${corruptedLinkCount} deal record(s) from linked master data.`, "success"); logAudit("deal_repair_links", { repairedCount: corruptedLinkCount }, userRole, userEmail); }; const toggleSort = (key) => { if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); else { setSortKey(key); setSortDir("asc"); } }; const filtered = filter === "All" ? normalizedDeals : normalizedDeals.filter(d => d.type === filter || d.stage === filter); const sortedDeals = useMemo(() => { const getSortValue = (deal, key) => { switch (key) { case "property": return `${deal.property_name || ""} ${deal.unit_no || ""}`.toLowerCase(); case "type": return (deal.type || "").toLowerCase(); case "stage": return (deal.stage || "").toLowerCase(); case "date": return deal.created_at || ""; case "client": return (deal.client_name || "").toLowerCase(); case "broker": return (deal.broker_name || "").toLowerCase(); case "value": return deal.transaction_value || 0; case "commission": return deal.expected_commission_net || 0; default: return ""; } }; return [...filtered].sort((a, b) => { const av = getSortValue(a, sortKey); const bv = getSortValue(b, sortKey); if (typeof av === "number" && typeof bv === "number") return sortDir === "asc" ? av - bv : bv - av; return sortDir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); }); }, [filtered, sortKey, sortDir]); const sortLabel = key => sortKey === key ? (sortDir === "asc" ? " ▲" : " ▼") : " ↕"; const SortTh = ({ sortBy, align = "left", children }) => ; return
setFilter(e.target.value)}> {DEAL_TYPES.map(t => )} {DEAL_STAGES.map(s => )} {hasPermission(userRole, 'sales.edit') && corruptedLinkCount > 0 && } {hasPermission(userRole, 'sales.edit') && duplicateDealCount > 0 && } {hasPermission(userRole, 'sales.edit') && DEAL_RESEED_ENABLED && !!pipelineSeedDeals.length && } {hasPermission(userRole, 'sales.create') && }
{["All", ...DEAL_TYPES].map(t => { const active = filter === t; return ; })}
Deal reseeding from pasted data is disabled. Firestore is now the only source of truth for deal create, edit, delete, and repair actions.
{dealMutationLabel && dealWriteState.status === "saving" &&
{dealMutationLabel} is being saved to Firestore now.
} {dealMutationLabel && dealWriteState.status === "saved" &&
{dealMutationLabel} was saved to Firestore at {dealWriteState.completedAt ? new Date(dealWriteState.completedAt).toLocaleString("en-GB") : "just now"}. Reloading the page should show the same result.
} {dealMutationLabel && dealWriteState.status === "error" &&
{dealMutationLabel} did not save to Firestore. Error: {dealWriteState.error || "Unknown error"}.
} {corruptedLinkCount > 0 &&
{corruptedLinkCount} deal record{corruptedLinkCount === 1 ? "" : "s"} have mismatched broker, customer, or developer fields. The table now resolves names from linked IDs first, and "Repair Linked Names" will rewrite the stored deal records from the master lists.
} {duplicateDealCount > 0 &&
Firestore currently contains {duplicateDealCount} suspected duplicate deal records across {duplicateGroups.length} duplicate group{duplicateGroups.length === 1 ? "" : "s"}. This usually happens when old seed deals were written into the database. Projected post-dedup counts: {formatDealCounts(dedupePreview.counts)}. Target counts: {formatDealCounts(TARGET_DEAL_COUNTS)}. New deleted deals should no longer come back after the sync fix.
}
DatePropertyTypeStageClientBrokerValueCommission {filtered.length === 0 && } {sortedDeals.map(d => { if (hasPermission(userRole, 'sales.edit')) { setEdit(d); setShow(true); } }}> )}
Actions
No deals found. Click "+ New Deal" to create one.
{d.created_at ? fmtDate(d.created_at) : "--"}
{d.property_name || "—"}
{d.unit_no && `Unit ${d.unit_no}`}
{d.type} {d.stage} {d.client_name || "—"} {d.broker_name || "—"} {d.transaction_value ? fmtAED(d.transaction_value) : "--"}
{fmtAED(d.expected_commission_net || 0)}
{d.vat_applicable &&
+ 5% VAT
}
{hasPermission(userRole, 'sales.create') && (() => { const isDone = d.stage === "Commission Collected" || d.stage === "Cancelled"; if (isDone) return null; const ci = commissionInvoicing(d, dealInvoices); if (ci.fullyInvoiced) return ( ✓ Invoiced ); const openInvoice = e => { e.stopPropagation(); setInvoiceDeal(d); setPage("invoices"); }; return {ci.partiallyInvoiced && {fmtAED(ci.remainingCents)} left} ; })()} {hasPermission(userRole, 'finance.create') && d.broker_id && d.stage !== "Cancelled" && (() => { if (brokerPaidDealIds.has(d.id)) return ( ✓ Broker Paid ); return ; })()} {hasPermission(userRole, 'sales.edit') && } {hasPermission(userRole, 'sales.edit') && }
{/* Deal Modal */} {show &&
setShow(false)}>
e.stopPropagation()}>
{edit?.id ? "Edit Deal" : "New Deal"}
setShow(false)} customers={customers} brokers={brokers} developers={developers} invoices={dealInvoices} txns={txns} accounts={accounts} />
} {cardDeal && setCardDeal(null)} />} {/* Pay Broker Modal */} {bpDeal && (() => { const bankAccts = (accounts || []).filter(a => a.isBank); const cashAccts = (accounts || []).filter(a => a.isCash || (!a.isBank && a.type === "Asset" && a.code === "1001")); const allLiquid = [...bankAccts, ...cashAccts]; const totalComm = bpDeal.expected_commission_net ? fromCents(bpDeal.expected_commission_net) : 0; const brokerAmt = parseFloat(bpForm.amount) || 0; const companyRetains = totalComm > 0 ? Math.max(0, totalComm - brokerAmt) : 0; const splitPctNum = parseFloat(bpForm.splitPct); const handleSplitPctChange = (val) => { const pct = parseFloat(val); if (!isNaN(pct) && totalComm > 0) { const calculated = Math.round(totalComm * pct / 100 * 100) / 100; setBpForm(p => ({ ...p, splitPct: val, amount: String(calculated) })); } else { setBpForm(p => ({ ...p, splitPct: val })); } }; const handleAmountChange = (val) => { const amt = parseFloat(val); if (!isNaN(amt) && totalComm > 0) { const impliedPct = Math.round(amt / totalComm * 10000) / 100; setBpForm(p => ({ ...p, amount: val, splitPct: String(impliedPct) })); } else { setBpForm(p => ({ ...p, amount: val, splitPct: "" })); } }; const handlePayBroker = async () => { if (!brokerAmt || brokerAmt <= 0) { toast("Enter a valid amount", "error"); return; } if (!bpForm.date) { toast("Select a date", "error"); return; } setBpSaving(true); try { const txn = journal.postBrokerPayment({ date: bpForm.date, deal: bpDeal, brokerAmount: brokerAmt, paidFromCode: bpForm.paidFromCode, memo: bpForm.memo, commit: false }); await persistTxn(txn); toast(`Broker payment of ${fmtAED(toCents(brokerAmt))} recorded`, "success"); setBpDeal(null); } catch (err) { toast("Save failed: " + err.message, "error"); } finally { setBpSaving(false); } }; const handleRecordNoBankPayment = async () => { if (!brokerAmt || brokerAmt <= 0) { toast("Enter a valid amount", "error"); return; } setBpSaving(true); try { const updated = { ...bpDeal, broker_paid_amount: toCents(brokerAmt), broker_paid_date: bpForm.date }; setDeals(prev => prev.map(d => d.id === bpDeal.id ? updated : d)); toast(`Broker payment of ${fmtAED(toCents(brokerAmt))} recorded (no bank transaction)`, "success"); setBpDeal(null); } catch (err) { toast("Save failed: " + err.message, "error"); } finally { setBpSaving(false); } }; return
setBpDeal(null)}>
e.stopPropagation()}>
Pay Broker
{bpDeal.property_name || "Deal"}
Broker: {bpDeal.broker_name}  |  Total commission: {fmtAED(bpDeal.expected_commission_net || 0)}
{/* Split summary bar */} {brokerAmt > 0 && totalComm > 0 &&
Broker {!isNaN(splitPctNum) ? `${splitPctNum}%` : ""}
Company {!isNaN(splitPctNum) ? `${Math.round((100 - splitPctNum) * 100) / 100}%` : ""}
Broker: {fmtAED(toCents(brokerAmt))} Company retains: {fmtAED(toCents(companyRetains))}
}
setBpForm(p => ({ ...p, date: e.target.value }))} />
handleSplitPctChange(e.target.value)} placeholder="e.g. 50" style={{ paddingRight: 32 }} /> %
handleAmountChange(e.target.value)} placeholder="Auto-calculated from %" />
setBpForm(p => ({ ...p, paidFromCode: e.target.value }))}> {bankAccts.length > 0 && {bankAccts.map(a => )}} {cashAccts.length > 0 && {cashAccts.map(a => )}} {allLiquid.length === 0 && }
setBpForm(p => ({ ...p, memo: e.target.value }))} />
; })()}
; } // Capture a DOM element to a multi-page A4 PDF. Pages are broken at whitespace // so rows / boxes / tables are never sliced through ("interrupted by the page // end"). A fixed wide windowWidth forces mobile to render exactly like desktop // (otherwise mobile font-boosting inflates the text). Returns jsPDF or null. async function captureElementToPdf(elementId) { if (!window.html2canvas || !window.jspdf) { toast("PDF libraries not loaded", "error"); return null; } const el = document.getElementById(elementId); if (!el) { toast("Nothing to export", "error"); return null; } try { const canvas = await window.html2canvas(el, { scale: 2, useCORS: true, allowTaint: true, backgroundColor: "#ffffff", logging: false, scrollX: 0, scrollY: -window.scrollY, windowWidth: 1100, windowHeight: 1400 }); const { jsPDF } = window.jspdf; const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); const pageW = pdf.internal.pageSize.getWidth(), pageH = pdf.internal.pageSize.getHeight(); const cw = canvas.width, ch = canvas.height; const pxPerMm = cw / pageW; // canvas px that map to 1mm at full page width const pageCanvasH = Math.floor(pageH * pxPerMm); // one A4 page worth of canvas rows // Fits on a single page — add as-is, no slicing. if (ch <= pageCanvasH + 4) { pdf.addImage(canvas.toDataURL("image/png"), "PNG", 0, 0, pageW, ch / pxPerMm); return pdf; } // Read pixels so we can break pages on near-white rows (skips text/boxes). let pix = null; try { pix = canvas.getContext("2d").getImageData(0, 0, cw, ch).data; } catch (e) { pix = null; } const isWhiteRow = (y) => { if (!pix) return false; const limit = Math.max(2, Math.floor(cw * 0.004)); let dirty = 0; for (let x = 0; x < cw; x += 4) { const i = (y * cw + x) * 4; if (pix[i] < 248 || pix[i + 1] < 248 || pix[i + 2] < 248) { if (++dirty > limit) return false; } } return true; }; const maxScanBack = Math.floor(pageCanvasH * 0.18); // how far up to hunt for a clean cut const tmp = document.createElement("canvas"), tctx = tmp.getContext("2d"); let start = 0, first = true; while (start < ch) { let end = Math.min(start + pageCanvasH, ch); if (end < ch) { // not the last slice — find a clean break for (let y = end; y > end - maxScanBack && y > start + 10; y--) { if (isWhiteRow(y)) { end = y; break; } } } const sliceH = end - start; tmp.width = cw; tmp.height = sliceH; tctx.fillStyle = "#ffffff"; tctx.fillRect(0, 0, cw, sliceH); tctx.drawImage(canvas, 0, start, cw, sliceH, 0, 0, cw, sliceH); if (!first) pdf.addPage(); pdf.addImage(tmp.toDataURL("image/png"), "PNG", 0, 0, pageW, sliceH / pxPerMm); first = false; start = end; } return pdf; } catch (err) { console.error(err); toast("PDF export failed: " + err.message, "error"); return null; } } // Build the per-stage timeline (with the age spent in each stage) from the // deal_stage_changes records, anchored on the deal's created date. function buildStageTimeline(deal, changes) { const recs = (changes || []).filter(r => r.deal_id === deal.id).slice().sort((a, b) => String(a.timestamp || a.date).localeCompare(String(b.timestamp || b.date))); const nowIso = new Date().toISOString(); const daysBetween = (a, b) => { const d = (new Date(b) - new Date(a)) / 86400000; return d > 0 ? Math.floor(d) : 0; }; const startAt = deal.created_at ? deal.created_at + "T00:00:00" : (recs[0] ? recs[0].timestamp : nowIso); if (recs.length === 0) return [{ stage: deal.stage || "—", enteredAt: startAt, current: true, days: daysBetween(startAt, nowIso) }]; const out = []; let prevStage = recs[0].from_stage || deal.stage || "—"; let prevAt = startAt; recs.forEach(r => { const at = r.timestamp || (r.date ? r.date + "T00:00:00" : nowIso); out.push({ stage: prevStage, enteredAt: prevAt, leftAt: at, days: daysBetween(prevAt, at) }); prevStage = r.to_stage || prevStage; prevAt = at; }); out.push({ stage: prevStage, enteredAt: prevAt, current: true, days: daysBetween(prevAt, nowIso) }); return out; } // Read-only "dossier" for one deal — everything the system knows, with PDF / // WhatsApp share. Visible to anyone with Deals access. function DealCard({ deal, txns, accounts, invoices, customers, brokers, developers, dealStageChanges, settings, onClose, persistTxn, userRole }) { const [showFull, setShowFull] = useState(false); const [busy, setBusy] = useState(false); const [linkingId, setLinkingId] = useState(""); const [showPicker, setShowPicker] = useState(false); const [pickerQ, setPickerQ] = useState(""); const [pickerDir, setPickerDir] = useState("in"); const { isMobile } = useWindowSize(); const isSecondary = deal.type === "Secondary"; const acctById = useMemo(() => new Map((accounts || []).map(a => [a.id, a])), [accounts]); const isCash = (id) => { const a = acctById.get(id); return !!(a && (a.isBank || a.isCash || a.code === "1001" || a.code === "1002")); }; const acctName = (id) => (acctById.get(id) || {}).name || "—"; const linked = useMemo(() => (txns || []).filter(t => t.deal_id === deal.id || (t.lines || []).some(l => l.deal_id === deal.id)).slice().sort((a, b) => String(a.date).localeCompare(String(b.date)) || String(a.ref).localeCompare(String(b.ref))), [txns, deal.id]); // Transactions that look related (by client / property / seller name appearing // in the description, counterparty, or line memos) but aren't linked. Surfaced // so they can be verified and linked; NOT counted in the totals above. const related = useMemo(() => { const linkedIds = new Set(linked.map(t => t.id)); const norm = s => String(s || "").toLowerCase(); const propN = norm(deal.property_name), clientN = norm(deal.client_name), sellerN = norm(deal.seller_name); return (txns || []).filter(t => { if (t.isVoid || linkedIds.has(t.id) || (t.deal_id && t.deal_id !== deal.id)) return false; const hay = norm(t.description) + " " + norm(t.counterparty) + " " + (t.lines || []).map(l => norm(l.memo)).join(" "); return (clientN.length >= 4 && hay.includes(clientN)) || (sellerN.length >= 4 && hay.includes(sellerN)) || (propN.length >= 4 && hay.includes(propN)); }).slice().sort((a, b) => String(a.date).localeCompare(String(b.date))); }, [txns, deal, linked]); const linkOne = async (t) => { if (!persistTxn) { toast("Linking isn't available here", "error"); return; } setLinkingId(t.id); try { await persistTxn({ ...t, deal_id: deal.id, counterparty: t.counterparty || deal.client_name || "", lines: (t.lines || []).map(l => ({ ...l, deal_id: deal.id })) }); toast("Transaction linked to this deal", "success"); } catch (err) { toast("Link failed: " + err.message, "error"); } finally { setLinkingId(""); } }; // Manual picker: every transaction not yet linked to ANY deal, searchable by // amount / name / ref — the reliable way to attach raw imported bank lines. const unlinkedTxns = useMemo(() => { const linkedIds = new Set(linked.map(t => t.id)); return (txns || []).filter(t => !t.isVoid && !linkedIds.has(t.id) && !t.deal_id).slice().sort((a, b) => String(b.date).localeCompare(String(a.date))); }, [txns, linked]); const txnInOut = (t) => ({ inAmt: (t.lines || []).reduce((s, l) => s + (isCash(l.accountId) ? (l.debit || 0) : 0), 0), outAmt: (t.lines || []).reduce((s, l) => s + (isCash(l.accountId) ? (l.credit || 0) : 0), 0), }); const pickerResults = useMemo(() => { const q = pickerQ.trim().toLowerCase(); const qDigits = q.replace(/[^0-9]/g, ""); return unlinkedTxns.filter(t => { const { inAmt, outAmt } = txnInOut(t); if (pickerDir === "in" && inAmt <= 0) return false; if (pickerDir === "out" && outAmt <= 0) return false; if (pickerDir === "all" && inAmt <= 0 && outAmt <= 0) return false; if (!q) return true; const amt = inAmt || outAmt; const hay = `${t.description || ""} ${t.ref || ""} ${t.counterparty || ""} ${(t.lines || []).map(l => l.memo || "").join(" ")}`.toLowerCase(); return hay.includes(q) || (qDigits.length >= 3 && String(Math.round(amt / 100)).includes(qDigits)); }).slice(0, 60); }, [unlinkedTxns, pickerQ, pickerDir]); const ci = commissionInvoicing(deal, invoices || []); const col = dealCollection(deal, txns || [], accounts || []); const inst = installmentStatus(deal, col.collectedCents); const brokerPaidFromTxns = linked.filter(t => !t.isVoid && t.txnType === "BP").reduce((s, t) => s + (t.lines || []).reduce((ss, l) => ss + ((acctById.get(l.accountId) || {}).type === "Expense" ? (l.debit || 0) : 0), 0), 0); const brokerPaid = brokerPaidFromTxns > 0 ? brokerPaidFromTxns : (deal.broker_paid_amount || 0); const netRetained = col.collectedCents - brokerPaid; const margin = col.collectedCents > 0 ? Math.round((netRetained / col.collectedCents) * 100) : null; const dealInvoices = useMemo(() => (invoices || []).filter(inv => (inv.lineItems || []).some(li => li.dealId === deal.id)), [invoices, deal.id]); // Sale Receipts posted from a specific invoice, keyed by invoice id, so the // Invoices table can flag which invoices have been collected ("✓ Receipted"). const receiptByInvoiceId = useMemo(() => { const m = new Map(); (txns || []).forEach(t => { if (t.txnType === "SR" && !t.isVoid && t.invoice_id) m.set(t.invoice_id, t); }); return m; }, [txns]); const broker = (brokers || []).find(b => b.id === deal.broker_id); const developer = (developers || []).find(x => x.id === deal.developer_id); const client = (customers || []).find(c => c.id === deal.customer_id); const seller = (customers || []).find(c => c.id === deal.seller_customer_id); const timeline = useMemo(() => buildStageTimeline(deal, dealStageChanges), [deal, dealStageChanges]); const CARD_ID = "deal-card-content"; const fileName = `DealCard_${(deal.property_name || "deal").replace(/[^\w]+/g, "_")}${deal.unit_no ? "_" + deal.unit_no : ""}.pdf`; const summaryText = `Deal Card — ${deal.property_name || ""}${deal.unit_no ? " · Unit " + deal.unit_no : ""}\nStage: ${deal.stage}\nValue: ${fmtAED(deal.transaction_value || 0)}\nExpected commission: ${fmtAED(deal.expected_commission_net || 0)}\nCollected: ${fmtAED(col.collectedCents)} · Remaining: ${fmtAED(col.remainingCents)}`; const handleDownload = async () => { setBusy(true); const pdf = await captureElementToPdf(REPORT_ID); if (pdf) pdf.save(fileName); setBusy(false); }; const handleShare = async () => { setBusy(true); const pdf = await captureElementToPdf(REPORT_ID); setBusy(false); if (!pdf) return; try { const file = new File([pdf.output("blob")], fileName, { type: "application/pdf" }); if (navigator.canShare && navigator.canShare({ files: [file] })) { await navigator.share({ files: [file], title: "Deal Card", text: summaryText }); } else { pdf.save(fileName); window.open("https://wa.me/?text=" + encodeURIComponent(summaryText), "_blank"); toast("PDF downloaded — attach it in the WhatsApp chat that just opened", "info"); } } catch (e) { /* user cancelled the share sheet */ } }; const fmtDays = (d) => d < 1 ? "<1 day" : d === 1 ? "1 day" : d < 60 ? d + " days" : Math.round(d / 30) + " months"; const instChip = (s) => { const m = { paid: ["Paid", "#059669", "#ECFDF5", "#A7F3D0"], partial: ["Partial", "#B45309", "#FFFBEB", "#FDE68A"], overdue: ["Overdue", "#B91C1C", "#FEF2F2", "#FECACA"], upcoming: ["Upcoming", "#6B7280", "#F3F4F6", "#E5E7EB"] }; const [txt, color, bg, bd] = m[s] || m.upcoming; return {txt}; }; // Styled A4 report (for PDF / WhatsApp) — rendered off-screen, captured by id. const REPORT_ID = "deal-card-report"; const RGOLD = "#B8902F"; const reportRef = `NP-${new Date().getFullYear()}-${((deal.id || "").replace(/[^A-Za-z0-9]/g, "").slice(-6) || "DEAL").toUpperCase()}`; const rTh = { fontSize: 9, fontWeight: 700, letterSpacing: "0.08em", color: "#9CA3AF", textTransform: "uppercase", padding: "7px 10px", textAlign: "left", borderBottom: "1.5px solid #E5E7EB", background: "#FAFAFB" }; const rTd = { fontSize: 10.5, color: "#1F2937", padding: "7px 10px", borderBottom: "1px solid #F1F2F4" }; const rSecTitle = (t) =>
{t}
; const sec = (title, children, extra) =>
{title}{extra}
{children}
; const kv = (k, v) =>
{k}{v}
; const party = (label, rec, fallbackName) =>
{label}
{(rec && rec.name) || fallbackName || "—"}
{rec && (rec.phone || rec.contactNo) &&
📞 {rec.phone || rec.contactNo}
} {rec && rec.email &&
✉ {rec.email}
} {rec && rec.trn &&
TRN {rec.trn}
}
; return
e.stopPropagation()}>
📋 Deal Card
{deal.property_name || "—"}{deal.unit_no ? · Unit {deal.unit_no} : null}
{deal.type} {deal.stage} {deal.vat_applicable && VAT 5%}
{settings?.company || "NASAMA PROPERTIES"}
Created {deal.created_at ? fmtDate(deal.created_at) : "—"}
Generated {fmtDate(todayStr())}
{sec("Parties",
{party("Developer", developer, deal.developer)} {party("Broker", broker, deal.broker_name)} {party(isSecondary ? "Buyer client" : "Client", client, deal.client_name)} {isSecondary && party("Seller client", seller, deal.seller_name)}
)} {sec("Deal economics",
{kv("Transaction value", fmtAED(deal.transaction_value || 0))} {kv(isSecondary ? "Buyer commission %" : "Commission %", deal.commission_pct ? deal.commission_pct + "%" : "—")} {isSecondary && kv("Seller commission %", deal.seller_commission_pct ? deal.seller_commission_pct + "%" : "—")} {isSecondary && kv("Seller commission", fmtAED(deal.seller_commission || 0))} {(deal.discount || 0) > 0 && kv("Discount", "− " + fmtAED(deal.discount))} {kv("Expected net commission", fmtAED(deal.expected_commission_net || 0))} {kv("VAT", deal.vat_applicable ? "Yes — 5% added on invoice" : "No")} {deal.vat_applicable && kv("Commission incl. VAT", fmtAED(Math.round((deal.expected_commission_net || 0) * 1.05)))}
)} {sec("Commissions & Invoicing",
{ci.commissions.map(c =>
{c.label}{c.invoiceNumbers.length ? · #{c.invoiceNumbers.join(", #")} : null} Target {fmtAED(c.target)} Invoiced {fmtAED(c.invoicedCents)} 0 ? "#B45309" : "#059669", fontWeight: 700 }}>Left {fmtAED(c.remainingCents)}
)}
Net expected {fmtAED(ci.netExpected)} Invoiced {fmtAED(ci.totalInvoicedCents)} · Remaining {fmtAED(ci.remainingCents)}
)} {sec(`Collection${col.count ? ` · ${col.count} installment${col.count !== 1 ? "s" : ""} received` : ""}`,
Collected {fmtAED(col.collectedCents)} 0 ? "#B45309" : "#059669" }}>Remaining to collect {fmtAED(col.remainingCents)}
{col.receipts.map((r, i) =>
Installment {i + 1}{r.date ? " · " + fmtDate(r.date) : ""}{r.ref ? " · " + r.ref : ""}{fmtAED(r.cents)}
)} {col.collectedCents === 0 &&
Nothing collected yet.
}
)} {inst.hasSchedule && sec(`Payment Schedule · ${inst.paidCount}/${inst.rows.length} paid`,
{inst.nextDue &&
0 ? "#B91C1C" : NAVY }}>{inst.overdueCents > 0 ? `⚠ ${fmtAED(inst.overdueCents)} overdue` : "Next due"} {fmtAED(inst.nextDue.dueCents)}{inst.nextDue.dueDate ? " · " + fmtDate(inst.nextDue.dueDate) : ""}
} {["#", "Due date", "Planned", "Received", "Status"].map((h, hi) => )} {inst.rows.map(r => )}
{h}
{r.index} {r.dueDate ? fmtDate(r.dueDate) : "—"} {fmtAED(r.amountCents)} {r.paidCents > 0 ? fmtAED(r.paidCents) : ""} {instChip(r.status)}
{inst.surplusCents > 0 &&
Collected {fmtAED(inst.surplusCents)} beyond the scheduled plan.
}
)} {sec("Profitability",
{kv("Cash collected", fmtAED(col.collectedCents))} {kv("Broker paid", brokerPaid > 0 ? "− " + fmtAED(brokerPaid) : "—")} {kv("Net retained", fmtAED(netRetained))} {kv("Margin", margin !== null ? margin + "%" : "—")}
)} {sec(`Linked transactions${linked.length ? ` · ${linked.length}` : ""}`,
{linked.length === 0 ?
No transactions linked to this deal yet.
: {["Date", "Ref", "Type", "Description", "In", "Out"].map(h => )} {linked.map((t, i) => { const inAmt = (t.lines || []).reduce((s, l) => s + (!t.isVoid && isCash(l.accountId) ? (l.debit || 0) : 0), 0); const outAmt = (t.lines || []).reduce((s, l) => s + (!t.isVoid && isCash(l.accountId) ? (l.credit || 0) : 0), 0); return {showFull && (t.lines || []).map((l, li) => )} ; })}
{h}
{t.date ? fmtDate(t.date) : "—"} {t.ref || "—"}{t.isVoid ? " (void)" : ""} {TXN_TYPES[t.txnType]?.label || t.txnType || "—"} {t.description || "—"} {inAmt > 0 ? fmtAED(inAmt) : ""} {outAmt > 0 ? fmtAED(outAmt) : ""}
{acctName(l.accountId)}{l.memo ? ` — ${l.memo}` : ""} {(l.debit || 0) > 0 ? fmtAED(l.debit) : ""} {(l.credit || 0) > 0 ? fmtAED(l.credit) : ""}
} {showPicker &&
{[["in", "Money in (received)"], ["out", "Money out (paid)"], ["all", "All"]].map(([id, label]) => )}
setPickerQ(e.target.value)} style={{ ...C.input, fontSize: 12, marginBottom: 6 }} />
{pickerResults.length === 0 ?
No matching unlinked transactions.
: pickerResults.map(t => { const { inAmt, outAmt } = txnInOut(t); const isIn = inAmt > 0; return
{t.description || t.ref || "—"}
{t.date ? fmtDate(t.date) : ""} · {t.ref || ""} · {TXN_TYPES[t.txnType]?.label || t.txnType}
{isIn ? "" : "− "}{fmtAED(isIn ? inAmt : outAmt)}
; })}
{pickerResults.length >= 60 &&
Showing first 60 — refine your search.
}
}
,
{linked.length > 0 && } {persistTxn && hasPermission(userRole, 'canEditTxns') && }
)} {related.length > 0 && sec(`Possibly related — not linked · ${related.length}`,
These match this deal by client or property name but aren't linked, so they are NOT counted above. Verify, then Link to attach them (updates Collection & Profitability).
{["Date", "Ref", "Type", "Description", "Amount", ""].map(h => )} {related.map(t => { const amt = (t.lines || []).reduce((s, l) => s + (l.debit || 0), 0); return ; })}
{h}
{t.date ? fmtDate(t.date) : "—"} {t.ref || "—"} {TXN_TYPES[t.txnType]?.label || t.txnType || "—"} {t.description || "—"} {fmtAED(amt)} {persistTxn && hasPermission(userRole, 'canEditTxns') ? : null}
)} {dealInvoices.length > 0 && sec("Invoices", {["Invoice #", "Date", "Status", "Collected", "Total incl VAT"].map(h => )}{dealInvoices.map(inv => )}
{h}
{inv.invoiceNumber || "—"} {inv.invoiceDate ? invFmtDate(inv.invoiceDate) : "—"} {inv.status === "void" ? "Void" : inv.status === "issued" ? "Issued" : "Draft"} {(() => { const rc = receiptByInvoiceId.get(inv.id); return rc ? ✓ {rc.ref || "Receipted"} : ; })()} AED {invFmt(inv.totals?.incl)}
)} {sec("Stage history",
{timeline.map((s, i) =>
{s.stage} {s.enteredAt ? fmtDate(String(s.enteredAt).slice(0, 10)) : ""}{s.leftAt ? ` → ${fmtDate(String(s.leftAt).slice(0, 10))}` : ""} {fmtDays(s.days)}{s.current ? " · current" : ""}
)}
)} {deal.notes && sec("Notes",
{deal.notes}
)}
{/* Off-screen A4-styled report — captured for PDF / WhatsApp */} ; } function DealForm({ initial, onSave, onCancel, customers, brokers, developers, invoices, txns, accounts }) { const [d, setD] = useState({ ...initial }); const up = (k, v) => setD(p => { const next = { ...p, [k]: v }; if (next.type === "Secondary") { const val = next.transaction_value || 0; const buyerPct = parseFloat(next.commission_pct) || 0; const sellerPct = parseFloat(next.seller_commission_pct) || 0; const disc = next.discount || 0; const buyerComm = val && buyerPct ? Math.round(val * buyerPct / 100) : 0; const sellerComm = val && sellerPct ? Math.round(val * sellerPct / 100) : 0; next.seller_commission = sellerComm; next.expected_commission_net = buyerComm + sellerComm - disc; } else if (k === "transaction_value" || k === "commission_pct" || k === "type") { const val = next.transaction_value; const pct = next.commission_pct; if (val && pct) next.expected_commission_net = Math.round(val * parseFloat(pct) / 100); } return next; }); const isSecondary = d.type === "Secondary"; // Decoupled text buffers for the amount fields: the user types freely and // cents are derived via toCents (avoids reformatting every keystroke). const [txnValueText, setTxnValueText] = useState(d.transaction_value ? fromCents(d.transaction_value) : ""); const [discountText, setDiscountText] = useState(d.discount ? fromCents(d.discount) : ""); const [expectedText, setExpectedText] = useState(d.expected_commission_net ? fromCents(d.expected_commission_net) : ""); // Expected commission is auto-computed (value × % − discount), so resync its // text buffer whenever that computed cents value changes. Transaction value and // discount are only ever set by their own inputs, so they need no resync effect. useEffect(() => { if (toCents(expectedText) !== (d.expected_commission_net || 0)) setExpectedText(d.expected_commission_net ? fromCents(d.expected_commission_net) : ""); }, [d.expected_commission_net]); return
up("type", e.target.value)}>{DEAL_TYPES.map(t => )}
up("stage", e.target.value)}>{DEAL_STAGES.map(s => )}
up("property_name", e.target.value)} />
up("unit_no", e.target.value)} />
{ const dev = developers.find(x => x.id === e.target.value); up("developer_id", e.target.value); up("developer", dev ? dev.name : ""); }}> {developers.map(v => )}
{ const br = brokers.find(x => x.id === e.target.value); up("broker_id", e.target.value); up("broker_name", br ? br.name : ""); }}> {brokers.map(b => )}
{ const c = customers.find(x => x.id === e.target.value); up("customer_id", e.target.value); up("client_name", c ? c.name : ""); }}> {customers.map(c => )}
{isSecondary &&
{ const c = customers.find(x => x.id === e.target.value); up("seller_customer_id", e.target.value); up("seller_name", c ? c.name : ""); }}> {customers.map(c => )}
}
{ setTxnValueText(e.target.value); up("transaction_value", toCents(e.target.value)); }} placeholder="Optional if you only know the commission amount" />
up("commission_pct", e.target.value)} placeholder="Optional" />
{isSecondary &&
up("seller_commission_pct", e.target.value)} placeholder="Optional" />
} {isSecondary &&
} {isSecondary &&
{ setDiscountText(e.target.value); up("discount", toCents(e.target.value)); }} placeholder="Optional" />
}
{ setExpectedText(e.target.value); up("expected_commission_net", toCents(e.target.value)); }} disabled={isSecondary} style={isSecondary ? { background: "#F3F4F6", color: "#374151" } : {}} placeholder={isSecondary ? "Auto-calculated (buyer + seller − discount)" : "You can enter this directly from your sheet"} />
up("vat_applicable", e.target.value === "yes")}>
up("created_at", e.target.value)} />