Files
property-manager/src/property-manager.jsx
Kenji Morishige eef3a39ff8 Add property manager UI with json-server backend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 23:33:27 -05:00

1262 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" style={wide ? { maxWidth: 740 } : {}}>
<div className="modal-header">
<h2>{title}</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">{children}</div>
{onSave && (
<div className="modal-footer">
<button className="btn btn-outline" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={onSave}>Save</button>
</div>
)}
</div>
</div>
);
}
// ── 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 (
<div>
<div className="stat-grid">
<div className="stat-card green">
<div className="stat-label">Total Income</div>
<div className="stat-value" style={{ color: COLORS.green }}>{fmt(income)}</div>
<div className="stat-sub">this period</div>
</div>
<div className="stat-card red">
<div className="stat-label">Total Expenses</div>
<div className="stat-value" style={{ color: COLORS.red }}>{fmt(expenses)}</div>
<div className="stat-sub">this period</div>
</div>
<div className="stat-card gold">
<div className="stat-label">Net Operating Income</div>
<div className="stat-value" style={{ color: net >= 0 ? COLORS.green : COLORS.red }}>{fmt(net)}</div>
<div className="stat-sub">income expenses</div>
</div>
<div className="stat-card navy">
<div className="stat-label">Pending Invoices</div>
<div className="stat-value">{fmt(pending)}</div>
<div className="stat-sub">{invoices.filter(i => i.status === "pending").length} invoice(s) outstanding</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<div className="card">
<div className="card-title">Recent Transactions</div>
<div className="table-wrap">
<table>
<thead><tr><th>Date</th><th>Description</th><th>Amount</th></tr></thead>
<tbody>
{transactions.slice(-5).reverse().map(t => (
<tr key={t.id}>
<td style={{ color: COLORS.slate, fontSize: "0.8rem" }}>{t.date}</td>
<td>{t.description}</td>
<td className={`mono ${t.type}`}>{t.type === "income" ? "+" : ""}{fmt(t.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-title">Expense Breakdown</div>
{Object.entries(categories).map(([cat, amt]) => (
<div key={cat} style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.82rem", marginBottom: 4 }}>
<span style={{ color: COLORS.navyMid }}>{cat}</span>
<span className="mono" style={{ color: COLORS.slate }}>{fmt(amt)}</span>
</div>
<div style={{ height: 6, background: COLORS.slateLight, borderRadius: 99, overflow: "hidden" }}>
<div style={{ height: "100%", width: `${(amt / expenses) * 100}%`, background: COLORS.gold, borderRadius: 99 }} />
</div>
</div>
))}
{expiringLeases.length > 0 && (
<>
<div className="divider" />
<div className="card-title">Lease Alerts</div>
{expiringLeases.map(l => {
const t = tenants.find(t => String(t.id) === String(l.tenantId));
const d = daysUntil(l.end);
return (
<div key={l.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8, fontSize: "0.83rem" }}>
<span>{t?.name} {l.unit}</span>
<span className={`badge ${d < 0 ? "badge-red" : "badge-gold"}`}>
{d < 0 ? `Expired ${Math.abs(d)}d ago` : `Expires in ${d}d`}
</span>
</div>
);
})}
</>
)}
</div>
</div>
</div>
);
}
// ── 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 (
<div>
<div className="tabs">
{["all", "income", "expense"].map(t => (
<button key={t} className={`tab ${tab === t ? "active" : ""}`} onClick={() => { setTab(t); setTxPage(1); }}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Transaction Ledger</div>
<button className="btn btn-primary btn-sm" onClick={() => setShowModal(true)}> Add Transaction</button>
</div>
<div className="table-wrap">
<table>
<thead>
<tr><th>Date</th><th>Type</th><th>Category</th><th>Description</th><th>Tenant</th><th style={{ textAlign: "right" }}>Amount</th><th /></tr>
</thead>
<tbody>
{visible.length === 0 && (
<tr><td colSpan={7}><div className="empty"><div className="empty-icon">📋</div><p>No transactions yet</p></div></td></tr>
)}
{paginated.map(t => {
const tenant = tenants.find(tn => String(tn.id) === String(t.tenantId));
return (
<tr key={t.id}>
<td style={{ color: COLORS.slate, fontSize: "0.8rem" }}>{t.date}</td>
<td><span className={`badge ${t.type === "income" ? "badge-green" : "badge-red"}`}>{t.type}</span></td>
<td style={{ color: COLORS.slate }}>{t.category}</td>
<td>{t.description}</td>
<td style={{ color: COLORS.slate }}>{tenant?.name || "—"}</td>
<td className={`mono ${t.type}`} style={{ textAlign: "right" }}>
{t.type === "income" ? "+" : ""}{fmt(t.amount)}
</td>
<td><button className="btn-delete" onClick={() => onDeleteTransaction(t.id)} title="Delete">×</button></td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 16, fontSize: "0.82rem", color: COLORS.slate }}>
<span>{sorted.length} transactions · page {txPage} of {totalPages}</span>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-outline btn-sm" disabled={txPage === 1} onClick={() => setTxPage(p => p - 1)} style={{ opacity: txPage === 1 ? 0.4 : 1 }}> Prev</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(n => (
<button key={n} className="btn btn-sm" onClick={() => setTxPage(n)}
style={{ background: n === txPage ? COLORS.gold : COLORS.slateLight, color: n === txPage ? COLORS.navy : COLORS.slate, border: "none", cursor: "pointer", borderRadius: 6, padding: "5px 10px", fontWeight: n === txPage ? 600 : 400 }}>
{n}
</button>
))}
<button className="btn btn-outline btn-sm" disabled={txPage === totalPages} onClick={() => setTxPage(p => p + 1)} style={{ opacity: txPage === totalPages ? 0.4 : 1 }}>Next </button>
</div>
</div>
)}
</div>
{showModal && (
<Modal title="Add Transaction" onClose={() => setShowModal(false)} onSave={save}>
<div className="form-row cols-2">
<div className="form-group"><label>Date</label><input type="date" value={form.date} onChange={e => setForm(p => ({ ...p, date: e.target.value }))} /></div>
<div className="form-group">
<label>Type</label>
<select value={form.type} onChange={e => setForm(p => ({ ...p, type: e.target.value, category: e.target.value === "income" ? "Rent" : "Repairs" }))}>
<option value="income">Income</option>
<option value="expense">Expense</option>
</select>
</div>
</div>
<div className="form-row cols-2">
<div className="form-group">
<label>Category</label>
<select value={form.category} onChange={e => setForm(p => ({ ...p, category: e.target.value }))}>
{(form.type === "income" ? incCats : expCats).map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div className="form-group">
<label>Tenant (optional)</label>
<select value={form.tenantId} onChange={e => setForm(p => ({ ...p, tenantId: e.target.value }))}>
<option value=""> None </option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group"><label>Description</label><input placeholder="e.g. June rent — Unit 1A" value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></div>
</div>
<div className="form-row">
<div className="form-group"><label>Amount ($)</label><input type="number" placeholder="0.00" value={form.amount} onChange={e => setForm(p => ({ ...p, amount: e.target.value }))} /></div>
</div>
</Modal>
)}
</div>
);
}
// ── Invoices ─────────────────────────────────────────────────────────────────
function InvoiceLineItem({ item, idx, onChange, onRemove }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 60px 100px 32px", gap: 8, marginBottom: 8, alignItems: "center" }}>
<input placeholder="Description" value={item.desc} onChange={e => onChange(idx, "desc", e.target.value)} />
<input type="number" placeholder="Qty" value={item.qty} onChange={e => onChange(idx, "qty", parseFloat(e.target.value) || 1)} style={{ textAlign: "center" }} />
<input type="number" placeholder="Rate" value={item.rate} onChange={e => onChange(idx, "rate", parseFloat(e.target.value) || 0)} />
<button className="btn btn-sm" style={{ background: COLORS.slateLight, border: "none", cursor: "pointer", color: COLORS.red, borderRadius: 6 }} onClick={() => onRemove(idx)}>×</button>
</div>
);
}
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 (
<div className="invoice-preview">
<div className="invoice-top">
<div className="invoice-from">
<h3>{fromName || "Kenji Morishige"}</h3>
<p>Tax ID: 99-4686386<br />kenji@kenjim.com<br />408.813.3323</p>
</div>
<div className="invoice-meta">
<div className="invoice-num">{inv.num || "INV-XXX"}</div>
<p style={{ color: COLORS.slate, fontSize: "0.8rem", marginTop: 4 }}>
Issue date: {inv.date}<br />Due date: {inv.due}
</p>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: "0.72rem", textTransform: "uppercase", letterSpacing: "0.08em", color: COLORS.slate, marginBottom: 4 }}>Bill To</div>
<strong style={{ color: COLORS.navy }}>{tenant?.name || "—"}</strong>
<p style={{ color: COLORS.slate, fontSize: "0.8rem" }}>{tenant?.unit}<br />{tenant?.email}</p>
</div>
<table className="invoice-lines">
<thead><tr><th>Description</th><th style={{ textAlign: "center" }}>Qty</th><th style={{ textAlign: "right" }}>Rate</th><th style={{ textAlign: "right" }}>Total</th></tr></thead>
<tbody>
{inv.items.map((item, i) => (
<tr key={i}>
<td>{item.desc}</td>
<td style={{ textAlign: "center" }}>{item.qty}</td>
<td style={{ textAlign: "right" }} className="mono">{fmt(item.rate)}</td>
<td style={{ textAlign: "right" }} className="mono">{fmt(item.qty * item.rate)}</td>
</tr>
))}
</tbody>
</table>
<div className="invoice-total">
<div className="total-line"><span>Subtotal</span><span className="mono">{fmt(subtotal)}</span></div>
<div className="total-line grand"><span>Total Due</span><span className="mono">{fmt(subtotal)}</span></div>
</div>
<div style={{ marginTop: 24, paddingTop: 16, borderTop: `1px solid ${COLORS.slateLight}`, fontSize: "0.75rem", color: COLORS.slate }}>
Please pay by {inv.due}. Questions? Contact kenji@kenjim.com or 408.813.3323.
</div>
</div>
);
}
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 (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 20 }}>
<button className="btn btn-outline" onClick={generateMonthly}> Generate Monthly Invoice</button>
<button className="btn btn-primary" onClick={() => setShowNew(true)}> Create Invoice</button>
</div>
<div className="card">
<div className="table-wrap">
<table>
<thead>
<tr><th>Invoice #</th><th>Tenant</th><th>Issue Date</th><th>Due Date</th><th>Amount</th><th>Status</th><th>Actions</th></tr>
</thead>
<tbody>
{invoices.length === 0 && (
<tr><td colSpan={7}><div className="empty"><div className="empty-icon">🧾</div><p>No invoices yet</p></div></td></tr>
)}
{[...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 (
<tr key={inv.id}>
<td style={{ fontWeight: 600, color: COLORS.gold }}>{inv.num}</td>
<td>{tenant?.name}</td>
<td style={{ color: COLORS.slate }}>{inv.date}</td>
<td style={{ color: COLORS.slate }}>{inv.due}</td>
<td className="mono">{fmt(total)}</td>
<td><span className={`badge ${inv.status === "paid" ? "badge-green" : "badge-gold"}`}>{inv.status}</span></td>
<td style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<button className="btn btn-outline btn-sm" onClick={() => setViewing(inv)}>View</button>
<button className="btn btn-sm" style={{ background: COLORS.slateLight, color: COLORS.navyMid, border: "none", cursor: "pointer", borderRadius: 6, padding: "5px 10px", fontSize: "0.78rem" }} onClick={() => sendInvoiceEmail(inv)}> Invoice</button>
<button className="btn btn-sm" style={{ background: "#E8F0FD", color: "#2255CC", border: "none", cursor: "pointer", borderRadius: 6, padding: "5px 10px", fontSize: "0.78rem" }} onClick={() => sendPaymentEmail(inv)}> Payment Rcvd</button>
{inv.status === "pending" && (
<button className="btn btn-sm" style={{ background: COLORS.greenLight, color: COLORS.green, border: "none", cursor: "pointer", borderRadius: 6, padding: "5px 10px", fontSize: "0.78rem" }} onClick={() => markPaid(inv.id)}>Mark Paid</button>
)}
<button className="btn-delete" onClick={() => onDeleteInvoice(inv.id)} title="Delete">×</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{showNew && (
<Modal title="Create Invoice" onClose={() => setShowNew(false)} onSave={save} wide>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<div>
<div className="form-row">
<div className="form-group">
<label>Tenant</label>
<select value={form.tenantId} onChange={e => setForm(p => ({ ...p, tenantId: e.target.value }))}>
<option value="">Select tenant...</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name} {t.unit}</option>)}
</select>
</div>
</div>
<div className="form-row cols-2">
<div className="form-group"><label>Issue Date</label><input type="date" value={form.date} onChange={e => setForm(p => ({ ...p, date: e.target.value }))} /></div>
<div className="form-group"><label>Due Date</label><input type="date" value={form.due} onChange={e => setForm(p => ({ ...p, due: e.target.value }))} /></div>
</div>
<div style={{ marginBottom: 8 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 60px 100px 32px", gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: "0.72rem", textTransform: "uppercase", letterSpacing: "0.06em", color: COLORS.slate }}>Description</span>
<span style={{ fontSize: "0.72rem", textTransform: "uppercase", letterSpacing: "0.06em", color: COLORS.slate, textAlign: "center" }}>Qty</span>
<span style={{ fontSize: "0.72rem", textTransform: "uppercase", letterSpacing: "0.06em", color: COLORS.slate }}>Rate ($)</span>
<span />
</div>
{form.items.map((item, idx) => (
<InvoiceLineItem key={idx} item={item} idx={idx} onChange={updateItem} onRemove={removeItem} />
))}
<button className="btn btn-outline btn-sm" onClick={addItem} style={{ marginTop: 6 }}> Add line</button>
</div>
</div>
<div>
<div style={{ fontSize: "0.72rem", textTransform: "uppercase", letterSpacing: "0.08em", color: COLORS.slate, marginBottom: 10 }}>Preview</div>
<InvoicePreview inv={{ ...form, num: nextInvoiceNum(invoices) }} tenants={tenants} />
</div>
</div>
</Modal>
)}
{viewing && (
<Modal title={`Invoice ${viewing.num}`} onClose={() => setViewing(null)}>
<InvoicePreview inv={viewing} tenants={tenants} />
</Modal>
)}
</div>
);
}
// ── 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 (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 20 }}>
<div />
<button className="btn btn-primary" onClick={() => setShowModal(true)}> Add Lease</button>
</div>
<div className="lease-grid">
{leases.length === 0 && (
<div className="empty"><div className="empty-icon">📄</div><p>No leases yet</p></div>
)}
{leases.map(lease => {
const tenant = tenants.find(t => String(t.id) === String(lease.tenantId));
const st = leaseStatus(lease);
const d = daysUntil(lease.end);
return (
<div key={lease.id} className={`lease-card ${st === "active" ? "" : st}`}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12 }}>
<div>
<h3>{tenant?.name || "Unknown Tenant"}</h3>
<div style={{ color: COLORS.slate, fontSize: "0.82rem" }}>{lease.unit}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span className={`badge ${st === "active" ? "badge-green" : st === "expiring" ? "badge-gold" : "badge-red"}`}>
{st === "active" ? "Active" : st === "expiring" ? `Expires in ${d}d` : "Expired"}
</span>
<button className="btn-delete" onClick={() => onDeleteLease(lease.id)} title="Delete">×</button>
</div>
</div>
<div className="lease-detail">
<div><strong>Monthly Rent:</strong> {fmt(lease.rent)}</div>
<div><strong>Security Deposit:</strong> {fmt(lease.deposit)}</div>
<div><strong>Lease Start:</strong> {lease.start}</div>
<div><strong>Lease End:</strong> {lease.end}</div>
{tenant && <><div><strong>Email:</strong> {tenant.email}</div><div><strong>Phone:</strong> {tenant.phone}</div></>}
</div>
</div>
);
})}
</div>
{showModal && (
<Modal title="Add Lease" onClose={() => setShowModal(false)} onSave={save}>
<div className="form-row">
<div className="form-group">
<label>Tenant</label>
<select value={form.tenantId} onChange={e => {
const t = tenants.find(t => t.id === parseInt(e.target.value));
setForm(p => ({ ...p, tenantId: e.target.value, unit: t?.unit || p.unit }));
}}>
<option value="">Select tenant...</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="form-row cols-2">
<div className="form-group"><label>Unit</label><input placeholder="e.g. Unit 1A" value={form.unit} onChange={e => setForm(p => ({ ...p, unit: e.target.value }))} /></div>
<div className="form-group"><label>Monthly Rent ($)</label><input type="number" placeholder="0.00" value={form.rent} onChange={e => setForm(p => ({ ...p, rent: e.target.value }))} /></div>
</div>
<div className="form-row cols-2">
<div className="form-group"><label>Security Deposit ($)</label><input type="number" placeholder="0.00" value={form.deposit} onChange={e => setForm(p => ({ ...p, deposit: e.target.value }))} /></div>
<div className="form-group" />
</div>
<div className="form-row cols-2">
<div className="form-group"><label>Start Date</label><input type="date" value={form.start} onChange={e => setForm(p => ({ ...p, start: e.target.value }))} /></div>
<div className="form-group"><label>End Date</label><input type="date" value={form.end} onChange={e => setForm(p => ({ ...p, end: e.target.value }))} /></div>
</div>
</Modal>
)}
</div>
);
}
// ── 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 (
<input
autoFocus
type={type}
value={draft}
onChange={e => 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 (
<span
onClick={() => { setDraft(value); setEditing(true); }}
title="Click to edit"
style={{ cursor: "text", color: COLORS.slate, borderBottom: `1px dashed ${COLORS.slateLight}`, paddingBottom: 1 }}
>
{value || <span style={{ opacity: 0.4 }}></span>}
</span>
);
}
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 (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 20 }}>
<div />
<button className="btn btn-primary" onClick={() => setShowModal(true)}> Add Tenant</button>
</div>
<div className="card">
<div className="table-wrap">
<table>
<thead><tr><th>Name</th><th>Unit</th><th>Email</th><th>Phone</th><th /></tr></thead>
<tbody>
{tenants.length === 0 && (
<tr><td colSpan={5}><div className="empty"><div className="empty-icon">👤</div><p>No tenants yet</p></div></td></tr>
)}
{tenants.map(t => (
<tr key={t.id}>
<td style={{ fontWeight: 500 }}>{t.name}</td>
<td><span className="badge badge-slate">{t.unit}</span></td>
<td><EditableCell value={t.email} type="email" onSave={val => onUpdateTenant(t.id, { email: val })} /></td>
<td><EditableCell value={t.phone} onSave={val => onUpdateTenant(t.id, { phone: val })} /></td>
<td><button className="btn-delete" onClick={() => onDeleteTenant(t.id)} title="Delete">×</button></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{showModal && (
<Modal title="Add Tenant" onClose={() => setShowModal(false)} onSave={save}>
<div className="form-row cols-2">
<div className="form-group"><label>Full Name</label><input placeholder="Jane Doe" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
<div className="form-group"><label>Unit</label><input placeholder="Unit 1A" value={form.unit} onChange={e => setForm(p => ({ ...p, unit: e.target.value }))} /></div>
</div>
<div className="form-row cols-2">
<div className="form-group"><label>Email</label><input type="email" placeholder="tenant@email.com" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} /></div>
<div className="form-group"><label>Phone</label><input placeholder="212-555-0100" value={form.phone} onChange={e => setForm(p => ({ ...p, phone: e.target.value }))} /></div>
</div>
</Modal>
)}
</div>
);
}
// ── 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 (
<div>
{/* Property Card */}
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontFamily: "'Playfair Display', Georgia, serif", fontSize: "1.1rem", fontWeight: 600, color: COLORS.navy, marginBottom: 4 }}>
{property.address}
</div>
<div style={{ color: COLORS.slate, fontSize: "0.85rem" }}>{property.city}, {property.state} {property.zip} &nbsp;·&nbsp; {property.type} &nbsp;·&nbsp; Since {property.purchaseYear}</div>
</div>
<div style={{ textAlign: "right" }}>
{tenant ? (
<>
<div style={{ fontSize: "0.72rem", textTransform: "uppercase", letterSpacing: "0.08em", color: COLORS.slate }}>Current Tenant</div>
<div style={{ fontWeight: 500, color: COLORS.navy }}>{tenant.name}</div>
<div style={{ fontSize: "0.8rem", color: COLORS.slate }}>{fmt(activeLease.rent)}/mo · Ends {activeLease.end}</div>
</>
) : <span className="badge badge-red">Vacant</span>}
</div>
</div>
{pendingTotal > 0 && (
<div style={{ marginTop: 12, padding: "8px 12px", background: COLORS.redLight, borderRadius: 6, fontSize: "0.82rem", color: COLORS.red }}>
Outstanding balance: <strong>{fmt(pendingTotal)}</strong> across {pendingInvoices.length} pending invoice{pendingInvoices.length !== 1 ? "s" : ""}
</div>
)}
</div>
{/* Year filter + P&L stats */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div className="tabs">
<button className={`tab ${yearFilter === "all" ? "active" : ""}`} onClick={() => setYearFilter("all")}>All Years</button>
{years.map(y => <button key={y} className={`tab ${yearFilter === y ? "active" : ""}`} onClick={() => setYearFilter(y)}>{y}</button>)}
</div>
</div>
<div className="stat-grid" style={{ marginBottom: 20 }}>
<div className="stat-card"><div className="stat-label">Total Income</div><div className="stat-value" style={{ color: COLORS.green }}>{fmt(income)}</div></div>
<div className="stat-card"><div className="stat-label">Total Expenses</div><div className="stat-value" style={{ color: COLORS.red }}>{fmt(expenses)}</div></div>
<div className="stat-card"><div className="stat-label">Net Operating Income</div><div className="stat-value" style={{ color: noi >= 0 ? COLORS.green : COLORS.red }}>{fmt(noi)}</div></div>
<div className="stat-card"><div className="stat-label">Transactions</div><div className="stat-value">{filtered.length}</div></div>
</div>
{/* Expense breakdown */}
{catEntries.length > 0 && (
<div className="card" style={{ marginBottom: 20 }}>
<div className="card-title">Expense Breakdown</div>
{catEntries.map(([cat, amt]) => (
<div key={cat} style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.82rem", marginBottom: 4 }}>
<span>{cat}</span><span className="mono">{fmt(amt)}</span>
</div>
<div style={{ height: 6, background: COLORS.slateLight, borderRadius: 3 }}>
<div style={{ height: 6, borderRadius: 3, background: COLORS.gold, width: `${(amt / maxCat) * 100}%` }} />
</div>
</div>
))}
</div>
)}
{/* Transaction list */}
<div className="card">
<div className="card-title">Transaction History</div>
<div className="table-wrap">
<table>
<thead><tr><th>Date</th><th>Type</th><th>Category</th><th>Description</th><th style={{ textAlign: "right" }}>Amount</th></tr></thead>
<tbody>
{filtered.length === 0 && <tr><td colSpan={5}><div className="empty"><p>No transactions for this period</p></div></td></tr>}
{[...filtered].sort((a, b) => b.date.localeCompare(a.date)).map(t => (
<tr key={t.id}>
<td style={{ color: COLORS.slate, fontSize: "0.8rem" }}>{t.date}</td>
<td><span className={`badge ${t.type === "income" ? "badge-green" : "badge-red"}`}>{t.type}</span></td>
<td style={{ color: COLORS.slate }}>{t.category}</td>
<td style={{ fontSize: "0.82rem" }}>{t.description}</td>
<td className={`mono ${t.type}`} style={{ textAlign: "right" }}>{t.type === "income" ? "+" : ""}{fmt(t.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
// ── 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 (
<>
<style>{css}</style>
<div className="app">
<div className="sidebar">
<div className="sidebar-logo">
<h1>Property<br />Manager</h1>
<span>Kenji Morishige</span>
</div>
{NAV.map(n => (
<div key={n.id} className={`nav-item ${page === n.id ? "active" : ""}`} onClick={() => setPage(n.id)}>
<span className="nav-icon">{n.icon}</span>
<span>{n.label}</span>
</div>
))}
</div>
<div className="main">
<div className="page-header">
<div>
<div className="page-title">{PAGE_TITLES[page].title}</div>
<div className="page-sub">{PAGE_TITLES[page].sub}</div>
</div>
</div>
<div className="page-content">
{loading ? (
<div style={{ padding: 40, textAlign: "center", color: COLORS.slate }}>Loading</div>
) : (
<>
{page === "dashboard" && <Dashboard transactions={transactions} invoices={invoices} leases={leases} tenants={tenants} />}
{page === "properties" && properties.map(p => <PropertyView key={p.id} property={p} transactions={transactions} leases={leases} invoices={invoices} tenants={tenants} />)}
{page === "transactions"&& <Transactions transactions={transactions} onAddTransaction={onAddTransaction} onDeleteTransaction={onDeleteTransaction} tenants={tenants} />}
{page === "invoices" && <Invoices invoices={invoices} onAddInvoice={onAddInvoice} onUpdateInvoice={onUpdateInvoice} onDeleteInvoice={onDeleteInvoice} tenants={tenants} leases={leases} />}
{page === "leases" && <Leases leases={leases} onAddLease={onAddLease} onDeleteLease={onDeleteLease} tenants={tenants} />}
{page === "tenants" && <Tenants tenants={tenants} onAddTenant={onAddTenant} onUpdateTenant={onUpdateTenant} onDeleteTenant={onDeleteTenant} />}
</>
)}
</div>
</div>
</div>
</>
);
}