import { useState, useEffect } from "react"; // ── Palette & design tokens ────────────────────────────────────────────────── // Deep navy (#0F1B2D) ground, warm off-white (#F5F0E8) surface, // gold accent (#C9A84C) for financial highlights, slate (#6B7A8D) for muted. // Display: "Georgia" serif. Body/data: system-ui. Mono for $ figures. const COLORS = { navy: "#0F1B2D", navyMid: "#1A2E47", gold: "#C9A84C", goldLight: "#F0DFA0", cream: "#F5F0E8", white: "#FFFFFF", slate: "#6B7A8D", slateLight: "#E8ECF0", red: "#C0392B", green: "#1A7A4A", greenLight: "#E8F5EE", redLight: "#FDECEA", }; const css = ` @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap'); *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: system-ui, -apple-system, sans-serif; background: ${COLORS.navy}; color: ${COLORS.navyMid}; min-height: 100vh; } .app { display: flex; min-height: 100vh; } /* Sidebar */ .sidebar { width: 220px; min-width: 220px; background: ${COLORS.navy}; display: flex; flex-direction: column; padding: 0 0 24px 0; border-right: 1px solid rgba(201,168,76,0.15); position: sticky; top: 0; height: 100vh; } .sidebar-logo { padding: 28px 20px 20px; border-bottom: 1px solid rgba(201,168,76,0.2); margin-bottom: 12px; } .sidebar-logo h1 { font-family: 'Playfair Display', Georgia, serif; color: ${COLORS.gold}; font-size: 1.2rem; line-height: 1.3; letter-spacing: 0.01em; } .sidebar-logo span { color: ${COLORS.slate}; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; display: block; margin-top: 2px; } .nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; font-size: 0.88rem; color: rgba(245,240,232,0.6); border-left: 3px solid transparent; transition: all 0.15s; user-select: none; } .nav-item:hover { color: ${COLORS.cream}; background: rgba(255,255,255,0.04); } .nav-item.active { color: ${COLORS.gold}; border-left-color: ${COLORS.gold}; background: rgba(201,168,76,0.08); font-weight: 500; } .nav-icon { font-size: 1rem; width: 18px; text-align: center; } /* Main */ .main { flex: 1; background: ${COLORS.cream}; overflow-y: auto; } .page-header { background: ${COLORS.white}; padding: 24px 32px 20px; border-bottom: 1px solid ${COLORS.slateLight}; display: flex; align-items: center; justify-content: space-between; } .page-title { font-family: 'Playfair Display', Georgia, serif; font-size: 1.5rem; color: ${COLORS.navy}; } .page-sub { font-size: 0.8rem; color: ${COLORS.slate}; margin-top: 2px; } .page-content { padding: 28px 32px; } /* Cards */ .card { background: ${COLORS.white}; border-radius: 8px; padding: 24px; border: 1px solid ${COLORS.slateLight}; } .card-title { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em; color: ${COLORS.slate}; margin-bottom: 8px; } /* Stat grid */ .stat-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 16px; margin-bottom: 28px; } .stat-card { background: ${COLORS.white}; border-radius: 8px; padding: 20px 24px; border: 1px solid ${COLORS.slateLight}; border-top: 3px solid transparent; } .stat-card.gold { border-top-color: ${COLORS.gold}; } .stat-card.green { border-top-color: ${COLORS.green}; } .stat-card.red { border-top-color: ${COLORS.red}; } .stat-card.navy { border-top-color: ${COLORS.navyMid}; } .stat-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: ${COLORS.slate}; } .stat-value { font-size: 1.7rem; font-weight: 700; color: ${COLORS.navy}; margin: 6px 0 2px; font-variant-numeric: tabular-nums; } .stat-sub { font-size: 0.75rem; color: ${COLORS.slate}; } /* Tables */ .table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 0.86rem; } thead th { text-align: left; padding: 10px 14px; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: ${COLORS.slate}; background: ${COLORS.cream}; border-bottom: 1px solid ${COLORS.slateLight}; font-weight: 600; } tbody tr { border-bottom: 1px solid ${COLORS.slateLight}; transition: background 0.1s; } tbody tr:hover { background: #fafaf8; } tbody td { padding: 12px 14px; color: ${COLORS.navyMid}; } .mono { font-variant-numeric: tabular-nums; font-family: 'Courier New', monospace; } .income { color: ${COLORS.green}; font-weight: 600; } .expense { color: ${COLORS.red}; font-weight: 600; } /* Badge */ .badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em; } .badge-green { background: ${COLORS.greenLight}; color: ${COLORS.green}; } .badge-red { background: ${COLORS.redLight}; color: ${COLORS.red}; } .badge-gold { background: #FEF8E6; color: #9A6F1A; } .badge-slate { background: ${COLORS.slateLight}; color: ${COLORS.slate}; } /* Buttons */ .btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 18px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; font-weight: 500; transition: all 0.15s; } .btn-primary { background: ${COLORS.gold}; color: ${COLORS.navy}; } .btn-primary:hover { background: #b8942e; } .btn-outline { background: transparent; border: 1px solid ${COLORS.slateLight}; color: ${COLORS.navyMid}; } .btn-outline:hover { border-color: ${COLORS.slate}; } .btn-danger { background: ${COLORS.red}; color: white; } .btn-sm { padding: 5px 11px; font-size: 0.78rem; } .btn-delete { background: none; border: none; cursor: pointer; color: ${COLORS.slate}; font-size: 1.1rem; line-height: 1; padding: 2px 6px; border-radius: 4px; opacity: 0.5; transition: opacity 0.15s, color 0.15s; } .btn-delete:hover { opacity: 1; color: ${COLORS.red}; } /* Form */ .form-row { display: grid; gap: 16px; margin-bottom: 16px; } .form-row.cols-2 { grid-template-columns: 1fr 1fr; } .form-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; } .form-group { display: flex; flex-direction: column; gap: 5px; } label { font-size: 0.78rem; font-weight: 600; color: ${COLORS.navy}; letter-spacing: 0.02em; } input, select, textarea { padding: 9px 12px; border: 1px solid ${COLORS.slateLight}; border-radius: 6px; font-size: 0.875rem; color: ${COLORS.navyMid}; background: ${COLORS.white}; outline: none; transition: border-color 0.15s; font-family: inherit; } input:focus, select:focus, textarea:focus { border-color: ${COLORS.gold}; box-shadow: 0 0 0 3px rgba(201,168,76,0.12); } textarea { resize: vertical; min-height: 80px; } /* Modal */ .modal-overlay { position: fixed; inset: 0; background: rgba(15,27,45,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; backdrop-filter: blur(2px); } .modal { background: ${COLORS.white}; border-radius: 12px; width: 100%; max-width: 600px; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 60px rgba(0,0,0,0.25); } .modal-header { padding: 22px 28px 18px; border-bottom: 1px solid ${COLORS.slateLight}; display: flex; justify-content: space-between; align-items: center; } .modal-header h2 { font-family: 'Playfair Display', Georgia, serif; font-size: 1.15rem; color: ${COLORS.navy}; } .modal-body { padding: 24px 28px; } .modal-footer { padding: 16px 28px 22px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid ${COLORS.slateLight}; } .close-btn { background: none; border: none; cursor: pointer; font-size: 1.3rem; color: ${COLORS.slate}; line-height: 1; padding: 2px; } .close-btn:hover { color: ${COLORS.navy}; } /* Invoice preview */ .invoice-preview { background: ${COLORS.white}; border: 1px solid ${COLORS.slateLight}; border-radius: 8px; padding: 32px; font-size: 0.875rem; } .invoice-top { display: flex; justify-content: space-between; margin-bottom: 28px; } .invoice-from h3 { font-family: 'Playfair Display', Georgia, serif; color: ${COLORS.navy}; font-size: 1.1rem; margin-bottom: 4px; } .invoice-from p, .invoice-to p { color: ${COLORS.slate}; font-size: 0.8rem; line-height: 1.7; } .invoice-meta { text-align: right; } .invoice-num { font-size: 1.5rem; font-weight: 700; color: ${COLORS.gold}; font-family: 'Playfair Display', Georgia, serif; } .invoice-lines { width: 100%; border-collapse: collapse; margin: 20px 0; } .invoice-lines th { background: ${COLORS.navy}; color: ${COLORS.gold}; padding: 8px 12px; text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; } .invoice-lines td { padding: 10px 12px; border-bottom: 1px solid ${COLORS.slateLight}; } .invoice-total { text-align: right; } .invoice-total .total-line { display: flex; justify-content: flex-end; gap: 40px; margin-bottom: 6px; font-size: 0.85rem; color: ${COLORS.slate}; } .invoice-total .grand { color: ${COLORS.navy}; font-weight: 700; font-size: 1.1rem; } /* Lease card */ .lease-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px,1fr)); gap: 16px; } .lease-card { background: ${COLORS.white}; border-radius: 8px; border: 1px solid ${COLORS.slateLight}; border-top: 4px solid ${COLORS.navy}; padding: 20px; } .lease-card.expiring { border-top-color: ${COLORS.gold}; } .lease-card.expired { border-top-color: ${COLORS.red}; } .lease-card h3 { font-size: 1rem; font-weight: 600; color: ${COLORS.navy}; margin-bottom: 4px; } .lease-detail { font-size: 0.8rem; color: ${COLORS.slate}; line-height: 1.9; } .lease-detail strong { color: ${COLORS.navyMid}; font-weight: 600; } /* Divider */ .divider { height: 1px; background: ${COLORS.slateLight}; margin: 24px 0; } /* Empty */ .empty { text-align: center; padding: 48px 24px; color: ${COLORS.slate}; } .empty-icon { font-size: 2.5rem; margin-bottom: 12px; } .empty p { font-size: 0.875rem; } /* Tabs */ .tabs { display: flex; gap: 4px; margin-bottom: 20px; background: ${COLORS.slateLight}; border-radius: 8px; padding: 4px; width: fit-content; } .tab { padding: 7px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.82rem; font-weight: 500; background: none; color: ${COLORS.slate}; transition: all 0.15s; } .tab.active { background: ${COLORS.white}; color: ${COLORS.navy}; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } @media (max-width: 900px) { .stat-grid { grid-template-columns: 1fr 1fr; } .sidebar { width: 60px; min-width: 60px; } .sidebar-logo, .nav-item span { display: none; } .nav-icon { margin: 0 auto; } .nav-item { justify-content: center; padding: 14px 0; } } `; // ── Helpers ────────────────────────────────────────────────────────────────── const fmt = (n) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 0 }).format(n); const today = () => new Date().toISOString().split("T")[0]; const daysUntil = (d) => Math.ceil((new Date(d) - new Date()) / 86400000); const leaseStatus = (lease) => { const d = daysUntil(lease.end); if (d < 0) return "expired"; if (d <= 60) return "expiring"; return "active"; }; const nextInvoiceNum = (invoices) => { const max = invoices.reduce((m, i) => Math.max(m, parseInt(i.num.split("-")[1])), 0); return `INV-${String(max + 1).padStart(3, "0")}`; }; // ── Components ─────────────────────────────────────────────────────────────── function Modal({ title, onClose, onSave, children, wide }) { return (
e.target === e.currentTarget && onClose()}>

{title}

{children}
{onSave && (
)}
); } // ── Dashboard ──────────────────────────────────────────────────────────────── function Dashboard({ transactions, invoices, leases, tenants }) { const income = transactions.filter(t => t.type === "income").reduce((s, t) => s + t.amount, 0); const expenses = transactions.filter(t => t.type === "expense").reduce((s, t) => s + t.amount, 0); const net = income - expenses; const pending = invoices.filter(i => i.status === "pending").reduce((s, i) => s + i.items.reduce((a, b) => a + b.rate * b.qty, 0), 0); const expiringLeases = leases.filter(l => leaseStatus(l) === "expiring" || leaseStatus(l) === "expired"); const categories = {}; transactions.filter(t => t.type === "expense").forEach(t => { categories[t.category] = (categories[t.category] || 0) + t.amount; }); return (
Total Income
{fmt(income)}
this period
Total Expenses
{fmt(expenses)}
this period
Net Operating Income
= 0 ? COLORS.green : COLORS.red }}>{fmt(net)}
income − expenses
Pending Invoices
{fmt(pending)}
{invoices.filter(i => i.status === "pending").length} invoice(s) outstanding
Recent Transactions
{transactions.slice(-5).reverse().map(t => ( ))}
DateDescriptionAmount
{t.date} {t.description} {t.type === "income" ? "+" : "−"}{fmt(t.amount)}
Expense Breakdown
{Object.entries(categories).map(([cat, amt]) => (
{cat} {fmt(amt)}
))} {expiringLeases.length > 0 && ( <>
Lease Alerts
{expiringLeases.map(l => { const t = tenants.find(t => String(t.id) === String(l.tenantId)); const d = daysUntil(l.end); return (
{t?.name} — {l.unit} {d < 0 ? `Expired ${Math.abs(d)}d ago` : `Expires in ${d}d`}
); })} )}
); } // ── Transactions ───────────────────────────────────────────────────────────── function Transactions({ transactions, onAddTransaction, onDeleteTransaction, tenants }) { const [showModal, setShowModal] = useState(false); const [tab, setTab] = useState("all"); const [txPage, setTxPage] = useState(1); const [form, setForm] = useState({ date: today(), type: "income", category: "Rent", description: "", amount: "", tenantId: "" }); const visible = tab === "all" ? transactions : transactions.filter(t => t.type === tab); const sorted = [...visible].sort((a, b) => b.date.localeCompare(a.date)); const PAGE_SIZE = 20; const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); const paginated = sorted.slice((txPage - 1) * PAGE_SIZE, txPage * PAGE_SIZE); const save = () => { if (!form.description || !form.amount) return; onAddTransaction({ ...form, amount: parseFloat(form.amount), tenantId: form.tenantId ? parseInt(form.tenantId) : null }); setShowModal(false); setForm({ date: today(), type: "income", category: "Rent", description: "", amount: "", tenantId: "" }); }; const incCats = ["Rent", "Late Fee", "Security Deposit", "Parking", "Pet Fee", "Other Income"]; const expCats = ["Repairs", "Maintenance", "Insurance", "Mortgage", "Taxes", "Utilities", "Management", "Landscaping", "Other Expense"]; return (
{["all", "income", "expense"].map(t => ( ))}
Transaction Ledger
{visible.length === 0 && ( )} {paginated.map(t => { const tenant = tenants.find(tn => String(tn.id) === String(t.tenantId)); return ( ); })}
DateTypeCategoryDescriptionTenantAmount
📋

No transactions yet

{t.date} {t.type} {t.category} {t.description} {tenant?.name || "—"} {t.type === "income" ? "+" : "−"}{fmt(t.amount)}
{totalPages > 1 && (
{sorted.length} transactions · page {txPage} of {totalPages}
{Array.from({ length: totalPages }, (_, i) => i + 1).map(n => ( ))}
)}
{showModal && ( setShowModal(false)} onSave={save}>
setForm(p => ({ ...p, date: e.target.value }))} />
setForm(p => ({ ...p, description: e.target.value }))} />
setForm(p => ({ ...p, amount: e.target.value }))} />
)}
); } // ── Invoices ───────────────────────────────────────────────────────────────── function InvoiceLineItem({ item, idx, onChange, onRemove }) { return (
onChange(idx, "desc", e.target.value)} /> onChange(idx, "qty", parseFloat(e.target.value) || 1)} style={{ textAlign: "center" }} /> onChange(idx, "rate", parseFloat(e.target.value) || 0)} />
); } function InvoicePreview({ inv, tenants, fromName }) { const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); const subtotal = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); return (

{fromName || "Kenji Morishige"}

Tax ID: 99-4686386
kenji@kenjim.com
408.813.3323

{inv.num || "INV-XXX"}

Issue date: {inv.date}
Due date: {inv.due}

Bill To
{tenant?.name || "—"}

{tenant?.unit}
{tenant?.email}

{inv.items.map((item, i) => ( ))}
DescriptionQtyRateTotal
{item.desc} {item.qty} {fmt(item.rate)} {fmt(item.qty * item.rate)}
Subtotal{fmt(subtotal)}
Total Due{fmt(subtotal)}
Please pay by {inv.due}. Questions? Contact kenji@kenjim.com or 408.813.3323.
); } function Invoices({ invoices, onAddInvoice, onUpdateInvoice, onDeleteInvoice, tenants, leases }) { const [showNew, setShowNew] = useState(false); const [viewing, setViewing] = useState(null); const [form, setForm] = useState({ tenantId: "", date: today(), due: "", items: [{ desc: "", qty: 1, rate: 0 }], status: "pending" }); const updateItem = (idx, key, val) => setForm(p => { const items = [...p.items]; items[idx] = { ...items[idx], [key]: val }; return { ...p, items }; }); const addItem = () => setForm(p => ({ ...p, items: [...p.items, { desc: "", qty: 1, rate: 0 }] })); const removeItem = (idx) => setForm(p => ({ ...p, items: p.items.filter((_, i) => i !== idx) })); const save = () => { if (!form.tenantId || form.items.length === 0) return; const newInv = { ...form, num: nextInvoiceNum(invoices), tenantId: parseInt(form.tenantId) }; onAddInvoice(newInv); setShowNew(false); setForm({ tenantId: "", date: today(), due: "", items: [{ desc: "", qty: 1, rate: 0 }], status: "pending" }); }; const markPaid = (id) => onUpdateInvoice(id, { status: "paid" }); const generateMonthly = () => { const activeLease = leases.find(l => leaseStatus(l) === "active" || leaseStatus(l) === "expiring"); if (!activeLease) { alert("No active lease found."); return; } const tenant = tenants.find(t => String(t.id) === String(activeLease.tenantId)); if (!tenant) { alert("Lease tenant not found."); return; } // Find the latest invoice month to determine next month const tenantInvoices = invoices.filter(i => String(i.tenantId) === String(activeLease.tenantId)); let nextMonth; if (tenantInvoices.length > 0) { const lastDate = tenantInvoices.map(i => i.date).sort().reverse()[0]; const d = new Date(lastDate + "T00:00:00"); d.setMonth(d.getMonth() + 1); nextMonth = d; } else { nextMonth = new Date(); nextMonth.setDate(1); } const monthName = nextMonth.toLocaleString("en-US", { month: "long", year: "numeric" }); const issueDate = nextMonth.toISOString().split("T")[0]; const dueDate = new Date(nextMonth); dueDate.setDate(5); const dueDateStr = dueDate.toISOString().split("T")[0]; const newInv = { num: nextInvoiceNum(invoices), tenantId: activeLease.tenantId, date: issueDate, due: dueDateStr, status: "pending", items: [{ desc: `Monthly Rent — ${monthName}`, qty: 1, rate: activeLease.rent }], }; onAddInvoice(newInv); }; const sendInvoiceEmail = (inv) => { const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); if (!tenant?.email) { alert("No email address on file for this tenant."); return; } const total = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); const lineItems = inv.items.map(i => ` ${i.desc.padEnd(40)} ${fmt(i.qty * i.rate).padStart(10)}` ).join("\n"); const subject = encodeURIComponent(`Invoice ${inv.num} — Due ${inv.due}`); const body = encodeURIComponent( `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ INVOICE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ From: Kenji Morishige Tax ID: 99-4686386 kenji@kenjim.com | 408.813.3323 To: ${tenant.name} ${tenant.unit} ${tenant.email} ────────────────────────────────────────── Invoice #: ${inv.num} Issue Date: ${inv.date} Due Date: ${inv.due} ────────────────────────────────────────── CHARGES ${lineItems} ────────────────────────────────────────── TOTAL DUE ${fmt(total).padStart(10)} ────────────────────────────────────────── Please remit payment by ${inv.due}. Questions? Reply to this email or call 408.813.3323. Thank you, Kenji Morishige` ); window.open(`mailto:${tenant.email}?cc=kenji@kenjim.com&subject=${subject}&body=${body}`); }; const sendPaymentEmail = (inv) => { const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); if (!tenant?.email) { alert("No email address on file for this tenant."); return; } const total = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); const subject = encodeURIComponent(`Payment Received — ${inv.num}`); const body = encodeURIComponent( `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PAYMENT CONFIRMATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Hi ${tenant.name}, This is to confirm that your payment has been received and applied to the following invoice. ────────────────────────────────────────── Invoice #: ${inv.num} Property: ${tenant.unit} Amount Paid: ${fmt(total)} Date Paid: ${today()} Status: PAID IN FULL ✓ ────────────────────────────────────────── Your account is up to date for this invoice. Thank you for your payment! If you have any questions, please contact: kenji@kenjim.com | 408.813.3323 Thank you, Kenji Morishige` ); window.open(`mailto:${tenant.email}?cc=kenji@kenjim.com&subject=${subject}&body=${body}`); }; return (
{invoices.length === 0 && ( )} {[...invoices].reverse().map(inv => { const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); const total = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); return ( ); })}
Invoice #TenantIssue DateDue DateAmountStatusActions
🧾

No invoices yet

{inv.num} {tenant?.name} {inv.date} {inv.due} {fmt(total)} {inv.status} {inv.status === "pending" && ( )}
{showNew && ( setShowNew(false)} onSave={save} wide>
setForm(p => ({ ...p, date: e.target.value }))} />
setForm(p => ({ ...p, due: e.target.value }))} />
Description Qty Rate ($)
{form.items.map((item, idx) => ( ))}
Preview
)} {viewing && ( setViewing(null)}> )}
); } // ── Leases ─────────────────────────────────────────────────────────────────── function Leases({ leases, onAddLease, onDeleteLease, tenants }) { const [showModal, setShowModal] = useState(false); const [form, setForm] = useState({ tenantId: "", unit: "", rent: "", deposit: "", start: today(), end: "" }); const save = () => { if (!form.tenantId || !form.unit || !form.rent) return; onAddLease({ ...form, tenantId: parseInt(form.tenantId), rent: parseFloat(form.rent), deposit: parseFloat(form.deposit || 0), status: "active" }); setShowModal(false); setForm({ tenantId: "", unit: "", rent: "", deposit: "", start: today(), end: "" }); }; return (
{leases.length === 0 && (
📄

No leases yet

)} {leases.map(lease => { const tenant = tenants.find(t => String(t.id) === String(lease.tenantId)); const st = leaseStatus(lease); const d = daysUntil(lease.end); return (

{tenant?.name || "Unknown Tenant"}

{lease.unit}
{st === "active" ? "Active" : st === "expiring" ? `Expires in ${d}d` : "Expired"}
Monthly Rent: {fmt(lease.rent)}
Security Deposit: {fmt(lease.deposit)}
Lease Start: {lease.start}
Lease End: {lease.end}
{tenant && <>
Email: {tenant.email}
Phone: {tenant.phone}
}
); })}
{showModal && ( setShowModal(false)} onSave={save}>
setForm(p => ({ ...p, unit: e.target.value }))} />
setForm(p => ({ ...p, rent: e.target.value }))} />
setForm(p => ({ ...p, deposit: e.target.value }))} />
setForm(p => ({ ...p, start: e.target.value }))} />
setForm(p => ({ ...p, end: e.target.value }))} />
)}
); } // ── Tenants ────────────────────────────────────────────────────────────────── function EditableCell({ value, onSave, type = "text" }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); const commit = () => { setEditing(false); if (draft !== value) onSave(draft); }; if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={e => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setDraft(value); setEditing(false); } }} style={{ width: "100%", padding: "3px 6px", fontSize: "inherit", border: `1px solid ${COLORS.gold}`, borderRadius: 4, outline: "none" }} /> ); } return ( { setDraft(value); setEditing(true); }} title="Click to edit" style={{ cursor: "text", color: COLORS.slate, borderBottom: `1px dashed ${COLORS.slateLight}`, paddingBottom: 1 }} > {value || } ); } function Tenants({ tenants, onAddTenant, onUpdateTenant, onDeleteTenant }) { const [showModal, setShowModal] = useState(false); const [form, setForm] = useState({ name: "", unit: "", email: "", phone: "" }); const save = () => { if (!form.name || !form.unit) return; onAddTenant(form); setShowModal(false); setForm({ name: "", unit: "", email: "", phone: "" }); }; return (
{tenants.length === 0 && ( )} {tenants.map(t => ( ))}
NameUnitEmailPhone
👤

No tenants yet

{t.name} {t.unit} onUpdateTenant(t.id, { email: val })} /> onUpdateTenant(t.id, { phone: val })} />
{showModal && ( setShowModal(false)} onSave={save}>
setForm(p => ({ ...p, name: e.target.value }))} />
setForm(p => ({ ...p, unit: e.target.value }))} />
setForm(p => ({ ...p, email: e.target.value }))} />
setForm(p => ({ ...p, phone: e.target.value }))} />
)}
); } // ── Property P&L ───────────────────────────────────────────────────────────── function PropertyView({ property, transactions, leases, invoices, tenants }) { const [yearFilter, setYearFilter] = useState("all"); const propTx = transactions.filter(t => String(t.propertyId) === String(property.id)); const years = [...new Set(propTx.map(t => t.date.slice(0, 4)))].sort().reverse(); const filtered = yearFilter === "all" ? propTx : propTx.filter(t => t.date.startsWith(yearFilter)); const income = filtered.filter(t => t.type === "income").reduce((s, t) => s + t.amount, 0); const expenses = filtered.filter(t => t.type === "expense").reduce((s, t) => s + t.amount, 0); const noi = income - expenses; // Expenses by category const byCategory = filtered .filter(t => t.type === "expense") .reduce((acc, t) => { acc[t.category] = (acc[t.category] || 0) + t.amount; return acc; }, {}); const catEntries = Object.entries(byCategory).sort((a, b) => b[1] - a[1]); const maxCat = catEntries[0]?.[1] || 1; const activeLease = leases.find(l => String(l.propertyId) === String(property.id) && (leaseStatus(l) === "active" || leaseStatus(l) === "expiring")); const tenant = activeLease ? tenants.find(t => String(t.id) === String(activeLease.tenantId)) : null; const pendingInvoices = invoices.filter(i => String(i.propertyId) === String(property.id) && i.status === "pending"); const pendingTotal = pendingInvoices.reduce((s, i) => s + i.items.reduce((ss, li) => ss + li.qty * li.rate, 0), 0); return (
{/* Property Card */}
{property.address}
{property.city}, {property.state} {property.zip}  ·  {property.type}  ·  Since {property.purchaseYear}
{tenant ? ( <>
Current Tenant
{tenant.name}
{fmt(activeLease.rent)}/mo · Ends {activeLease.end}
) : Vacant}
{pendingTotal > 0 && (
Outstanding balance: {fmt(pendingTotal)} across {pendingInvoices.length} pending invoice{pendingInvoices.length !== 1 ? "s" : ""}
)}
{/* Year filter + P&L stats */}
{years.map(y => )}
Total Income
{fmt(income)}
Total Expenses
{fmt(expenses)}
Net Operating Income
= 0 ? COLORS.green : COLORS.red }}>{fmt(noi)}
Transactions
{filtered.length}
{/* Expense breakdown */} {catEntries.length > 0 && (
Expense Breakdown
{catEntries.map(([cat, amt]) => (
{cat}{fmt(amt)}
))}
)} {/* Transaction list */}
Transaction History
{filtered.length === 0 && } {[...filtered].sort((a, b) => b.date.localeCompare(a.date)).map(t => ( ))}
DateTypeCategoryDescriptionAmount

No transactions for this period

{t.date} {t.type} {t.category} {t.description} {t.type === "income" ? "+" : "−"}{fmt(t.amount)}
); } // ── App Shell ──────────────────────────────────────────────────────────────── const NAV = [ { id: "dashboard", icon: "📊", label: "Dashboard" }, { id: "properties", icon: "🏠", label: "Properties" }, { id: "transactions", icon: "💳", label: "Transactions" }, { id: "invoices", icon: "🧾", label: "Invoices" }, { id: "leases", icon: "📄", label: "Leases" }, { id: "tenants", icon: "👤", label: "Tenants" }, ]; const api = { getAll: (resource) => fetch(`/${resource}`).then(r => r.json()), post: (resource, body) => fetch(`/${resource}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(r => r.json()), patch: (resource, id, body) => fetch(`/${resource}/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(r => r.json()), delete: (resource, id) => fetch(`/${resource}/${id}`, { method: "DELETE" }), }; export default function App() { const [page, setPage] = useState("dashboard"); const [properties, setProperties] = useState([]); const [tenants, setTenants] = useState([]); const [leases, setLeases] = useState([]); const [transactions, setTransactions] = useState([]); const [invoices, setInvoices] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { Promise.all([ api.getAll("properties"), api.getAll("tenants"), api.getAll("leases"), api.getAll("transactions"), api.getAll("invoices"), ]).then(([props, t, l, tr, inv]) => { setProperties(props); setTenants(t); setLeases(l); setTransactions(tr); setInvoices(inv); setLoading(false); }); }, []); const onAddTenant = (data) => api.post("tenants", data).then(t => setTenants(prev => [...prev, t])); const onAddLease = (data) => api.post("leases", data).then(l => setLeases(prev => [...prev, l])); const onAddTransaction = (data) => api.post("transactions", data).then(t => setTransactions(prev => [...prev, t])); const onAddInvoice = (data) => api.post("invoices", data).then(inv => setInvoices(prev => [...prev, inv])); const onUpdateInvoice = (id, changes) => api.patch("invoices", id, changes).then(updated => setInvoices(prev => prev.map(i => i.id === id ? updated : i))); const onUpdateTenant = (id, changes) => api.patch("tenants", id, changes).then(updated => setTenants(prev => prev.map(t => t.id === id ? updated : t))); const onDeleteTenant = (id) => api.delete("tenants", id).then(() => setTenants(prev => prev.filter(t => t.id !== id))); const onDeleteLease = (id) => api.delete("leases", id).then(() => setLeases(prev => prev.filter(l => l.id !== id))); const onDeleteTransaction = (id) => api.delete("transactions", id).then(() => setTransactions(prev => prev.filter(t => t.id !== id))); const onDeleteInvoice = (id) => api.delete("invoices", id).then(() => setInvoices(prev => prev.filter(i => i.id !== id))); const PAGE_TITLES = { dashboard: { title: "Dashboard", sub: "Overview of your rental portfolio" }, properties: { title: "Properties", sub: "Property P&L and expense history" }, transactions:{ title: "Transactions", sub: "Income & expense ledger" }, invoices: { title: "Invoices", sub: "Create and track tenant invoices" }, leases: { title: "Lease Contracts", sub: "Track lease terms and renewals" }, tenants: { title: "Tenants", sub: "Tenant contact directory" }, }; return ( <>

Property
Manager

Kenji Morishige
{NAV.map(n => (
setPage(n.id)}> {n.icon} {n.label}
))}
{PAGE_TITLES[page].title}
{PAGE_TITLES[page].sub}
{loading ? (
Loading…
) : ( <> {page === "dashboard" && } {page === "properties" && properties.map(p => )} {page === "transactions"&& } {page === "invoices" && } {page === "leases" && } {page === "tenants" && } )}
); }