475 lines
18 KiB
TypeScript
475 lines
18 KiB
TypeScript
// routes/landlord/dashboard/index.tsx
|
|
import { define } from "../../../utils.ts";
|
|
import {
|
|
getLandlordDashboardData,
|
|
getPendingLeaseApprovals,
|
|
} from "../../../services/landlordService.ts";
|
|
import { getUnreadCount } from "../../../services/messageService.ts";
|
|
import { DashboardMaintenanceRequest } from "../../../services/maintenanceRequestService.ts";
|
|
import { DashboardMessage } from "../../../services/messageService.ts";
|
|
import { page } from "fresh";
|
|
import { SessionCookie } from "../../auth/callback.ts";
|
|
import { MakeNewDate } from "../../../helpers.ts";
|
|
import { Lease, Property, User } from "../../../db/interfaces.ts";
|
|
import LandlordPendingApprovals from "../../../islands/LandlordPendingApprovals.tsx";
|
|
|
|
// Interface for pending lease approval with nested property and tenant data
|
|
interface PendingLeaseApproval extends Lease {
|
|
property?: Property | null;
|
|
tenant?: Pick<User, "id" | "name" | "email" | "phone"> | null;
|
|
}
|
|
|
|
// Flexible interface for dashboard data - allows any structure
|
|
interface FlexibleDashboardData {
|
|
// Core dashboard stats from LandlordDashboardData
|
|
properties?: {
|
|
total: number;
|
|
id: string;
|
|
address_1: string;
|
|
city: string;
|
|
state: string;
|
|
rental_status: string;
|
|
monthly_rent: number;
|
|
bedrooms: number;
|
|
bathrooms: number;
|
|
available: number;
|
|
occupied: number;
|
|
draft: number;
|
|
}[];
|
|
leases?: {
|
|
active: number;
|
|
draft: number;
|
|
expired: number;
|
|
}[];
|
|
applications?: {
|
|
pending: number;
|
|
total_this_month: number;
|
|
title: string;
|
|
}[];
|
|
payments?: {
|
|
expected_this_month: number;
|
|
received_this_month: number;
|
|
overdue: number;
|
|
}[];
|
|
// Additional UI properties with proper types from service layer
|
|
maintenanceRequests?: DashboardMaintenanceRequest[];
|
|
recentMessages?: DashboardMessage[];
|
|
monthlyRevenue?: number;
|
|
pendingLeaseApprovals?: PendingLeaseApproval[];
|
|
unreadMessageCount?: number;
|
|
[key: string]: unknown; // Allow any additional properties
|
|
}
|
|
|
|
// Flexible interface for component data
|
|
interface LandlordDashboardPageData {
|
|
stateData?: SessionCookie;
|
|
dashboardData?: FlexibleDashboardData;
|
|
error?: string;
|
|
[key: string]: unknown; // Allow additional properties with unknown type
|
|
}
|
|
|
|
interface LandlordDashboardProps {
|
|
data: LandlordDashboardPageData;
|
|
}
|
|
|
|
export const handler = define.handlers({
|
|
async GET(ctx) {
|
|
const stateData: SessionCookie = ctx.state.data;
|
|
const { user_id } = stateData;
|
|
|
|
try {
|
|
const dashboardData = await getLandlordDashboardData(user_id);
|
|
const pendingApprovals = await getPendingLeaseApprovals(user_id);
|
|
const unreadMessageCount = await getUnreadCount(user_id);
|
|
|
|
return page({
|
|
stateData,
|
|
dashboardData: {
|
|
...dashboardData,
|
|
pendingLeaseApprovals: pendingApprovals,
|
|
unreadMessageCount,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Error loading landlord dashboard:", error);
|
|
return page({
|
|
stateData,
|
|
error: "Failed to load dashboard data",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
export default define.page(
|
|
function LandlordDashboard({ data }: LandlordDashboardProps) {
|
|
const { stateData: _stateData, dashboardData, error } = data;
|
|
|
|
if (error) {
|
|
return (
|
|
<div class="dashboard-error-page landlord-route">
|
|
<div class="dashboard-error-inner">
|
|
<h1 class="dashboard-error-title">Dashboard Error</h1>
|
|
<p class="dashboard-error-text">{error}</p>
|
|
<a href="/onboarding#landlord" class="dashboard-error-link">
|
|
Return to Onboarding
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const {
|
|
properties,
|
|
maintenanceRequests,
|
|
recentMessages,
|
|
monthlyRevenue,
|
|
pendingLeaseApprovals,
|
|
unreadMessageCount = 0,
|
|
} = dashboardData || {};
|
|
|
|
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">Landlord Dashboard</h1>
|
|
<p class="dashboard-header-subtitle">
|
|
Welcome back! Here's what's happening with your properties.
|
|
</p>
|
|
</div>
|
|
<div class="dashboard-header-actions">
|
|
<a
|
|
href="/landlord/properties/add"
|
|
class="dashboard-btn dashboard-btn--primary"
|
|
>
|
|
Add Property
|
|
</a>
|
|
<a
|
|
href="/landlord/tenants/invite"
|
|
class="dashboard-btn dashboard-btn--secondary"
|
|
>
|
|
Invite Tenant
|
|
</a>
|
|
<a
|
|
href="/landlord/messages"
|
|
class={`dashboard-btn ${
|
|
unreadMessageCount > 0
|
|
? "dashboard-btn--messages-new"
|
|
: "dashboard-btn--secondary"
|
|
}`}
|
|
>
|
|
Messages
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dashboard-content">
|
|
{/* Stats Overview */}
|
|
<div class="dashboard-stats-grid">
|
|
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="dashboard-stat-content">
|
|
<dl>
|
|
<dt class="dashboard-stat-label">Total Properties</dt>
|
|
<dd class="dashboard-stat-value">
|
|
{properties?.length || 0}
|
|
</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">Pending Approvals</dt>
|
|
<dd class="dashboard-stat-value">
|
|
{pendingLeaseApprovals?.length || 0}
|
|
</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="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="dashboard-stat-content">
|
|
<dl>
|
|
<dt class="dashboard-stat-label">Open Maintenance</dt>
|
|
<dd class="dashboard-stat-value">
|
|
{maintenanceRequests?.filter((req) =>
|
|
req.status === "open"
|
|
).length || 0}
|
|
</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="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">Monthly Revenue</dt>
|
|
<dd class="dashboard-stat-value">
|
|
${monthlyRevenue?.toLocaleString() || "0"}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dashboard-two-col">
|
|
{/* Recent Properties */}
|
|
<div class="dashboard-card dashboard-card--no-overflow">
|
|
<div class="dashboard-card-body">
|
|
<div class="dashboard-section-header">
|
|
<h3 class="dashboard-section-title">Your Properties</h3>
|
|
<a href="/landlord/properties" class="dashboard-section-link">
|
|
View all
|
|
</a>
|
|
</div>
|
|
<div class="dashboard-list-spaced">
|
|
{properties && properties.length > 0
|
|
? (
|
|
properties.slice(0, 3).map((property) => (
|
|
<div key={property.id} class="dashboard-row">
|
|
<div>
|
|
<p class="dashboard-row-label">
|
|
{property.address_1}
|
|
</p>
|
|
<p class="dashboard-row-muted">
|
|
{property.city}, {property.state}
|
|
</p>
|
|
<p class="dashboard-row-meta capitalize">
|
|
{property.rental_status}
|
|
</p>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
<p class="dashboard-row-value">
|
|
${property.monthly_rent}/mo
|
|
</p>
|
|
<p class="dashboard-row-sub">
|
|
{property.bedrooms}bd/{property.bathrooms}ba
|
|
</p>
|
|
<a
|
|
href={`/landlord/properties/${property.id}`}
|
|
class="dashboard-btn-link"
|
|
>
|
|
Details
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
: (
|
|
<div class="dashboard-empty">
|
|
<p class="dashboard-empty-text">No properties yet</p>
|
|
<a
|
|
href="/landlord/properties/add"
|
|
class="dashboard-empty-link"
|
|
>
|
|
Add your first property
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Messages */}
|
|
<div class="dashboard-card dashboard-card--no-overflow">
|
|
<div class="dashboard-card-body">
|
|
<div class="dashboard-section-header">
|
|
<h3 class="dashboard-section-title">Recent Messages</h3>
|
|
<a href="/landlord/messages" class="dashboard-section-link">
|
|
View all
|
|
</a>
|
|
</div>
|
|
<div class="dashboard-list-spaced">
|
|
{recentMessages && recentMessages.length > 0
|
|
? (
|
|
recentMessages.slice(0, 3).map((message) => (
|
|
<div key={message.id} class="dashboard-message-row">
|
|
<div class="dashboard-message-avatar">
|
|
<span class="dashboard-message-avatar-text">
|
|
{message.sender_name?.charAt(0) || "T"}
|
|
</span>
|
|
</div>
|
|
<div class="dashboard-message-body">
|
|
<p class="dashboard-message-from">
|
|
{message.sender_name || "Tenant"}
|
|
</p>
|
|
<p class="dashboard-message-preview">
|
|
{message.subject}
|
|
</p>
|
|
<p class="dashboard-message-meta">
|
|
{MakeNewDate(
|
|
(message as DashboardMessage)
|
|
.created_on as number,
|
|
)
|
|
.toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
{!message.is_read && (
|
|
<div
|
|
class="dashboard-message-dot"
|
|
aria-hidden="true"
|
|
>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)
|
|
: (
|
|
<div class="dashboard-empty">
|
|
<p class="dashboard-empty-text">No messages yet</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pending Lease Approvals */}
|
|
<LandlordPendingApprovals
|
|
approvals={pendingLeaseApprovals ?? []}
|
|
/>
|
|
|
|
{/* Maintenance Requests */}
|
|
<div class="dashboard-card dashboard-card--no-overflow">
|
|
<div class="dashboard-card-body">
|
|
<div class="dashboard-section-header">
|
|
<h3 class="dashboard-section-title">
|
|
Maintenance Requests
|
|
</h3>
|
|
<a
|
|
href="/landlord/maintenance"
|
|
class="dashboard-section-link"
|
|
>
|
|
View all
|
|
</a>
|
|
</div>
|
|
<div class="dashboard-list-spaced">
|
|
{maintenanceRequests &&
|
|
maintenanceRequests.filter((req) => req.status === "open")
|
|
.length > 0
|
|
? (
|
|
maintenanceRequests.filter((req) => req.status === "open")
|
|
.slice(0, 3).map((request) => (
|
|
<div
|
|
key={request.id}
|
|
class="dashboard-row dashboard-row--red"
|
|
>
|
|
<div>
|
|
<p class="dashboard-row-label">
|
|
{request.description}
|
|
</p>
|
|
<p class="dashboard-row-muted capitalize">
|
|
Priority: {request.priority}
|
|
</p>
|
|
<p class="dashboard-row-meta">
|
|
{request.created_on
|
|
? new Date(request.created_on)
|
|
.toLocaleDateString()
|
|
: "Unknown date"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span class="dashboard-badge dashboard-badge--red-pill">
|
|
{request.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
: (
|
|
<div class="dashboard-empty">
|
|
<p class="dashboard-empty-text">
|
|
No open maintenance requests
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|