310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
// routes/tenant/property/index.tsx
|
|
import { define } from "../../../utils.ts";
|
|
import { page } from "fresh";
|
|
import { getLeasesByTenant } from "../../../services/leaseService.ts";
|
|
import { getPropertyById } from "../../../services/propertyService.ts";
|
|
import { getMediaByProperty } from "../../../services/propertyMediaService.ts";
|
|
import { getDocumentsByLease } from "../../../services/leaseDocumentService.ts";
|
|
import {
|
|
Lease,
|
|
LeaseDocument,
|
|
Property,
|
|
PropertyMedia,
|
|
} from "../../../db/interfaces.ts";
|
|
import { AlertMessage } from "../maintenance/new/components/AlertMessage.tsx";
|
|
|
|
interface PropertyPageData {
|
|
lease?: Lease;
|
|
property?: Property;
|
|
propertyPhotos?: PropertyMedia[];
|
|
leaseDocuments?: LeaseDocument[];
|
|
error?: string;
|
|
}
|
|
|
|
export const handler = define.handlers({
|
|
async GET(ctx) {
|
|
const { user_id } = ctx.state.data;
|
|
|
|
try {
|
|
const leases = await getLeasesByTenant(user_id);
|
|
const currentLease = leases?.[0]; // Assuming the first lease is the current active one
|
|
|
|
if (!currentLease || !currentLease.property_id) {
|
|
return page<PropertyPageData>({
|
|
error: "No active lease found for a property.",
|
|
});
|
|
}
|
|
|
|
const property = await getPropertyById(currentLease.property_id);
|
|
const propertyPhotos = await getMediaByProperty(currentLease.property_id);
|
|
const leaseDocuments = await getDocumentsByLease(currentLease.id);
|
|
|
|
return page<PropertyPageData>({
|
|
lease: currentLease,
|
|
property,
|
|
propertyPhotos: propertyPhotos.filter((m: PropertyMedia) =>
|
|
m.media_type === "photo"
|
|
),
|
|
leaseDocuments,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error loading property details:", error);
|
|
return page<PropertyPageData>({
|
|
error: "Failed to load property details.",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
function DetailItem(
|
|
{ label, value }: {
|
|
label: string;
|
|
value: string | number | null | undefined;
|
|
},
|
|
) {
|
|
return (
|
|
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
<dt class="text-sm font-medium text-gray-500">{label}</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
|
{value !== undefined ? value : "N/A"}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatPropertyAddress(property: Property) {
|
|
const street = property.address_1?.trim() || "";
|
|
const cityState = [property.city, property.state].filter(Boolean).join(", ");
|
|
const zip = property.zip?.trim() || "";
|
|
const region = [cityState, zip].filter(Boolean).join(" ").trim();
|
|
return [street, region].filter(Boolean).join(", ").trim();
|
|
}
|
|
|
|
function parseList(raw: string | null | undefined): string[] {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.map((item) => String(item)).filter(Boolean);
|
|
}
|
|
} catch {
|
|
// Fall through to comma-separated parsing.
|
|
}
|
|
return raw.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
function DocumentList({ documents }: { documents: LeaseDocument[] }) {
|
|
return (
|
|
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
|
{documents.length > 0
|
|
? (
|
|
documents.map((doc) => (
|
|
<li class="pl-3 pr-4 py-3 flex items-center justify-between text-sm">
|
|
<div class="w-0 flex-1 flex items-center">
|
|
<svg
|
|
class="shrink-0 h-5 w-5 text-gray-400"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span class="ml-2 flex-1 w-0 truncate">
|
|
{doc.file_name || "Document"} ({doc.document_type})
|
|
</span>
|
|
</div>
|
|
<div class="ml-4 shrink-0">
|
|
{doc.file_url
|
|
? (
|
|
<a
|
|
href={doc.file_url}
|
|
target="_blank"
|
|
class="font-medium text-blue-600 hover:text-blue-500"
|
|
>
|
|
View
|
|
</a>
|
|
)
|
|
: <span class="text-gray-400">Unavailable</span>}
|
|
</div>
|
|
</li>
|
|
))
|
|
)
|
|
: (
|
|
<li class="px-6 py-4 text-sm text-gray-500 text-center">
|
|
No documents found.
|
|
</li>
|
|
)}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
export default define.page(
|
|
function TenantPropertyPage({ data }: { data: PropertyPageData }) {
|
|
const { lease, property, propertyPhotos, leaseDocuments, error } =
|
|
data as PropertyPageData;
|
|
|
|
if (error) {
|
|
return (
|
|
<div class="min-h-screen bg-gray-50 p-8">
|
|
<AlertMessage type="error">{error}</AlertMessage>
|
|
<div class="mt-6">
|
|
<a
|
|
href="/tenant/dashboard"
|
|
class="text-blue-600 hover:text-blue-500 font-medium"
|
|
>
|
|
Go to Dashboard
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!property) {
|
|
return (
|
|
<div class="min-h-screen bg-gray-50 p-8">
|
|
<AlertMessage type="error">
|
|
Property details could not be loaded.
|
|
</AlertMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const addressLine = formatPropertyAddress(property);
|
|
const amenities = parseList(property.amenities);
|
|
const rules = parseList(lease?.late_fee_rules ?? "");
|
|
const photoItems = (propertyPhotos ?? []).filter(
|
|
(photo): photo is PropertyMedia & { file_url: string } =>
|
|
Boolean(photo.file_url),
|
|
);
|
|
|
|
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="py-6">
|
|
<h1 class="text-3xl font-bold text-gray-900">
|
|
Your Current Residence
|
|
</h1>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
{addressLine || "Address not available"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 py-8 space-y-8">
|
|
{/* Property Photos */}
|
|
{photoItems.length > 0 && (
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg p-6">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4 border-b pb-2">
|
|
Property Photos
|
|
</h3>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
|
{photoItems.map((photo) => (
|
|
<img
|
|
src={photo.file_url}
|
|
alt={photo.file_name || "Property Photo"}
|
|
class="w-full h-48 object-cover rounded-lg shadow-md"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Property Details */}
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Property Details
|
|
</h3>
|
|
</div>
|
|
<div class="border-t border-gray-200">
|
|
<dl>
|
|
<DetailItem
|
|
label="Address"
|
|
value={addressLine || "Address not available"}
|
|
/>
|
|
<DetailItem
|
|
label="Unit Number"
|
|
value={property.address_2}
|
|
/>
|
|
<DetailItem label="Bedrooms" value={property.bedrooms} />
|
|
<DetailItem label="Bathrooms" value={property.bathrooms} />
|
|
<DetailItem
|
|
label="Square Footage"
|
|
value={property.square_footage}
|
|
/>
|
|
<DetailItem label="Year Built" value={property.year_built} />
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Amenities */}
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Amenities
|
|
</h3>
|
|
</div>
|
|
<div class="border-t border-gray-200 p-6">
|
|
{amenities.length > 0
|
|
? (
|
|
<ul class="list-disc list-inside space-y-1 text-sm text-gray-700">
|
|
{amenities.map((amenity: string) => (
|
|
<li key={amenity}>{amenity}</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
: <p class="text-sm text-gray-500">No amenities listed.</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rules */}
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Community Rules
|
|
</h3>
|
|
</div>
|
|
<div class="border-t border-gray-200 p-6">
|
|
{rules.length > 0
|
|
? (
|
|
<ul class="list-disc list-inside space-y-1 text-sm text-gray-700">
|
|
{rules.map((rule: string) => <li key={rule}>{rule}</li>)}
|
|
</ul>
|
|
)
|
|
: (
|
|
<p class="text-sm text-gray-500">
|
|
No specific rules listed.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lease Documents */}
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
Lease Documents
|
|
</h3>
|
|
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
|
Your current lease agreement and related documents.
|
|
</p>
|
|
</div>
|
|
<div class="border-t border-gray-200">
|
|
<DocumentList documents={leaseDocuments || []} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|