/** * ============================================================ * ESTATE PLATFORM — Full Stack Frontend * A real estate plot management system for Nigerian developers * ============================================================ * * ARCHITECTURE OVERVIEW * ───────────────────── * 1. CONSTANTS & THEME — Design tokens, colors, fonts * 2. MOCK DATA & STATE SEED — Initial app state (replace with API calls in production) * 3. UTILITY FUNCTIONS — Formatters, helpers, date utils * 4. CONTEXT (AppContext) — Global state: auth, estates, plots, notifications * 5. HOOKS — useNotifications, usePaymentDue * 6. SHARED UI COMPONENTS — Button, Badge, Modal, Input, Card, Table, etc. * 7. AUTH VIEWS — Login screen * 8. PRIMARY DEVELOPER VIEWS * 8a. Dashboard — KPIs, revenue summary, recent activity * 8b. Estate Manager — Create/open estates, upload layout * 8c. Plot Grid — Visual estate map (the "seat selector") * 8d. Payment Approvals — Approve/reject payment submissions * 8e. Developer Accounts — Create, disable, allocate plots to secondary devs * 8f. Sales Records — Full transaction history * 8g. Notifications — Payment due alerts, approvals * 9. SECONDARY DEVELOPER VIEWS * 9a. My Dashboard — Their KPIs only * 9b. Estate Map — View-only map + submit payments * 9c. My Sales — Their own transaction history * 9d. Submit Payment — Paystack / bank transfer flow * 10. APP SHELL — Sidebar, TopBar, routing * 11. ROOT EXPORT — * * PRODUCTION NOTES (for the developer inheriting this) * ───────────────────────────────────────────────────── * - Replace AppContext mock state with real API calls (e.g. Supabase, Firebase, or custom Node/Express backend) * - Paystack integration: swap the mock handlePaystackPay() with the real Paystack Popup JS SDK * - Auth: replace mock login with JWT-based auth (store token in httpOnly cookie, not localStorage) * - Image uploads: hook up to Cloudinary or S3 for estate layout images * - Notifications: connect to a real-time service (e.g. Pusher, Supabase Realtime, or WebSockets) * - This file can be split into separate component files following the section numbers above */ import { useState, useContext, createContext, useCallback, useEffect, useRef } from "react"; // ============================================================ // SECTION 1: CONSTANTS & THEME // ============================================================ const THEME = { // Core palette — warm earth tones evoke Nigerian soil, gold evokes prosperity bg: "#F7F4EF", surface: "#FFFFFF", surfaceAlt: "#F0ECE5", border: "#E4DDD3", borderDark: "#C8BFB0", // Brand primary: "#1C3A2B", // Deep forest green primaryHov: "#254D3A", gold: "#C8923A", // Warm gold accent goldLight: "#F5E6D0", // Status colors available: { bg: "#EAF5EE", text: "#1A6636", dot: "#2DB05A", border: "#B8DECA" }, reserved: { bg: "#FEF7E6", text: "#7A5200", dot: "#F0A500", border: "#F5D88A" }, part_pay: { bg: "#E8EFFE", text: "#1A3580", dot: "#3D6FE8", border: "#A8BEF5" }, full_pay: { bg: "#FDEAEA", text: "#8A1A1A", dot: "#D93030", border: "#F5AAAA" }, pending: { bg: "#FEF3E6", text: "#7A4200", dot: "#E87830", border: "#F5C8A0" }, // Typography fontDisplay: "'Playfair Display', Georgia, serif", fontBody: "'DM Sans', system-ui, sans-serif", fontMono: "'DM Mono', 'Courier New', monospace", // Shadows shadowSm: "0 1px 4px rgba(28,58,43,0.08)", shadowMd: "0 4px 16px rgba(28,58,43,0.10)", shadowLg: "0 12px 40px rgba(28,58,43,0.14)", }; const PLOT_SIZE_CONFIG = { "180sqm": { label: "180 m²", baseColor: "#E8F0D8", borderColor: "#8AAA58" }, "250sqm": { label: "250 m²", baseColor: "#D8EAF0", borderColor: "#5890AA" }, "500sqm": { label: "500 m²", baseColor: "#EAD8F0", borderColor: "#9058AA" }, "1000sqm": { label: "1,000 m²",baseColor: "#F0E4D8", borderColor: "#AA7058" }, }; const PAYMENT_METHODS = [ { id: "paystack", label: "Paystack (Card / USSD / Transfer)" }, { id: "bank_transfer", label: "Direct Bank Transfer" }, ]; const PAYMENT_PLAN_TYPES = [ { id: "outright", label: "Outright Payment" }, { id: "6months", label: "6-Month Installment" }, { id: "12months", label: "12-Month Installment" }, { id: "24months", label: "24-Month Installment" }, { id: "custom", label: "Custom Plan" }, ]; const NAV_PRIMARY = [ { id: "dashboard", label: "Dashboard", icon: "⬡" }, { id: "estates", label: "My Estates", icon: "🏘" }, { id: "approvals", label: "Approvals", icon: "✅", badge: "approvals" }, { id: "developers", label: "Developers", icon: "👥" }, { id: "sales", label: "Sales Records", icon: "📊" }, { id: "notifications",label: "Notifications", icon: "🔔", badge: "notifications" }, ]; const NAV_SECONDARY = [ { id: "sec_dashboard",label: "Dashboard", icon: "⬡" }, { id: "sec_estate", label: "Estate Map", icon: "🏘" }, { id: "sec_sales", label: "My Sales", icon: "📊" }, { id: "sec_notifications", label: "Alerts", icon: "🔔", badge: "notifications" }, ]; // ============================================================ // SECTION 2: MOCK DATA & STATE SEED // ============================================================ // In production: fetch this from your API on login const MOCK_PRIMARY_DEV = { id: "primary_001", role: "primary", name: "Okafor Holdings Ltd", email: "admin@okaforholdings.com", phone: "+234 803 000 0001", logoInitial: "OH", }; const MOCK_SECONDARY_DEVS = [ { id: "sec_001", role: "secondary", name: "Apex Realty", email: "apex@email.com", phone: "+234 803 111 0001", color: "#C8923A", active: true, passwordHash: "pass123", allocatedPlots: [] }, { id: "sec_002", role: "secondary", name: "Greenfield Homes",email: "green@email.com", phone: "+234 803 111 0002", color: "#2D8A5F", active: true, passwordHash: "pass123", allocatedPlots: [] }, { id: "sec_003", role: "secondary", name: "Lagos Prestige", email: "prestige@email.com", phone: "+234 803 111 0003", color: "#3D6FE8", active: false, passwordHash: "pass123", allocatedPlots: [] }, { id: "sec_004", role: "secondary", name: "Sunrise Plots", email: "sunrise@email.com", phone: "+234 803 111 0004", color: "#C83A6F", active: true, passwordHash: "pass123", allocatedPlots: [] }, ]; // Generate a realistic 12x14 estate grid function generateEstateGrid(estateId) { const plots = []; let plotNum = 1; const layout = [ // Each row: array of cell types. "R"=road, "A"=amenity, sizes=plot type ["R","R","R","R","R","R","R","R","R","R","R","R","R","R"], ["R","180sqm","180sqm","180sqm","180sqm","180sqm","R","180sqm","180sqm","180sqm","180sqm","180sqm","180sqm","R"], ["R","180sqm","180sqm","180sqm","180sqm","180sqm","R","180sqm","180sqm","180sqm","180sqm","180sqm","180sqm","R"], ["R","180sqm","180sqm","180sqm","180sqm","180sqm","R","180sqm","180sqm","180sqm","180sqm","180sqm","180sqm","R"], ["R","R","R","R","R","R","R","R","R","R","R","R","R","R"], ["R","250sqm","250sqm","250sqm","250sqm","250sqm","R","500sqm","500sqm","500sqm","500sqm","A","A","R"], ["R","250sqm","250sqm","250sqm","250sqm","250sqm","R","500sqm","500sqm","500sqm","500sqm","A","A","R"], ["R","250sqm","250sqm","250sqm","250sqm","250sqm","R","500sqm","500sqm","500sqm","500sqm","A","A","R"], ["R","R","R","R","R","R","R","R","R","R","R","R","R","R"], ["R","1000sqm","1000sqm","1000sqm","R","250sqm","250sqm","250sqm","250sqm","R","180sqm","180sqm","180sqm","R"], ["R","1000sqm","1000sqm","1000sqm","R","250sqm","250sqm","250sqm","250sqm","R","180sqm","180sqm","180sqm","R"], ["R","1000sqm","1000sqm","1000sqm","R","250sqm","250sqm","250sqm","250sqm","R","180sqm","180sqm","180sqm","R"], ["R","R","R","R","R","R","R","R","R","R","R","R","R","R"], ]; layout.forEach((row, ri) => { row.forEach((cell, ci) => { if (cell === "R" || cell === "A") return; plots.push({ id: `${estateId}_P${String(plotNum).padStart(3,"0")}`, estateId, plotNumber: plotNum++, row: ri, col: ci, size: cell, status: "available", // available | reserved | part_pay | full_pay assignedDevId: null, // which secondary dev "owns" this plot slot buyerName: null, buyerPhone: null, paymentPlan: null, planStartDate: null, planEndDate: null, totalPrice: cell === "180sqm" ? 8500000 : cell === "250sqm" ? 12000000 : cell === "500sqm" ? 22000000 : 40000000, amountPaid: 0, transactions: [], // Array of payment records }); }); }); return { layout, plots }; } const { plots: seed_plots } = generateEstateGrid("est_001"); const MOCK_ESTATES = [ { id: "est_001", name: "Palm Grove Estate", location: "Lekki Phase 2, Lagos", totalHa: 10, status: "active", layoutImage: null, // URL to uploaded image in production createdAt: "2024-01-15", plots: seed_plots, }, ]; const MOCK_NOTIFICATIONS_SEED = [ { id: "notif_001", type: "payment_due", title: "Payment Plan Expiring Soon", body: "Buyer Chidi Okeke's 12-month plan on Plot P004 expires in 7 days.", plotId: "est_001_P004", read: false, date: new Date().toISOString(), for: ["primary_001", "sec_001"], }, ]; const MOCK_PENDING_APPROVALS_SEED = [ { id: "appr_001", plotId: "est_001_P007", estateId: "est_001", submittedBy: "sec_001", buyerName: "Emeka Nwachukwu", buyerPhone: "+234 803 222 0001", amount: 4250000, method: "bank_transfer", paymentPlan: "6months", planStartDate: "2025-05-01", planEndDate: "2025-11-01", proofNote: "Transfer made to GTBank account. Teller ref: GTB2025042200184", submittedAt: new Date(Date.now() - 3600000).toISOString(), status: "pending", // pending | approved | rejected }, ]; // ============================================================ // SECTION 3: UTILITY FUNCTIONS // ============================================================ /** Format number as Nigerian Naira */ const formatNaira = (n) => "₦" + Number(n || 0).toLocaleString("en-NG"); /** Format ISO date string to readable form */ const formatDate = (iso) => { if (!iso) return "—"; return new Date(iso).toLocaleDateString("en-NG", { day: "numeric", month: "short", year: "numeric", }); }; /** Calculate days remaining from today to a date string */ const daysUntil = (dateStr) => { if (!dateStr) return null; const diff = new Date(dateStr) - new Date(); return Math.ceil(diff / (1000 * 60 * 60 * 24)); }; /** Truncate long strings with ellipsis */ const truncate = (str, len = 24) => str && str.length > len ? str.slice(0, len) + "…" : str; /** Get status config object from THEME */ const getStatusConfig = (status) => ({ available: THEME.available, reserved: THEME.reserved, part_pay: THEME.part_pay, full_pay: THEME.full_pay, pending: THEME.pending, }[status] || THEME.available); /** Compute payment percentage */ const payPct = (plot) => plot.totalPrice > 0 ? Math.min(100, Math.round((plot.amountPaid / plot.totalPrice) * 100)) : 0; /** Get initials from a name */ const initials = (name = "") => name.split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase(); // ============================================================ // SECTION 4: APP CONTEXT (Global State) // ============================================================ // In production: replace setState calls with API mutations + optimistic updates const AppContext = createContext(null); function AppProvider({ children }) { // ── Auth ────────────────────────────────────────────────── const [currentUser, setCurrentUser] = useState(null); // ── Core Data ───────────────────────────────────────────── const [estates, setEstates] = useState(MOCK_ESTATES); const [developers, setDevelopers] = useState(MOCK_SECONDARY_DEVS); const [approvals, setApprovals] = useState(MOCK_PENDING_APPROVALS_SEED); const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS_SEED); // ── Navigation ──────────────────────────────────────────── const [activePage, setActivePage] = useState("dashboard"); const [activeEstateId, setActiveEstateId] = useState("est_001"); // ── UI State ────────────────────────────────────────────── const [selectedPlot, setSelectedPlot] = useState(null); const [toast, setToast] = useState(null); // ─── Helpers: show a toast message ────────────────────── const showToast = useCallback((message, type = "success") => { setToast({ message, type, id: Date.now() }); setTimeout(() => setToast(null), 3500); }, []); // ─── Auth Actions ───────────────────────────────────────── const login = useCallback((email, password) => { // Primary developer login if (email === MOCK_PRIMARY_DEV.email && password === "admin123") { setCurrentUser(MOCK_PRIMARY_DEV); setActivePage("dashboard"); return true; } // Secondary developer login const dev = MOCK_SECONDARY_DEVS.find( d => d.email === email && d.passwordHash === password ); if (dev) { if (!dev.active) return "disabled"; setCurrentUser(dev); setActivePage("sec_dashboard"); return true; } return false; }, []); const logout = useCallback(() => { setCurrentUser(null); setActivePage("dashboard"); setSelectedPlot(null); }, []); // ─── Estate Actions ─────────────────────────────────────── const createEstate = useCallback((estateData) => { const id = `est_${Date.now()}`; const { plots } = generateEstateGrid(id); const newEstate = { id, ...estateData, status: "active", createdAt: new Date().toISOString().split("T")[0], plots, }; setEstates(prev => [...prev, newEstate]); showToast(`Estate "${estateData.name}" created successfully!`); return id; }, [showToast]); const updatePlotPrice = useCallback((estateId, size, newPrice) => { setEstates(prev => prev.map(e => { if (e.id !== estateId) return e; return { ...e, plots: e.plots.map(p => p.size === size ? { ...p, totalPrice: newPrice } : p ), }; })); showToast("Plot prices updated."); }, [showToast]); // ─── Plot Actions ───────────────────────────────────────── /** Primary dev directly updates a plot (e.g. after approving payment) */ const updatePlot = useCallback((estateId, plotId, changes) => { setEstates(prev => prev.map(e => { if (e.id !== estateId) return e; return { ...e, plots: e.plots.map(p => p.id === plotId ? { ...p, ...changes } : p ), }; })); }, []); // ─── Approval Actions ───────────────────────────────────── /** Secondary dev submits a payment for approval */ const submitPaymentForApproval = useCallback((submission) => { const newApproval = { id: `appr_${Date.now()}`, submittedAt: new Date().toISOString(), status: "pending", ...submission, }; setApprovals(prev => [...prev, newApproval]); // Also mark plot as "pending" visually updatePlot(submission.estateId, submission.plotId, { status: "reserved" }); showToast("Payment submitted for approval."); }, [updatePlot, showToast]); /** Primary dev approves a pending payment */ const approvePayment = useCallback((approvalId) => { const appr = approvals.find(a => a.id === approvalId); if (!appr) return; // Find current plot const estate = estates.find(e => e.id === appr.estateId); const plot = estate?.plots.find(p => p.id === appr.plotId); if (!plot) return; const newAmountPaid = (plot.amountPaid || 0) + appr.amount; const newStatus = newAmountPaid >= plot.totalPrice ? "full_pay" : "part_pay"; const transaction = { id: `txn_${Date.now()}`, approvalId: appr.id, amount: appr.amount, method: appr.method, devId: appr.submittedBy, date: new Date().toISOString(), note: appr.proofNote, }; updatePlot(appr.estateId, appr.plotId, { status: newStatus, buyerName: appr.buyerName, buyerPhone: appr.buyerPhone, paymentPlan: appr.paymentPlan, planStartDate: appr.planStartDate, planEndDate: appr.planEndDate, assignedDevId: appr.submittedBy, amountPaid: newAmountPaid, transactions: [...(plot.transactions || []), transaction], }); setApprovals(prev => prev.map(a => a.id === approvalId ? { ...a, status: "approved" } : a) ); showToast("Payment approved ✓"); }, [approvals, estates, updatePlot, showToast]); /** Primary dev rejects a pending payment */ const rejectPayment = useCallback((approvalId, reason) => { setApprovals(prev => prev.map(a => a.id === approvalId ? { ...a, status: "rejected", rejectReason: reason } : a) ); const appr = approvals.find(a => a.id === approvalId); if (appr) updatePlot(appr.estateId, appr.plotId, { status: "available" }); showToast("Payment rejected.", "error"); }, [approvals, updatePlot, showToast]); // ─── Developer Account Actions ──────────────────────────── /** Primary dev creates a new secondary developer account */ const createDeveloper = useCallback((devData) => { const newDev = { id: `sec_${Date.now()}`, role: "secondary", active: true, passwordHash: devData.password, // In production: hash this server-side allocatedPlots: devData.allocatedPlots || [], color: devData.color || "#888888", ...devData, }; setDevelopers(prev => [...prev, newDev]); showToast(`Account created for ${devData.name}`); }, [showToast]); /** Primary dev toggles a secondary dev's active status */ const toggleDeveloperStatus = useCallback((devId) => { setDevelopers(prev => prev.map(d => d.id === devId ? { ...d, active: !d.active } : d) ); }, []); /** Primary dev allocates specific plots to a developer */ const allocatePlots = useCallback((devId, plotIds) => { setDevelopers(prev => prev.map(d => d.id === devId ? { ...d, allocatedPlots: plotIds } : d) ); showToast("Plots allocated successfully."); }, [showToast]); // ─── Notification Actions ───────────────────────────────── const markNotifRead = useCallback((notifId) => { setNotifications(prev => prev.map(n => n.id === notifId ? { ...n, read: true } : n) ); }, []); // ─── Derived Values ─────────────────────────────────────── const activeEstate = estates.find(e => e.id === activeEstateId) || estates[0]; const pendingCount = approvals.filter(a => a.status === "pending").length; const unreadNotifCount = notifications.filter(n => !n.read && (n.for?.includes(currentUser?.id)) ).length; // ─── Context Value ──────────────────────────────────────── const value = { // State currentUser, estates, developers, approvals, notifications, activePage, activeEstate, activeEstateId, selectedPlot, toast, pendingCount, unreadNotifCount, // Setters / Actions setActivePage, setActiveEstateId, setSelectedPlot, login, logout, createEstate, updatePlotPrice, updatePlot, submitPaymentForApproval, approvePayment, rejectPayment, createDeveloper, toggleDeveloperStatus, allocatePlots, markNotifRead, showToast, }; return {children}; } /** Hook for easy context access */ const useApp = () => useContext(AppContext); // ============================================================ // SECTION 5: HOOKS // ============================================================ /** Check for plans expiring within 14 days and surface notifications */ function usePaymentDueChecker() { const { estates, notifications, setNotifications, currentUser } = useApp(); useEffect(() => { if (!currentUser) return; estates.forEach(estate => { estate.plots.forEach(plot => { if (!plot.planEndDate || plot.status === "full_pay") return; const days = daysUntil(plot.planEndDate); if (days !== null && days <= 14 && days >= 0) { const existsAlready = notifications.some(n => n.plotId === plot.id && n.type === "payment_due" ); if (!existsAlready) { setNotifications(prev => [...prev, { id: `notif_auto_${plot.id}`, type: "payment_due", title: "Payment Plan Expiring", body: `${plot.buyerName || "A buyer"}'s plan on Plot ${plot.id} expires in ${days} day(s).`, plotId: plot.id, read: false, date: new Date().toISOString(), for: ["primary_001", plot.assignedDevId].filter(Boolean), }]); } } }); }); }, [estates, currentUser]); } // ============================================================ // SECTION 6: SHARED UI COMPONENTS // ============================================================ // ── Font Injector ───────────────────────────────────────────── function FontLoader() { useEffect(() => { if (document.getElementById("estate-fonts")) return; const link = document.createElement("link"); link.id = "estate-fonts"; link.rel = "stylesheet"; link.href = "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap"; document.head.appendChild(link); }, []); return null; } // ── Toast Notification ──────────────────────────────────────── function Toast() { const { toast } = useApp(); if (!toast) return null; return (
{toast.type === "error" ? "✕" : "✓"} {toast.message}
); } // ── Button ──────────────────────────────────────────────────── function Btn({ children, onClick, variant = "primary", size = "md", disabled, style: extraStyle }) { const variants = { primary: { bg: THEME.primary, color: "#fff", border: "none" }, gold: { bg: THEME.gold, color: "#fff", border: "none" }, outline: { bg: "transparent", color: THEME.primary, border: `1.5px solid ${THEME.primary}` }, ghost: { bg: "transparent", color: THEME.primary, border: "none" }, danger: { bg: "#8A1A1A", color: "#fff", border: "none" }, }; const sizes = { sm: { padding: "6px 12px", fontSize: 12 }, md: { padding: "9px 18px", fontSize: 13 }, lg: { padding: "12px 24px", fontSize: 14 }, }; const v = variants[variant]; const s = sizes[size]; return ( ); } // ── Text Input ──────────────────────────────────────────────── function Input({ label, value, onChange, type = "text", placeholder, required, style: extraStyle }) { return (
{label && ( )} e.target.style.borderColor = THEME.gold} onBlur={e => e.target.style.borderColor = THEME.border} />
); } // ── Select ──────────────────────────────────────────────────── function Select({ label, value, onChange, options, required }) { return (
{label && ( )}
); } // ── Modal ───────────────────────────────────────────────────── function Modal({ title, onClose, children, width = 520 }) { return (
{/* Modal Header */}

{title}

{/* Modal Body */}
{children}
); } // ── Card ────────────────────────────────────────────────────── function Card({ children, style: extraStyle }) { return (
{children}
); } // ── KPI Stat Card ───────────────────────────────────────────── function StatCard({ label, value, sub, color }) { return (
{value}
{label}
{sub && (
{sub}
)}
); } // ── Status Badge ────────────────────────────────────────────── function StatusBadge({ status, label }) { const cfg = getStatusConfig(status); const labels = { available: "Available", reserved: "Reserved", part_pay: "Part Payment", full_pay: "Fully Sold", pending: "Pending", }; return ( {label || labels[status] || status} ); } // ── Developer Avatar Badge ───────────────────────────────────── function DevAvatar({ dev, size = 28 }) { if (!dev) return null; return ( {initials(dev.name)} ); } // ── Payment Progress Bar ────────────────────────────────────── function PayBar({ plot }) { const pct = payPct(plot); const color = pct === 100 ? THEME.full_pay.dot : pct > 0 ? THEME.part_pay.dot : THEME.available.dot; return (
{formatNaira(plot.amountPaid)} {pct}%
); } // ── Section Header ──────────────────────────────────────────── function SectionHeader({ title, subtitle, action }) { return (

{title}

{subtitle && (

{subtitle}

)}
{action}
); } // ── Empty State ─────────────────────────────────────────────── function EmptyState({ icon = "📭", message }) { return (
{icon}
{message}
); } // ============================================================ // SECTION 7: AUTH — LOGIN SCREEN // ============================================================ function LoginScreen() { const { login } = useApp(); const [email, setEmail] = useState(""); const [password,setPassword]= useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const handleLogin = async () => { if (!email || !password) { setError("Please fill in all fields."); return; } setLoading(true); setTimeout(() => { const result = login(email, password); setLoading(false); if (result === "disabled") setError("This account has been disabled. Contact the primary developer."); else if (result === false) setError("Invalid email or password."); }, 600); }; return (
{/* Left — Branding Panel */}
Estate Platform

Okafor
Holdings Ltd

Manage your estates, track plot sales, and coordinate your developer network — all in one place.

{/* Feature bullets */} {[ "Visual estate map with real-time plot status", "Secondary developer account management", "Payment approval workflow", "Automatic payment plan reminders", ].map(f => (
{f}
))}
{/* Right — Login Form */}

Sign In

Primary: admin@okaforholdings.com / admin123
Secondary: apex@email.com / pass123

setEmail(e.target.value)} placeholder="you@example.com" /> setPassword(e.target.value)} placeholder="••••••••" />
{error && (
{error}
)} {loading ? "Signing in…" : "Sign In →"}
); } // ============================================================ // SECTION 8a: PRIMARY DEV — DASHBOARD // ============================================================ function PrimaryDashboard() { const { estates, developers, approvals, pendingCount } = useApp(); // Aggregate across all estates const allPlots = estates.flatMap(e => e.plots); const totalPlots = allPlots.length; const soldFull = allPlots.filter(p => p.status === "full_pay").length; const partPaid = allPlots.filter(p => p.status === "part_pay").length; const available = allPlots.filter(p => p.status === "available").length; const totalRevenue = allPlots.reduce((s, p) => s + p.amountPaid, 0); const totalValue = allPlots.reduce((s, p) => s + p.totalPrice, 0); const activeDev = developers.filter(d => d.active).length; // Recent transactions across all plots const recentTxns = allPlots .flatMap(p => (p.transactions || []).map(t => ({ ...t, plotId: p.id, plotSize: p.size }))) .sort((a, b) => new Date(b.date) - new Date(a.date)) .slice(0, 6); return (
{/* KPI Row */}
{/* Revenue Card */}
{formatNaira(totalRevenue)}
Total revenue collected of {formatNaira(totalValue)} total value
{totalValue > 0 ? Math.round((totalRevenue / totalValue) * 100) : 0}%
{/* Revenue bar */}
0 ? `${(totalRevenue / totalValue) * 100}%` : "0%", transition: "width 0.6s ease", }} />
{/* Recent Transactions */}

Recent Transactions

{recentTxns.length === 0 ? : recentTxns.map(txn => (
{formatNaira(txn.amount)}
{txn.plotId} · {formatDate(txn.date)}
))}
{/* Developer Performance */}

Developer Performance

{developers.map(dev => { const devPlots = allPlots.filter(p => p.assignedDevId === dev.id); const devRevenue = devPlots.reduce((s, p) => s + p.amountPaid, 0); return (
{dev.name}
{devPlots.length} plots
{formatNaira(devRevenue)}
{!dev.active && ( )}
); })}
); } // ============================================================ // SECTION 8b: PRIMARY DEV — ESTATE MANAGER // ============================================================ function EstateManager() { const { estates, createEstate, setActivePage, setActiveEstateId, updatePlotPrice } = useApp(); const [showCreate, setShowCreate] = useState(false); const [showPrices, setShowPrices] = useState(false); const [editEstate, setEditEstate] = useState(null); // New estate form state const [form, setForm] = useState({ name: "", location: "", totalHa: "", layoutImage: null, }); // Price editor state const [prices, setPrices] = useState({ "180sqm": 8500000, "250sqm": 12000000, "500sqm": 22000000, "1000sqm": 40000000, }); const handleCreateEstate = () => { if (!form.name || !form.location) return; const id = createEstate({ ...form, totalHa: Number(form.totalHa) }); setShowCreate(false); setForm({ name: "", location: "", totalHa: "", layoutImage: null }); }; const handleLayoutUpload = (e) => { const file = e.target.files[0]; if (!file) return; const url = URL.createObjectURL(file); setForm(f => ({ ...f, layoutImage: url })); }; const handleSavePrices = () => { Object.entries(prices).forEach(([size, price]) => { updatePlotPrice(editEstate.id, size, Number(price)); }); setShowPrices(false); }; return (
setShowCreate(true)} variant="gold"> + Open New Estate } /> {/* Estate Cards */}
{estates.map(estate => { const plots = estate.plots; const sold = plots.filter(p => p.status === "full_pay").length; const available = plots.filter(p => p.status === "available").length; const revenue = plots.reduce((s, p) => s + p.amountPaid, 0); return ( {/* Gold top bar */}
{/* Layout Image Preview */} {estate.layoutImage && (
layout
)} {!estate.layoutImage && (
🗺 No layout uploaded
)}
{estate.name}
{estate.location}
{[ { l: "Total Plots", v: plots.length }, { l: "Sold", v: sold }, { l: "Available", v: available }, ].map(s => (
{s.v}
{s.l}
))}
Revenue: {formatNaira(revenue)}
{ setActiveEstateId(estate.id); setActivePage("plot_grid"); }}> View Map { setEditEstate(estate); setShowPrices(true); }}> Edit Prices
); })}
{/* ── Modal: Create Estate ── */} {showCreate && ( setShowCreate(false)}>
setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. Palm Grove Estate" required /> setForm(f => ({ ...f, location: e.target.value }))} placeholder="e.g. Lekki Phase 2, Lagos" required /> setForm(f => ({ ...f, totalHa: e.target.value }))} placeholder="e.g. 10" /> {/* Layout Upload */}
setShowCreate(false)}>Cancel Create Estate
)} {/* ── Modal: Edit Plot Prices ── */} {showPrices && editEstate && ( setShowPrices(false)}>

Changing prices here updates all available plots of that size. Plots with active payments are not affected.

{Object.entries(PLOT_SIZE_CONFIG).map(([size, cfg]) => (
{cfg.label} setPrices(p => ({ ...p, [size]: e.target.value }))} placeholder="Price in ₦" style={{ flex: 1 }} /> {formatNaira(prices[size])}
))}
setShowPrices(false)}>Cancel Save Prices
)}
); } // ============================================================ // SECTION 8c: PLOT GRID — Visual Estate Map // Used by both primary and secondary devs // ============================================================ function PlotGrid({ readOnly = false }) { const { activeEstate, developers, currentUser, selectedPlot, setSelectedPlot, submitPaymentForApproval } = useApp(); const [showPayModal, setShowPayModal] = useState(false); if (!activeEstate) return ; const plots = activeEstate.plots; const layout = activeEstate.plots; // we re-derive the grid from stored row/col // For secondary devs: only show allocated plots if allocation is set const myDev = developers.find(d => d.id === currentUser?.id); const myAllocated = myDev?.allocatedPlots || []; const hasAllocation = myAllocated.length > 0; // Row range for grid const maxRow = Math.max(...plots.map(p => p.row)); const maxCol = Math.max(...plots.map(p => p.col)); // Build lookup: [row][col] → plot const plotMap = {}; plots.forEach(p => { if (!plotMap[p.row]) plotMap[p.row] = {}; plotMap[p.row][p.col] = p; }); // Road/amenity rows and cols (hardcoded to match layout generation) const ROAD_ROWS = new Set([0, 4, 8, 12]); const isRoadCol = (ri, ci) => { if (ci === 0 || ci === maxCol) return true; if (ri >= 1 && ri <= 3 && ci === 6) return true; if (ri >= 5 && ri <= 7 && ci === 6) return true; if (ri >= 9 && ri <= 11 && (ci === 4 || ci === 9)) return true; return false; }; const isAmenity = (ri, ci) => ri >= 5 && ri <= 7 && ci >= 11 && ci <= 12; return (
{/* Legend */}
{["available","reserved","part_pay","full_pay"].map(s => { const cfg = getStatusConfig(s); const lbl = { available:"Available", reserved:"Reserved", part_pay:"Part Payment", full_pay:"Fully Sold" }[s]; return (
{lbl}
); })}
Click a plot to {readOnly ? "view details" : "submit payment"}
{/* Scrollable Grid */}
{Array.from({ length: maxRow + 1 }).map((_, ri) => (
{Array.from({ length: maxCol + 1 }).map((_, ci) => { // Road cell if (ROAD_ROWS.has(ri) || isRoadCol(ri, ci)) { return (
); } // Amenity if (isAmenity(ri, ci)) { return (
🌳
); } // Plot cell const plot = plotMap[ri]?.[ci]; if (!plot) return
; const isSelected = selectedPlot?.id === plot.id; const isDimmed = hasAllocation && !myAllocated.includes(plot.id); const dev = developers.find(d => d.id === plot.assignedDevId); const cfg = getStatusConfig(plot.status); const sizeW = plot.size === "1000sqm" ? 70 : plot.size === "500sqm" ? 56 : plot.size === "250sqm" ? 50 : 42; const sizeH = plot.size === "1000sqm" ? 62 : plot.size === "500sqm" ? 50 : plot.size === "250sqm" ? 44 : 36; return (
{ setSelectedPlot(plot); if (!readOnly && currentUser?.role === "secondary") setShowPayModal(true); }} title={`${plot.id} · ${PLOT_SIZE_CONFIG[plot.size]?.label} · ${formatNaira(plot.totalPrice)}`} style={{ width: sizeW, height: sizeH, flexShrink: 0, background: isSelected ? THEME.gold : cfg.bg, border: `1.5px solid ${isSelected ? THEME.gold : cfg.border}`, borderRadius: 3, cursor: "pointer", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 2, opacity: isDimmed ? 0.35 : 1, boxShadow: isSelected ? `0 0 0 3px ${THEME.gold}50, ${THEME.shadowSm}` : THEME.shadowSm, transform: isSelected ? "scale(1.06)" : "scale(1)", transition: "all 0.15s ease", zIndex: isSelected ? 5 : 1, position: "relative", }} > {plot.plotNumber} {dev && } {plot.status !== "available" && ( )}
); })}
))}
{/* ── Detail Side Panel ── */} {selectedPlot && ( { setSelectedPlot(null); setShowPayModal(false); }} onSubmitPayment={() => setShowPayModal(true)} /> )} {/* ── Submit Payment Modal (secondary dev) ── */} {showPayModal && selectedPlot && currentUser?.role === "secondary" && ( setShowPayModal(false)} onSubmit={(data) => { submitPaymentForApproval({ plotId: selectedPlot.id, estateId: selectedPlot.estateId, submittedBy: currentUser.id, ...data, }); setShowPayModal(false); setSelectedPlot(null); }} /> )}
); } // ── Plot Detail Side Panel ──────────────────────────────────── function PlotDetailPanel({ plot, onClose, readOnly, onSubmitPayment }) { const { developers, currentUser } = useApp(); const dev = developers.find(d => d.id === plot.assignedDevId); const pct = payPct(plot); return (
{/* Header */}
Plot {plot.plotNumber}
{PLOT_SIZE_CONFIG[plot.size]?.label} · {plot.id}
{/* Body */}
{/* Price & Payment */}
Total Price
{formatNaira(plot.totalPrice)}
Balance
{formatNaira(plot.totalPrice - plot.amountPaid)}
{/* Buyer Info */} {plot.buyerName && (
Buyer
{plot.buyerName}
{plot.buyerPhone &&
{plot.buyerPhone}
}
)} {/* Developer */} {dev && (
Assigned Developer
{dev.name}
)} {/* Payment Plan */} {plot.paymentPlan && (
Payment Plan
{PAYMENT_PLAN_TYPES.find(p => p.id === plot.paymentPlan)?.label || plot.paymentPlan}
{plot.planEndDate && (
Ends: {formatDate(plot.planEndDate)} {daysUntil(plot.planEndDate) !== null && daysUntil(plot.planEndDate) >= 0 && ( ({daysUntil(plot.planEndDate)} days left) )}
)}
)} {/* Transaction History */} {plot.transactions?.length > 0 && (
Payment History
{plot.transactions.map(txn => (
{formatNaira(txn.amount)}
{formatDate(txn.date)} · {txn.method}
))}
)}
{/* Footer CTA — secondary dev only */} {!readOnly && currentUser?.role === "secondary" && plot.status !== "full_pay" && (
Submit Payment
)}
); } // ============================================================ // SECTION 9d: SUBMIT PAYMENT MODAL // Used by secondary devs to submit payment for approval // ============================================================ function SubmitPaymentModal({ plot, onClose, onSubmit }) { const [form, setForm] = useState({ buyerName: "", buyerPhone: "", amount: "", method: "bank_transfer", paymentPlan: "outright", planStartDate: new Date().toISOString().split("T")[0], planEndDate: "", proofNote: "", }); const balance = plot.totalPrice - plot.amountPaid; const handlePay = () => { if (!form.buyerName || !form.amount) return; if (form.method === "paystack") { // In production: initialize Paystack popup here, then call onSubmit in the callback alert("Paystack integration: In production, the Paystack popup opens here. Proceeding as submitted."); } onSubmit({ ...form, amount: Number(form.amount), }); }; return (
{/* Plot summary */}
Balance to pay {formatNaira(balance)}
setForm(f => ({ ...f, buyerName: e.target.value }))} placeholder="e.g. Chidi Okonkwo" /> setForm(f => ({ ...f, buyerPhone: e.target.value }))} placeholder="+234 803 000 0000" /> setForm(f => ({ ...f, amount: e.target.value }))} placeholder="e.g. 4250000" /> setForm(f => ({ ...f, paymentPlan: e.target.value }))} options={PAYMENT_PLAN_TYPES.map(p => ({ value: p.id, label: p.label }))} /> {form.paymentPlan !== "outright" && (
setForm(f => ({ ...f, planStartDate: e.target.value }))} /> setForm(f => ({ ...f, planEndDate: e.target.value }))} />
)}