700 lines
26 KiB
TypeScript

// routes/landlord/properties/[id]/index.tsx
// Property details page with complete payment history and expenses
import { define } from "../../../../utils.ts";
import { page } from "fresh";
import { getPropertyById } from "../../../../services/propertyService.ts";
import { getLeasesByProperty } from "../../../../services/leaseService.ts";
import { getUserById } from "../../../../services/userService.ts";
import {
getExpensesByProperty,
type PropertyExpense,
} from "../../../../services/expenseService.ts";
import {
getRentHistory,
type RentChange,
} from "../../../../services/rentChangeService.ts";
import { db } from "../../../../db/db.ts";
import { leases, payment_transactions } from "../../../../db/schema.ts";
import { eq } from "drizzle-orm";
import type { Property, Lease, User } from "../../../../db/interfaces.ts";
interface PropertyDetailsData {
property: Property | null;
leases: Lease[];
payments: Array<{
id: string;
amount: number | null;
payment_date: number | null;
payment_method: string | null;
transaction_status: string | null;
tenant_name?: string;
}>;
expenses: PropertyExpense[];
rentHistory: Array<{
effective_date: number;
monthly_rent: number;
previous_rent: number | null;
reason: string | null;
is_current: boolean;
}>;
error?: string;
}
function buildPropertyAddress(property: Property | null): string {
if (!property) return "";
return [
property.address_1,
property.address_2,
property.city,
property.state,
property.zip,
].filter(Boolean).join(", ");
}
export const handler = define.handlers({
async GET(ctx) {
const { id } = ctx.params;
try {
// Fetch property
const property = await getPropertyById(id);
if (!property) {
return page({
property: null,
leases: [],
payments: [],
expenses: [],
rentHistory: [],
error: "Property not found",
} as PropertyDetailsData);
}
// Fetch all leases for this property
const leases = await getLeasesByProperty(id);
// Fetch all payment transactions for these leases
const payments: PropertyDetailsData["payments"] = [];
for (const lease of leases) {
if (lease.id) {
const leasePayments = await db.select({
id: payment_transactions.id,
amount: payment_transactions.amount,
payment_date: payment_transactions.payment_date,
payment_method: payment_transactions.payment_method,
transaction_status: payment_transactions.transaction_status,
})
.from(payment_transactions)
.where(eq(payment_transactions.lease_id, lease.id));
// Fetch tenant name for the lease
let tenant_name = undefined;
if (lease.tenant_id) {
const tenant = await getUserById(lease.tenant_id);
tenant_name = tenant?.name || undefined;
}
for (const payment of leasePayments) {
payments.push({
...payment,
tenant_name,
});
}
}
}
// Sort payments by date (newest first)
payments.sort((a, b) =>
(b.payment_date || 0) - (a.payment_date || 0)
);
// Fetch all expenses for this property
const expenses = await getExpensesByProperty(id);
// Fetch rent history
const rentHistory = await getRentHistory(id);
return page({
property,
leases,
payments,
expenses,
rentHistory,
} as PropertyDetailsData);
} catch (error) {
console.error("Error loading property details:", error);
return page({
property: null,
leases: [],
payments: [],
expenses: [],
rentHistory: [],
error: "Failed to load property details",
} as PropertyDetailsData);
}
},
});
export default define.page(function PropertyDetailsPage({ data }: { data: PropertyDetailsData }) {
const { property, leases, payments, expenses, rentHistory, error } = data;
if (error) {
return (
<div class="dashboard-page landlord-route">
<div class="dashboard-error-page">
<div class="dashboard-error-inner">
<h1 class="dashboard-error-title">Error</h1>
<p class="dashboard-error-text">{error}</p>
<a href="/landlord/properties" class="dashboard-error-link">
Back to Properties
</a>
</div>
</div>
</div>
);
}
if (!property) {
return (
<div class="dashboard-page landlord-route">
<div class="dashboard-error-page">
<div class="dashboard-error-inner">
<h1 class="dashboard-error-title">Property Not Found</h1>
<p class="dashboard-error-text">The property you're looking for doesn't exist.</p>
<a href="/landlord/properties" class="dashboard-error-link">
Back to Properties
</a>
</div>
</div>
</div>
);
}
const propertyAddress = buildPropertyAddress(property);
const totalPayments = payments.reduce((sum, p) => sum + (p.amount || 0), 0);
const totalExpenses = expenses.reduce((sum, e) => sum + (e.amount || 0), 0);
const netIncome = totalPayments - totalExpenses;
// Calculate expenses by category
const expensesByCategory: Record<string, number> = {};
for (const expense of expenses) {
const category = expense.category || "uncategorized";
expensesByCategory[category] = (expensesByCategory[category] || 0) + (expense.amount || 0);
}
return (
<div class="dashboard-page landlord-route">
{/* Header */}
<div class="dashboard-header">
<div class="dashboard-header-inner">
<div class="dashboard-header-row">
<div>
<h1 class="dashboard-header-title">Property Details</h1>
<p class="dashboard-header-subtitle">
{property.title || propertyAddress}
</p>
</div>
<div class="dashboard-header-actions">
<a
href="/landlord/properties"
class="dashboard-btn dashboard-btn--secondary"
>
Back to Properties
</a>
<a
href={`/landlord/properties/${property.id}/edit`}
class="dashboard-btn dashboard-btn--primary"
>
Edit Property
</a>
</div>
</div>
</div>
</div>
<div class="dashboard-content">
{/* Property Info Card */}
<div class="dashboard-card dashboard-section-mb">
<div class="dashboard-card-body">
<h3 class="dashboard-section-title dashboard-section-title--mb">
Property Information
</h3>
<div class="dashboard-property-grid">
<div>
<dt class="dashboard-stat-label">Address</dt>
<dd class="dashboard-stat-value">
{propertyAddress || "N/A"}
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Monthly Rent</dt>
<dd class="dashboard-stat-value">
${property.monthly_rent?.toLocaleString() || "N/A"}
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Status</dt>
<dd>
<span class={`dashboard-badge capitalize ${
property.rental_status === "occupied"
? "dashboard-badge--green"
: property.rental_status === "available"
? "dashboard-badge--blue"
: "dashboard-badge--yellow"
}`}>
{property.rental_status || "unknown"}
</span>
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Bedrooms / Bathrooms</dt>
<dd class="dashboard-stat-value">
{property.bedrooms || 0}bd / {property.bathrooms || 0}ba
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Square Footage</dt>
<dd class="dashboard-stat-value">
{property.square_footage?.toLocaleString() || "N/A"} sq ft
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Property Type</dt>
<dd class="dashboard-stat-value capitalize">
{property.property_type || "N/A"}
</dd>
</div>
</div>
</div>
</div>
{/* Financial Summary */}
<div class="dashboard-stats-grid dashboard-section-mb">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon dashboard-stat-icon--green"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Total Payments Received</dt>
<dd class="dashboard-stat-value">
${totalPayments.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon dashboard-stat-icon--red"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Total Expenses</dt>
<dd class="dashboard-stat-value">
${totalExpenses.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon dashboard-stat-icon--blue"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Net Income</dt>
<dd class={`dashboard-stat-value ${
netIncome >= 0 ? "text-success" : "text-danger"
}`}>
${netIncome.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Active Leases</dt>
<dd class="dashboard-stat-value">
{leases.filter((l) => l.lease_status === "active").length}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Two column layout */}
<div class="dashboard-two-col">
{/* Payment History */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Payment History</h3>
<span class="dashboard-section-subtitle">
{payments.length} payments received
</span>
</div>
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th class="dashboard-table-th">Date</th>
<th class="dashboard-table-th">Tenant</th>
<th class="dashboard-table-th">Method</th>
<th class="dashboard-table-th">Amount</th>
<th class="dashboard-table-th">Status</th>
</tr>
</thead>
<tbody>
{payments && payments.length > 0
? (
payments.map((payment) => (
<tr key={payment.id}>
<td class="dashboard-table-td">
{payment.payment_date
? new Date(payment.payment_date)
.toLocaleDateString()
: "N/A"}
</td>
<td class="dashboard-table-td">
{payment.tenant_name || "Unknown"}
</td>
<td class="dashboard-table-td capitalize">
{payment.payment_method || "N/A"}
</td>
<td class="dashboard-table-td">
${payment.amount?.toLocaleString() || "0"}
</td>
<td class="dashboard-table-td">
<span
class={`dashboard-badge ${
payment.transaction_status === "completed"
? "dashboard-badge--green"
: payment.transaction_status === "pending"
? "dashboard-badge--yellow"
: "dashboard-badge--red"
}`}
>
{payment.transaction_status || "unknown"}
</span>
</td>
</tr>
))
)
: (
<tr>
<td colspan={5} class="dashboard-table-empty">
No payment history yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Expenses by Category */}
{Object.keys(expensesByCategory).length > 0 && (
<div class="dashboard-card">
<div class="dashboard-card-body">
<h3 class="dashboard-section-title dashboard-section-title--mb">
Expenses by Category
</h3>
<div class="dashboard-list-spaced">
{Object.entries(expensesByCategory)
.sort(([, a], [, b]) => b - a)
.map(([category, amount]) => (
<div key={category} class="dashboard-row">
<div>
<p class="dashboard-row-label capitalize">
{category}
</p>
</div>
<span class="dashboard-row-value">
${amount.toLocaleString()}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Expense History */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Expense History</h3>
<a
href={`/landlord/properties/${property.id}/expenses/add`}
class="dashboard-section-link"
>
Add Expense
</a>
</div>
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th class="dashboard-table-th">Date</th>
<th class="dashboard-table-th">Category</th>
<th class="dashboard-table-th">Description</th>
<th class="dashboard-table-th">Vendor</th>
<th class="dashboard-table-th">Amount</th>
</tr>
</thead>
<tbody>
{expenses && expenses.length > 0
? (
expenses.map((expense) => (
<tr key={expense.id}>
<td class="dashboard-table-td">
{expense.expense_date
? new Date(expense.expense_date)
.toLocaleDateString()
: "N/A"}
</td>
<td class="dashboard-table-td capitalize">
{expense.category || "N/A"}
</td>
<td class="dashboard-table-td">
{expense.description || "-"}
</td>
<td class="dashboard-table-td">
{expense.vendor || "-"}
</td>
<td class="dashboard-table-td">
${expense.amount?.toLocaleString() || "0"}
</td>
</tr>
))
)
: (
<tr>
<td colspan={5} class="dashboard-table-empty">
No expenses recorded yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Active Leases */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Active Leases</h3>
</div>
<div class="dashboard-list-spaced">
{leases && leases.length > 0
? (
leases
.filter((l) => l.lease_status === "active")
.map((lease) => (
<div key={lease.id} class="dashboard-row">
<div>
<p class="dashboard-row-label">
{lease.title || `Lease #${lease.id.slice(0, 8)}`}
</p>
<p class="dashboard-row-muted">
{lease.start_date
? new Date(lease.start_date)
.toLocaleDateString()
: ""}{" "}
-{" "}
{lease.end_date
? new Date(lease.end_date)
.toLocaleDateString()
: ""}
</p>
</div>
<span class="dashboard-row-value">
${lease.rent?.toLocaleString() || "0"}/mo
</span>
</div>
))
)
: (
<div class="dashboard-empty">
<p class="dashboard-empty-text">No active leases</p>
</div>
)}
</div>
</div>
</div>
{/* Rent History */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Rent History</h3>
<a
href={`/landlord/properties/${property.id}/rent/change`}
class="dashboard-section-link"
>
Change Rent
</a>
</div>
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th class="dashboard-table-th">Effective Date</th>
<th class="dashboard-table-th">Previous Rent</th>
<th class="dashboard-table-th">New Rent</th>
<th class="dashboard-table-th">Change</th>
<th class="dashboard-table-th">Reason</th>
</tr>
</thead>
<tbody>
{rentHistory && rentHistory.length > 0
? (
rentHistory.map((entry, index) => {
const previousRent = entry.previous_rent || 0;
const newRent = entry.monthly_rent || 0;
const changeAmount = newRent - previousRent;
const isIncrease = changeAmount > 0;
const isDecrease = changeAmount < 0;
return (
<tr key={index}>
<td class="dashboard-table-td">
{entry.effective_date
? new Date(entry.effective_date)
.toLocaleDateString()
: "N/A"}
{entry.is_current && (
<span class="dashboard-badge dashboard-badge--green" style={{ marginLeft: "8px" }}>
Current
</span>
)}
</td>
<td class="dashboard-table-td">
${previousRent.toLocaleString()}
</td>
<td class="dashboard-table-td">
${newRent.toLocaleString()}
</td>
<td class="dashboard-table-td">
{previousRent > 0 && (
<span
class={
isIncrease
? "text-success"
: isDecrease
? "text-danger"
: ""
}
>
{isIncrease && "+"}
${changeAmount.toLocaleString()}
{changeAmount !== 0 && ` (${
Math.round(
(changeAmount / previousRent) * 100
)
}%)`}
</span>
)}
{previousRent === 0 && <span>-</span>}
</td>
<td class="dashboard-table-td">
{entry.reason || "-"}
</td>
</tr>
);
})
)
: (
<tr>
<td colspan={5} class="dashboard-table-empty">
No rent history available
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
);
});