343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
// routes/landlord/applications/index.tsx
|
|
import { define } from "../../../utils.ts";
|
|
import {
|
|
getTenantApplicationsByLandlord,
|
|
TenantApplication,
|
|
updateTenantApplicationStatus,
|
|
} from "../../../services/tenantApplicationService.ts";
|
|
import { page } from "fresh";
|
|
import { SessionCookie } from "../../auth/callback.ts";
|
|
import { createPublicId } from "../../../helpers.ts";
|
|
|
|
// Extended interface for tenant applications with additional UI properties
|
|
interface ExtendedTenantApplication extends TenantApplication {
|
|
status?: string; // UI status field (maps to application_status)
|
|
applicant_name?: string;
|
|
applicant_email?: string;
|
|
created_at?: number; // UI field (maps to submitted_on)
|
|
property_address?: string;
|
|
monthly_income?: number;
|
|
application_notes?: string;
|
|
[key: string]: unknown; // Allow additional dynamic properties
|
|
}
|
|
|
|
// Flexible interface for component data
|
|
interface LandlordApplicationsData {
|
|
stateData?: SessionCookie;
|
|
applications?: ExtendedTenantApplication[];
|
|
error?: string;
|
|
publicId?: string;
|
|
[key: string]: unknown; // Allow additional properties with unknown type
|
|
}
|
|
|
|
interface LandlordApplicationsProps {
|
|
data: LandlordApplicationsData;
|
|
}
|
|
|
|
export const handler = define.handlers({
|
|
async GET(ctx) {
|
|
const stateData: SessionCookie = ctx.state.data;
|
|
const { user_id } = stateData;
|
|
|
|
try {
|
|
const applications = await getTenantApplicationsByLandlord(user_id);
|
|
const publicId = await createPublicId(user_id);
|
|
|
|
return page({
|
|
stateData,
|
|
applications,
|
|
error: "",
|
|
publicId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error loading tenant applications:", error);
|
|
const publicId = await createPublicId(user_id);
|
|
return page({
|
|
stateData,
|
|
error: "Failed to load applications",
|
|
publicId,
|
|
});
|
|
}
|
|
},
|
|
|
|
async POST(ctx) {
|
|
const stateData: SessionCookie = ctx.state.data;
|
|
try {
|
|
const formData = await ctx.req.formData();
|
|
const application_id = formData.get("application_id") as string;
|
|
const action = formData.get("action") as string;
|
|
|
|
if (action === "approve" || action === "reject") {
|
|
await updateTenantApplicationStatus(
|
|
application_id,
|
|
action === "approve" ? "approved" : "rejected",
|
|
);
|
|
}
|
|
|
|
return new Response("", {
|
|
status: 302,
|
|
headers: { Location: "/landlord/applications" },
|
|
});
|
|
} catch (error) {
|
|
console.error("Error updating application:", error);
|
|
return page({
|
|
stateData,
|
|
error: "Failed to update application",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
export default define.page(
|
|
function LandlordApplications({ data }: LandlordApplicationsProps) {
|
|
const { stateData: _stateData, applications, error } = data;
|
|
|
|
return (
|
|
<div class="min-h-screen bg-gray-50 landlord-route">
|
|
{/* 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">
|
|
Tenant Applications
|
|
</h1>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Review and manage tenant applications for your properties
|
|
</p>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<a
|
|
href="/landlord/dashboard"
|
|
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"
|
|
>
|
|
Back to Dashboard
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{error && (
|
|
<div class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Filter Tabs */}
|
|
<div class="mb-6">
|
|
<div class="border-b border-gray-200">
|
|
<nav class="-mb-px flex space-x-8">
|
|
<a
|
|
href="#"
|
|
class="border-blue-500 text-blue-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm"
|
|
>
|
|
All Applications ({applications?.length || 0})
|
|
</a>
|
|
<a
|
|
href="#"
|
|
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm"
|
|
>
|
|
Pending
|
|
({applications?.filter((app) => app.status === "pending")
|
|
.length || 0})
|
|
</a>
|
|
<a
|
|
href="#"
|
|
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm"
|
|
>
|
|
Approved
|
|
({applications?.filter((app) => app.status === "approved")
|
|
.length || 0})
|
|
</a>
|
|
<a
|
|
href="#"
|
|
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm"
|
|
>
|
|
Rejected
|
|
({applications?.filter((app) => app.status === "rejected")
|
|
.length || 0})
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
{applications && applications.length > 0
|
|
? (
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
|
<ul class="divide-y divide-gray-200">
|
|
{applications.map((application) => (
|
|
<li key={application.id}>
|
|
<div class="px-4 py-4 sm:px-6">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
|
|
<span class="text-sm font-medium text-gray-700">
|
|
{application.applicant_name?.charAt(0) || "T"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="ml-4">
|
|
<div class="flex items-center">
|
|
<p class="text-sm font-medium text-blue-600 truncate">
|
|
{application.applicant_name}
|
|
</p>
|
|
<span
|
|
class={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
application.status === "pending"
|
|
? "bg-yellow-100 text-yellow-800"
|
|
: application.status === "approved"
|
|
? "bg-green-100 text-green-800"
|
|
: "bg-red-100 text-red-800"
|
|
}`}
|
|
>
|
|
{application.status}
|
|
</span>
|
|
</div>
|
|
<div class="mt-2 flex">
|
|
<div class="flex items-center text-sm text-gray-500">
|
|
<svg
|
|
class="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
|
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
|
</svg>
|
|
<p>{application.applicant_email}</p>
|
|
</div>
|
|
<div class="ml-6 flex items-center text-sm text-gray-500">
|
|
<svg
|
|
class="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<p>
|
|
Applied {application.created_at
|
|
? new Date(application.created_at)
|
|
.toLocaleDateString()
|
|
: "Unknown date"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{application.status === "pending" && (
|
|
<div class="flex space-x-2">
|
|
<form method="POST" class="inline">
|
|
<input
|
|
type="hidden"
|
|
name="application_id"
|
|
value={application.id}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="action"
|
|
value="approve"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
|
>
|
|
Approve
|
|
</button>
|
|
</form>
|
|
<form method="POST" class="inline">
|
|
<input
|
|
type="hidden"
|
|
name="application_id"
|
|
value={application.id}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="action"
|
|
value="reject"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
|
>
|
|
Reject
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
<div class="sm:flex sm:justify-between">
|
|
<div class="sm:flex">
|
|
<p class="flex items-center text-sm text-gray-500">
|
|
Property: {application.property_address ||
|
|
"Property Address"}
|
|
</p>
|
|
</div>
|
|
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
|
<p>
|
|
Monthly Income: ${application.monthly_income
|
|
?.toLocaleString() || "N/A"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{application.application_notes && (
|
|
<div class="mt-2">
|
|
<p class="text-sm text-gray-600">
|
|
<strong>Notes:</strong>{" "}
|
|
{application.application_notes}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)
|
|
: (
|
|
<div class="text-center py-12">
|
|
<svg
|
|
class="mx-auto h-12 w-12 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>
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
|
No applications
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
You haven't received any tenant applications yet.
|
|
</p>
|
|
<div class="mt-6">
|
|
<a
|
|
href="/landlord/properties"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
Manage Properties
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|