556 lines
21 KiB
TypeScript
556 lines
21 KiB
TypeScript
// routes/landlord/dashboard/index.tsx
|
|
import { define } from "../../../utils.ts";
|
|
import { getLandlordDashboardData } from "../../../services/landlordService.ts";
|
|
import { DashboardTenantApplication } from "../../../services/tenantApplicationService.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";
|
|
|
|
// 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
|
|
tenantApplications?: DashboardTenantApplication[];
|
|
maintenanceRequests?: DashboardMaintenanceRequest[];
|
|
recentMessages?: DashboardMessage[];
|
|
monthlyRevenue?: 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;
|
|
|
|
if (!user_id) {
|
|
return new Response("", {
|
|
status: 302,
|
|
headers: { Location: "/auth/login" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const dashboardData = await getLandlordDashboardData(user_id);
|
|
|
|
return page({
|
|
stateData,
|
|
dashboardData,
|
|
});
|
|
} 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, dashboardData, error } = data;
|
|
|
|
if (error) {
|
|
return (
|
|
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div class="text-center">
|
|
<h1 class="text-2xl font-bold text-gray-900 mb-4">
|
|
Dashboard Error
|
|
</h1>
|
|
<p class="text-gray-600">{error}</p>
|
|
<a
|
|
href="/onboarding#landlord"
|
|
class="mt-4 inline-block text-blue-600 hover:text-blue-500"
|
|
>
|
|
Return to Onboarding
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const {
|
|
properties,
|
|
tenantApplications,
|
|
maintenanceRequests,
|
|
recentMessages,
|
|
monthlyRevenue,
|
|
} = dashboardData || {};
|
|
|
|
return (
|
|
<div class="min-h-screen bg-gray-50">
|
|
{/* Header */}
|
|
<div class="bg-white shadow">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-between items-center py-6">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900">
|
|
Landlord Dashboard
|
|
</h1>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Welcome back! Here's what's happening with your properties.
|
|
</p>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<a
|
|
href="/landlord/properties/add"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
Add Property
|
|
</a>
|
|
<a
|
|
href="/landlord/tenants/invite"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
|
>
|
|
Invite Tenant
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Stats Overview */}
|
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
|
<div class="p-5">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<svg
|
|
class="h-6 w-6 text-gray-400"
|
|
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-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
|
Total Properties
|
|
</dt>
|
|
<dd class="text-lg font-medium text-gray-900">
|
|
{properties?.length || 0}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
|
<div class="p-5">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<svg
|
|
class="h-6 w-6 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
|
Pending Applications
|
|
</dt>
|
|
<dd class="text-lg font-medium text-gray-900">
|
|
{tenantApplications?.filter((app) =>
|
|
app.status === "pending"
|
|
).length || 0}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
|
<div class="p-5">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<svg
|
|
class="h-6 w-6 text-gray-400"
|
|
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="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
|
Open Maintenance
|
|
</dt>
|
|
<dd class="text-lg font-medium text-gray-900">
|
|
{maintenanceRequests?.filter((req) =>
|
|
req.status === "open"
|
|
).length || 0}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
|
<div class="p-5">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<svg
|
|
class="h-6 w-6 text-gray-400"
|
|
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="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
|
Monthly Revenue
|
|
</dt>
|
|
<dd class="text-lg font-medium text-gray-900">
|
|
${monthlyRevenue?.toLocaleString() || "0"}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
|
{/* Recent Properties */}
|
|
<div class="bg-white shadow rounded-lg">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Your Properties
|
|
</h3>
|
|
<a
|
|
href="/landlord/properties"
|
|
class="text-sm text-blue-600 hover:text-blue-500"
|
|
>
|
|
View all
|
|
</a>
|
|
</div>
|
|
<div class="space-y-4">
|
|
{properties && properties.length > 0
|
|
? (
|
|
properties.slice(0, 3).map((property) => (
|
|
<div
|
|
key={property.id}
|
|
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
|
>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900">
|
|
{property.address_1}
|
|
</p>
|
|
<p class="text-sm text-gray-500">
|
|
{property.city}, {property.state}
|
|
</p>
|
|
<p class="text-xs text-gray-400 capitalize">
|
|
{property.rental_status}
|
|
</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-sm font-medium text-gray-900">
|
|
${property.monthly_rent}/mo
|
|
</p>
|
|
<p class="text-xs text-gray-500">
|
|
{property.bedrooms}bd/{property.bathrooms}ba
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
: (
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-gray-500">No properties yet</p>
|
|
<a
|
|
href="/landlord/properties/add"
|
|
class="text-sm text-blue-600 hover:text-blue-500"
|
|
>
|
|
Add your first property
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Messages */}
|
|
<div class="bg-white shadow rounded-lg">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Recent Messages
|
|
</h3>
|
|
<a
|
|
href="/landlord/messages"
|
|
class="text-sm text-blue-600 hover:text-blue-500"
|
|
>
|
|
View all
|
|
</a>
|
|
</div>
|
|
<div class="space-y-4">
|
|
{recentMessages && recentMessages.length > 0
|
|
? (
|
|
recentMessages.slice(0, 3).map((message) => (
|
|
<div
|
|
key={message.id}
|
|
class="flex items-start space-x-3"
|
|
>
|
|
<div class="flex-shrink-0">
|
|
<div class="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center">
|
|
<span class="text-xs font-medium text-gray-700">
|
|
{message.sender_name?.charAt(0) || "T"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900">
|
|
{message.sender_name || "Tenant"}
|
|
</p>
|
|
<p class="text-sm text-gray-500 truncate">
|
|
{message.subject}
|
|
</p>
|
|
<p class="text-xs text-gray-400">
|
|
{MakeNewDate(
|
|
(message as DashboardMessage)
|
|
.created_on as number,
|
|
)
|
|
.toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
{!message.is_read && (
|
|
<div class="flex-shrink-0">
|
|
<div class="w-2 h-2 bg-blue-600 rounded-full">
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)
|
|
: (
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-gray-500">No messages yet</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pending Applications */}
|
|
<div class="bg-white shadow rounded-lg">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Pending Applications
|
|
</h3>
|
|
<a
|
|
href="/landlord/applications"
|
|
class="text-sm text-blue-600 hover:text-blue-500"
|
|
>
|
|
View all
|
|
</a>
|
|
</div>
|
|
<div class="space-y-4">
|
|
{tenantApplications &&
|
|
tenantApplications.filter((app) =>
|
|
app.status === "pending"
|
|
).length > 0
|
|
? (
|
|
tenantApplications.filter((app) =>
|
|
app.status === "pending"
|
|
).slice(0, 3).map((application) => (
|
|
<div
|
|
key={application.id}
|
|
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
|
|
>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900">
|
|
{application.applicant_name}
|
|
</p>
|
|
<p class="text-sm text-gray-500">
|
|
{application.applicant_email}
|
|
</p>
|
|
<p class="text-xs text-gray-400">
|
|
Applied {application.submitted_on
|
|
? new Date(application.submitted_on)
|
|
.toLocaleDateString()
|
|
: "Unknown date"}
|
|
</p>
|
|
</div>
|
|
<div class="flex space-x-2">
|
|
<button class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
|
Approve
|
|
</button>
|
|
<button class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">
|
|
Reject
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
: (
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-gray-500">
|
|
No pending applications
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Maintenance Requests */}
|
|
<div class="bg-white shadow rounded-lg">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Maintenance Requests
|
|
</h3>
|
|
<a
|
|
href="/landlord/maintenance"
|
|
class="text-sm text-blue-600 hover:text-blue-500"
|
|
>
|
|
View all
|
|
</a>
|
|
</div>
|
|
<div class="space-y-4">
|
|
{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="flex items-center justify-between p-3 bg-red-50 rounded-lg"
|
|
>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900">
|
|
{request.description}
|
|
</p>
|
|
<p class="text-sm text-gray-500 capitalize">
|
|
Priority: {request.priority}
|
|
</p>
|
|
<p class="text-xs text-gray-400">
|
|
{request.created_on
|
|
? new Date(request.created_on)
|
|
.toLocaleDateString()
|
|
: "Unknown date"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
{request.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
: (
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-gray-500">
|
|
No open maintenance requests
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|