1262 lines
59 KiB
JavaScript
1262 lines
59 KiB
JavaScript
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} · {property.type} · 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>
|
||
</>
|
||
);
|
||
}
|