state before z ai

This commit is contained in:
Nathan Anderson 2026-01-30 17:53:25 -06:00
parent 363514879a
commit 344df1775d
23 changed files with 906 additions and 91 deletions

View File

@ -0,0 +1,59 @@
// islands/DeleteApplicationButton.tsx
import { useState } from "preact/hooks";
interface DeleteApplicationButtonProps {
applicationId: string;
applicantName: string;
applicantEmail: string;
}
export default function DeleteApplicationButton({
applicationId,
applicantName,
applicantEmail
}: DeleteApplicationButtonProps) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
const confirmed = confirm(
`Are you sure you want to delete the application from ${applicantName} (${applicantEmail})? This action cannot be undone.`
);
if (!confirmed) return;
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('id', applicationId);
formData.append('sender', globalThis.location.pathname);
const response = await fetch('/api/delete/tenant_application', {
method: 'POST',
body: formData
});
if (response.redirected) {
globalThis.location.href = response.url;
} else if (response.ok) {
globalThis.location.reload();
} else {
alert('Failed to delete application. Please try again.');
}
} catch (_error) {
alert('Error deleting application');
} finally {
setIsDeleting(false);
}
};
return (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}

View File

@ -0,0 +1,56 @@
// islands/DeleteLeaseButton.tsx
import { useState } from "preact/hooks";
interface DeleteLeaseButtonProps {
leaseId: string;
propertyAddress: string;
tenantName?: string;
}
export default function DeleteLeaseButton({ leaseId, propertyAddress, tenantName }: DeleteLeaseButtonProps) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
const tenantInfo = tenantName ? ` for ${tenantName}` : "";
const confirmed = confirm(
`Are you sure you want to delete the lease for "${propertyAddress}"${tenantInfo}? This action cannot be undone.`
);
if (!confirmed) return;
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('lease_id', leaseId);
formData.append('sender', globalThis.location.pathname);
const response = await fetch('/api/delete/lease', {
method: 'POST',
body: formData
});
if (response.redirected) {
globalThis.location.href = response.url;
} else if (response.ok) {
globalThis.location.reload();
} else {
alert('Failed to delete lease. Please try again.');
}
} catch (_error) {
alert('Error deleting lease');
} finally {
setIsDeleting(false);
}
};
return (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
class="text-sm text-red-600 hover:text-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}

View File

@ -0,0 +1,54 @@
// islands/DeletePropertyButton.tsx
import { useState } from "preact/hooks";
interface DeletePropertyButtonProps {
propertyId: string;
propertyAddress: string;
}
export default function DeletePropertyButton({ propertyId, propertyAddress }: DeletePropertyButtonProps) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
const confirmed = confirm(
`Are you sure you want to delete "${propertyAddress}"? This action cannot be undone and will remove all associated data including leases, applications, and maintenance requests.`
);
if (!confirmed) return;
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('property_id', propertyId);
formData.append('sender', globalThis.location.pathname);
const response = await fetch('/api/delete/property', {
method: 'POST',
body: formData
});
if (response.redirected) {
globalThis.location.href = response.url;
} else if (response.ok) {
globalThis.location.reload();
} else {
alert('Failed to delete property. Please make sure it has no active leases or applications.');
}
} catch (_error) {
alert('Error deleting property');
} finally {
setIsDeleting(false);
}
};
return (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
class="text-sm text-red-600 hover:text-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}

View File

@ -0,0 +1,63 @@
// islands/DeleteTenantInviteButton.tsx
import { useState } from "preact/hooks";
interface DeleteTenantInviteButtonProps {
inviteId: string;
propertyAddress?: string;
invitedEmails?: string[];
}
export default function DeleteTenantInviteButton({
inviteId,
propertyAddress,
invitedEmails
}: DeleteTenantInviteButtonProps) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
const propertyInfo = propertyAddress ? `"${propertyAddress}"` : "this property";
const emailCount = invitedEmails?.length || 0;
const emailInfo = emailCount > 0 ? ` with ${emailCount} invited email${emailCount > 1 ? 's' : ''}` : "";
const confirmed = confirm(
`Are you sure you want to delete the tenant invite for ${propertyInfo}${emailInfo}? This action cannot be undone.`
);
if (!confirmed) return;
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('invite_id', inviteId);
formData.append('sender', globalThis.location.pathname);
const response = await fetch('/api/delete/tenant_invite', {
method: 'POST',
body: formData
});
if (response.redirected) {
globalThis.location.href = response.url;
} else if (response.ok) {
globalThis.location.reload();
} else {
alert('Failed to delete tenant invite. Please try again.');
}
} catch (_error) {
alert('Error deleting tenant invite');
} finally {
setIsDeleting(false);
}
};
return (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
class="text-sm text-red-600 hover:text-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}

View File

@ -0,0 +1,78 @@
// islands/LeaseApprovalButtons.tsx
import { useState } from "preact/hooks";
interface LeaseApprovalButtonsProps {
approvalId: string;
}
export default function LeaseApprovalButtons({ approvalId }: LeaseApprovalButtonsProps) {
const [isApproving, setIsApproving] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const handleApprove = async () => {
setIsApproving(true);
try {
const response = await fetch(`/api/lease/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lease_id: approvalId })
});
if (response.ok) {
globalThis.location.reload();
} else {
alert('Failed to approve lease');
}
} catch (_error) {
alert('Error approving lease');
} finally {
setIsApproving(false);
}
};
const handleReject = async () => {
const reason = prompt('Rejection reason (optional):');
if (reason === null) return; // User cancelled
setIsRejecting(true);
try {
const response = await fetch(`/api/lease/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lease_id: approvalId,
rejection_reason: reason || ''
})
});
if (response.ok) {
globalThis.location.reload();
} else {
alert('Failed to reject lease');
}
} catch (_error) {
alert('Error rejecting lease');
} finally {
setIsRejecting(false);
}
};
return (
<div class="flex space-x-2">
<button
type="button"
onClick={handleApprove}
disabled={isApproving || isRejecting}
class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded hover:bg-green-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isApproving ? 'Approving...' : 'Approve'}
</button>
<button
type="button"
onClick={handleReject}
disabled={isApproving || isRejecting}
class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isRejecting ? 'Rejecting...' : 'Reject'}
</button>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { Lease, Property } from "../db/interfaces.ts"; import { Lease, Property } from "../db/interfaces.ts";
import { PopAddLeaseDialog } from "./dialogs/PopAddLeaseDialog.tsx"; import { PopAddLeaseDialog } from "./dialogs/PopAddLeaseDialog.tsx";
import DeleteLeaseButton from "./DeleteLeaseButton.tsx";
interface LeasedHomeProps { interface LeasedHomeProps {
leases: Lease[]; leases: Lease[];
@ -85,47 +86,12 @@ export default function LeasedHome({ leases, properties }: LeasedHomeProps) {
key={lease.id} key={lease.id}
class="border rounded p-4 bg-white shadow relative group" class="border rounded p-4 bg-white shadow relative group"
> >
<form <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
method="POST" <DeleteLeaseButton
action="/api/delete/lease" leaseId={lease.id}
className="absolute top-1/2 -right-0 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200" propertyAddress={lease.property_address || lease.title || 'Unknown Property'}
style={{ display: "inline" }}
onSubmit={(e) => {
// Set sender input to current page
const form = e.currentTarget;
form.sender.value = location.pathname;
}}
>
<input type="hidden" name="lease_id" value={lease.id} />
<input
type="hidden"
name="sender"
value={typeof location !== "undefined"
? location.pathname
: ""}
/> />
<button </div>
type="submit"
aria-label="Delete lease"
className="text-red-700"
style={{ background: "none", border: "none", padding: 0 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
<div class="font-semibold">Title: {lease.title}</div> <div class="font-semibold">Title: {lease.title}</div>
<div>Rent: {lease.rent}</div> <div>Rent: {lease.rent}</div>
<div>Deposit: {lease.security_deposit}</div> <div>Deposit: {lease.security_deposit}</div>

View File

@ -0,0 +1,31 @@
// routes/api/delete/tenant_invite.ts
import { define } from "../../../utils.ts";
import { deleteTenantInvite } from "../../../services/tenantInviteService.ts";
import { SessionCookie } from "../../auth/callback.ts";
export const handler = define.handlers({
async POST(ctx) {
const formData = await ctx.req.formData();
const invite_id = formData.get("invite_id");
const sender = formData.get("sender");
if (!invite_id || !sender) {
return new Response("Missing invite_id or sender", { status: 400 });
}
await deleteTenantInvite(invite_id.toString());
// Redirect back to the sender URL (must be absolute)
const senderPath = sender.toString();
let redirectUrl = senderPath;
try {
if (senderPath.startsWith("/")) {
const url = new URL(ctx.req.url);
redirectUrl = url.origin + senderPath;
}
} catch (_) {
// fallback: just use senderPath
}
return Response.redirect(redirectUrl, 303);
},
});

View File

@ -104,7 +104,6 @@ export const handler = define.handlers({
JSON.stringify({ JSON.stringify({
success: true, success: true,
lease: approvedLease, lease: approvedLease,
lease: approvedLease,
}), }),
{ {
status: 200, status: 200,

View File

@ -11,9 +11,8 @@ export const handler = define.handlers({
async GET(ctx) { async GET(ctx) {
try { try {
const stateData: SessionCookie = ctx.state.data; const stateData: SessionCookie = ctx.state.data;
const { user_id } = stateData;
if (!user_id) { if (!stateData || !stateData.user_id) {
return new Response( return new Response(
JSON.stringify({ error: "Unauthorized" }), JSON.stringify({ error: "Unauthorized" }),
{ {
@ -23,6 +22,8 @@ export const handler = define.handlers({
); );
} }
const { user_id } = stateData;
const pendingLeases = await getPendingApprovals(user_id); const pendingLeases = await getPendingApprovals(user_id);
if (!pendingLeases || pendingLeases.length === 0) { if (!pendingLeases || pendingLeases.length === 0) {

View File

@ -106,7 +106,6 @@ export const handler = define.handlers({
JSON.stringify({ JSON.stringify({
success: true, success: true,
lease: rejectedLease, lease: rejectedLease,
lease: rejectedLease,
}), }),
{ {
status: 200, status: 200,

View File

@ -16,6 +16,7 @@ export const handler = define.handlers({
async POST(ctx) { async POST(ctx) {
try { try {
const stateData: SessionCookie = ctx.state.data; const stateData: SessionCookie = ctx.state.data;
console.log('stateData', stateData);
const { user_id } = stateData; const { user_id } = stateData;
if (!user_id) { if (!user_id) {
@ -113,16 +114,6 @@ export const handler = define.handlers({
monthly_rent: property.monthly_rent ?? undefined, monthly_rent: property.monthly_rent ?? undefined,
}); });
} }
await sendLeaseApprovalRequest(invite.landlord_id, {
lease_id: lease.id,
property_address: property.address_1
? `${property.address_1}, ${property.city}, ${property.state}`
: property.title || "Property",
tenant_name: tenant?.name || "Tenant",
landlord_name: landlord?.name || "Landlord",
monthly_rent: property.monthly_rent,
});
}
return new Response( return new Response(
JSON.stringify({ JSON.stringify({

View File

@ -48,19 +48,21 @@ interface LandlordApprovalsProps {
export const handler = define.handlers({ export const handler = define.handlers({
async GET(ctx) { async GET(ctx) {
const stateData: SessionCookie = ctx.state.data; const stateData: SessionCookie = ctx.state.data;
const { user_id } = stateData;
if (!user_id) { if (!stateData || !stateData.user_id) {
return new Response("", { return new Response("", {
status: 302, status: 302,
headers: { Location: "/auth/login" }, headers: { Location: "/auth/login" },
}); });
} }
const { user_id } = stateData;
try { try {
const response = await fetch("/api/lease/pending-approvals", { const response = await fetch(`http://${ctx.req.headers.get("host")}/api/lease/pending-approvals`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Cookie": ctx.req.headers.get("cookie") || "",
}, },
}); });
@ -116,8 +118,8 @@ export const handler = define.handlers({
} }
const apiEndpoint = action === "approve" const apiEndpoint = action === "approve"
? "/api/lease/approve" ? `http://${ctx.req.headers.get("host")}/api/lease/approve`
: "/api/lease/reject"; : `http://${ctx.req.headers.get("host")}/api/lease/reject`;
const body = action === "reject" const body = action === "reject"
? { lease_id, rejection_reason } ? { lease_id, rejection_reason }
@ -127,6 +129,7 @@ export const handler = define.handlers({
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Cookie": ctx.req.headers.get("cookie") || "",
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });

View File

@ -1,12 +1,16 @@
// routes/landlord/dashboard/index.tsx // routes/landlord/dashboard/index.tsx
import { define } from "../../../utils.ts"; import { define } from "../../../utils.ts";
import { getLandlordDashboardData, getPendingLeaseApprovals } from "../../../services/landlordService.ts"; import { getLandlordDashboardData, getPendingLeaseApprovals } from "../../../services/landlordService.ts";
import { getTenantInvitesByLandlord } from "../../../services/tenantInviteService.ts";
import { DashboardTenantApplication } from "../../../services/tenantApplicationService.ts"; import { DashboardTenantApplication } from "../../../services/tenantApplicationService.ts";
import { DashboardMaintenanceRequest } from "../../../services/maintenanceRequestService.ts"; import { DashboardMaintenanceRequest } from "../../../services/maintenanceRequestService.ts";
import { DashboardMessage } from "../../../services/messageService.ts"; import { DashboardMessage } from "../../../services/messageService.ts";
import { TenantInvite, Lease } from "../../../db/interfaces.ts";
import { page } from "fresh"; import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts"; import { SessionCookie } from "../../auth/callback.ts";
import { MakeNewDate } from "../../../helpers.ts"; import { MakeNewDate } from "../../../helpers.ts";
import LeaseApprovalButtons from "../../../islands/LeaseApprovalButtons.tsx";
import DeleteApplicationButton from "../../../islands/DeleteApplicationButton.tsx";
// Flexible interface for dashboard data - allows any structure // Flexible interface for dashboard data - allows any structure
interface FlexibleDashboardData { interface FlexibleDashboardData {
@ -76,12 +80,14 @@ export const handler = define.handlers({
try { try {
const dashboardData = await getLandlordDashboardData(user_id); const dashboardData = await getLandlordDashboardData(user_id);
const pendingApprovals = await getPendingLeaseApprovals(user_id); const pendingApprovals = await getPendingLeaseApprovals(user_id);
const tenantInvites = await getTenantInvitesByLandlord(user_id);
return page({ return page({
stateData, stateData,
dashboardData: { dashboardData: {
...dashboardData, ...dashboardData,
pendingApprovals, pendingApprovals,
tenantInvites,
}, },
}); });
} catch (error) { } catch (error) {
@ -124,6 +130,8 @@ export default define.page(
recentMessages, recentMessages,
monthlyRevenue, monthlyRevenue,
pendingLeaseApprovals, pendingLeaseApprovals,
tenantInvites,
leases,
} = dashboardData || {}; } = dashboardData || {};
return ( return (
@ -470,6 +478,11 @@ export default define.page(
> >
Reject Reject
</button> </button>
<DeleteApplicationButton
applicationId={application.id}
applicantName={application.applicant_name || 'Unknown'}
applicantEmail={application.applicant_email || 'Unknown'}
/>
</div> </div>
</div> </div>
)) ))
@ -520,26 +533,7 @@ export default define.page(
: "Unknown date"} : "Unknown date"}
</p> </p>
</div> </div>
<div class="flex space-x-2"> <LeaseApprovalButtons approvalId={approval.id} />
<form method="POST" action="/api/lease/approve">
<input type="hidden" name="lease_id" value={approval.id} />
<button
type="submit"
class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded hover:bg-green-200"
>
Approve
</button>
</form>
<form method="POST" action="/api/lease/reject">
<input type="hidden" name="lease_id" value={approval.id} />
<button
type="submit"
class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded hover:bg-red-200"
>
Reject
</button>
</form>
</div>
</div> </div>
)) ))
) )
@ -554,6 +548,198 @@ export default define.page(
</div> </div>
</div> </div>
{/* Tenant Invites */}
<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">
Tenant Invites
</h3>
<a
href="/landlord/tenants/invites"
class="text-sm text-blue-600 hover:text-blue-500"
>
View all
</a>
</div>
<div class="space-y-4">
{tenantInvites && Array.isArray(tenantInvites) && tenantInvites.length > 0
? (
tenantInvites.slice(0, 3).map((invite: TenantInvite) => {
// Parse invited_emails string to get count
const invitedEmails = invite.invited_emails ?
invite.invited_emails.split(',').filter(email => email.trim()) : [];
return (
<div
key={invite.id}
class="flex items-center justify-between p-3 bg-purple-50 rounded-lg"
>
<div>
<p class="text-sm font-medium text-gray-900">
Property Invite
</p>
<p class="text-sm text-gray-500">
{invitedEmails.length} tenant(s) invited
</p>
<p class="text-xs text-gray-400">
Created {invite.created_on
? new Date(invite.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-purple-100 text-purple-800">
{invite.lease_id ? "Lease Created" : "Pending"}
</span>
</div>
</div>
);
})
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">
No tenant invites
</p>
</div>
)}
</div>
</div>
</div>
{/* Pending Leases */}
<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 Leases
</h3>
<a
href="/landlord/leases"
class="text-sm text-blue-600 hover:text-blue-500"
>
View all
</a>
</div>
<div class="space-y-4">
{(leases as unknown as Lease[]) && Array.isArray(leases) && (leases as unknown as Lease[]).length > 0
? (
(leases as unknown as Lease[]).filter((lease: Lease) => lease.approval_status === "pending_approval").slice(0, 3).map((lease: Lease) => (
<div
key={lease.id}
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
>
<div>
<p class="text-sm font-medium text-gray-900">
{lease.property_address || lease.title || "Property"}
</p>
<p class="text-sm text-gray-500">
${lease.rent || 0}/month
</p>
<p class="text-xs text-gray-400">
Created {lease.created_on
? new Date(lease.created_on).toLocaleDateString()
: "Unknown date"}
</p>
</div>
<div class="flex items-center space-x-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Pending Approval
</span>
{lease.approval_status === "pending_approval" && (
<div class="flex space-x-1">
<form method="POST" action="/landlord/approvals" class="inline">
<input type="hidden" name="lease_id" value={lease.id} />
<input type="hidden" name="action" value="approve" />
<button
type="submit"
class="inline-flex items-center px-2 py-1 border border-transparent text-xs leading-3 font-medium rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-green-500"
>
Approve
</button>
</form>
<form method="POST" action="/landlord/approvals" class="inline">
<input type="hidden" name="lease_id" value={lease.id} />
<input type="hidden" name="action" value="reject" />
<button
type="submit"
class="inline-flex items-center px-2 py-1 border border-transparent text-xs leading-3 font-medium rounded text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-red-500"
>
Reject
</button>
</form>
</div>
)}
</div>
</div>
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">
No pending leases
</p>
</div>
)}
</div>
</div>
</div>
{/* Active Leases */}
<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">
Active Leases
</h3>
<a
href="/landlord/leases"
class="text-sm text-blue-600 hover:text-blue-500"
>
View all
</a>
</div>
<div class="space-y-4">
{(leases as unknown as Lease[]) && Array.isArray(leases) && (leases as unknown as Lease[]).length > 0
? (
(leases as unknown as Lease[]).filter((lease: Lease) => lease.lease_status === "active").slice(0, 3).map((lease: Lease) => (
<div
key={lease.id}
class="flex items-center justify-between p-3 bg-green-50 rounded-lg"
>
<div>
<p class="text-sm font-medium text-gray-900">
{lease.property_address || lease.title || "Property"}
</p>
<p class="text-sm text-gray-500">
${lease.rent || 0}/month
</p>
<p class="text-xs text-gray-400">
Started {lease.start_date
? new Date(lease.start_date).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-green-100 text-green-800">
Active
</span>
</div>
</div>
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">
No active leases
</p>
</div>
)}
</div>
</div>
</div>
{/* Maintenance Requests */} {/* Maintenance Requests */}
<div class="bg-white shadow rounded-lg"> <div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">

View File

@ -4,6 +4,7 @@ import { SessionCookie } from "../../auth/callback.ts";
import { getPropertiesByUser } from "../../../services/propertyService.ts"; import { getPropertiesByUser } from "../../../services/propertyService.ts";
import { page } from "fresh"; import { page } from "fresh";
import { Property } from "../../../db/interfaces.ts"; import { Property } from "../../../db/interfaces.ts";
import DeletePropertyButton from "../../../islands/DeletePropertyButton.tsx";
export const handler = define.handlers({ export const handler = define.handlers({
async GET(ctx) { async GET(ctx) {
@ -147,10 +148,14 @@ export default define.page(function LandlordProperties({ data }) {
> >
Applications Applications
</a> </a>
<DeletePropertyButton
propertyId={property.id}
propertyAddress={property.address_1 || 'Unknown Address'}
/>
</div> </div>
{property.rental_status === "draft" && ( {property.rental_status === "draft" && (
<button class="text-sm bg-green-100 text-green-800 px-3 py-1 rounded-full hover:bg-green-200"> <button type="button" class="text-sm bg-green-100 text-green-800 px-3 py-1 rounded-full hover:bg-green-200">
Publish Publish
</button> </button>
)} )}

View File

@ -0,0 +1,240 @@
// routes/landlord/tenants/invites.tsx
import { define } from "../../../utils.ts";
import { page } from "fresh";
import type { SessionCookie } from "../../auth/callback.ts";
import type { Property, TenantInvite } from "../../../db/interfaces.ts";
import { getPropertiesByOwner } from "../../../services/propertyService.ts";
import { getTenantInvitesByLandlord } from "../../../services/tenantInviteService.ts";
import { getPropertyById } from "../../../services/propertyService.ts";
import DeleteTenantInviteButton from "../../../islands/DeleteTenantInviteButton.tsx";
interface InvitesPageData {
stateData: SessionCookie;
properties: Property[];
invites: (TenantInvite & { property?: Property })[];
error?: string;
}
function parseInvitedEmails(value?: string | null) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((item) => typeof item === "string");
}
} catch (_error) {
// Fall back to comma-separated parsing.
}
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
export const handler = define.handlers({
async GET(ctx) {
const stateData = ctx.state.data as SessionCookie;
const { user_id } = stateData;
if (!user_id) {
return new Response("", {
status: 302,
headers: { Location: "/auth/login" },
});
}
try {
const properties = await getPropertiesByOwner(user_id);
const invites = await getTenantInvitesByLandlord(user_id);
// Enrich invites with property data
const enrichedInvites = await Promise.all(
invites.map(async (invite) => {
const property = invite.property_id
? await getPropertyById(invite.property_id)
: null;
return { ...invite, property };
})
);
return page({
stateData,
properties,
invites: enrichedInvites,
});
} catch (error) {
console.error("Error loading tenant invites:", error);
return page({
stateData,
properties: [],
invites: [],
error: "Unable to load tenant invites. Please try again.",
});
}
},
});
export default define.page(function TenantInvitesPage({ data }) {
const { stateData, properties, invites, error } = data as InvitesPageData;
return (
<div class="min-h-screen bg-gray-50">
<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 Invites
</h1>
<p class="mt-1 text-sm text-gray-500">
Manage your tenant invitations and track their status
</p>
</div>
<div class="flex space-x-3">
<a
href="/landlord/tenants/invite"
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"
>
Create New Invite
</a>
<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>
)}
{invites.length === 0
? (
<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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">
No tenant invites
</h3>
<p class="mt-1 text-sm text-gray-500">
Get started by creating your first tenant invite.
</p>
<div class="mt-6">
<a
href="/landlord/tenants/invite"
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"
>
Create Invite
</a>
</div>
</div>
)
: (
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{invites.map((invite) => {
const invitedEmails = parseInvitedEmails(invite.invited_emails);
const propertyAddress = invite.property?.address_1 || invite.property?.title || 'Unknown Property';
return (
<div
key={invite.id}
class="bg-white overflow-hidden 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 truncate">
{propertyAddress}
</h3>
<div class="flex space-x-2">
<a
href={`/landlord/tenants/invite?invite_id=${invite.id}`}
class="text-sm text-blue-600 hover:text-blue-500"
>
Edit
</a>
<DeleteTenantInviteButton
inviteId={invite.id}
propertyAddress={propertyAddress}
invitedEmails={invitedEmails}
/>
</div>
</div>
<div class="space-y-2">
<div>
<span class="text-sm font-medium text-gray-500">Invited Emails:</span>
<div class="mt-1">
{invitedEmails.length > 0
? (
<ul class="text-sm text-gray-600 space-y-1">
{invitedEmails.map((email, index) => (
<li key={index} class="truncate">{email}</li>
))}
</ul>
)
: (
<p class="text-sm text-gray-400">No emails added</p>
)}
</div>
</div>
<div>
<span class="text-sm font-medium text-gray-500">Available On:</span>
<p class="text-sm text-gray-600">
{invite.available_on
? new Date(invite.available_on).toLocaleDateString()
: "Immediately"}
</p>
</div>
<div>
<span class="text-sm font-medium text-gray-500">Status:</span>
<div class="mt-1">
{invite.lease_id
? (
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Lease Created
</span>
)
: (
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Pending
</span>
)}
</div>
</div>
<div>
<span class="text-sm font-medium text-gray-500">Created:</span>
<p class="text-sm text-gray-600">
{invite.created_on
? new Date(invite.created_on).toLocaleDateString()
: "Unknown"}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
});

View File

@ -132,9 +132,12 @@ export const handler = define.handlers({
await startGuestOnboarding(userId, "tenant"); await startGuestOnboarding(userId, "tenant");
const response = await fetch("/api/lease/request-approval", { const response = await fetch(`http://${ctx.req.headers.get("host")}/api/lease/request-approval`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
"Cookie": ctx.req.headers.get("cookie") || "",
},
body: JSON.stringify({ body: JSON.stringify({
invite_id: invite.id, invite_id: invite.id,
property_id: invite.property_id, property_id: invite.property_id,

View File

@ -21,7 +21,7 @@ export const handler = define.handlers({
} }
try { try {
const response = await fetch("/api/retrieve/payment_methods", { const response = await fetch(`http://${ctx.req.headers.get("host")}/api/retrieve/payment_methods`, {
headers: { headers: {
"Cookie": ctx.req.headers.get("Cookie") || "", "Cookie": ctx.req.headers.get("Cookie") || "",
}, },

View File

@ -71,9 +71,12 @@ export const handler = define.handlers({
const lease_id = formData.get("lease_id") as string; const lease_id = formData.get("lease_id") as string;
// Call the payment intent creation endpoint // Call the payment intent creation endpoint
const intentResponse = await fetch("/api/payments/create_intent", { const intentResponse = await fetch(`http://${ctx.req.headers.get("host")}/api/payments/create_intent`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
"Cookie": ctx.req.headers.get("cookie") || "",
},
body: JSON.stringify({ body: JSON.stringify({
lease_id, lease_id,
amount, amount,

View File

@ -157,9 +157,30 @@ let e2eLeases: Lease[] = [
rent: 1800, rent: 1800,
security_deposit: 1800, security_deposit: 1800,
lease_status: "active", lease_status: "active",
approval_status: "approved",
approval_requested_at: BASE_TIME - 1000 * 60 * 60 * 24 * 115,
approved_at: BASE_TIME - 1000 * 60 * 60 * 24 * 114,
approved_by: e2eUserIds.landlord,
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 120, created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 120,
updated_on: BASE_TIME - 1000 * 60 * 60 * 24 * 5, updated_on: BASE_TIME - 1000 * 60 * 60 * 24 * 5,
}, },
{
id: "e2e-lease-pending-1",
title: "Pending Oak Avenue Lease",
property_id: "e2e-prop-1",
property_address: "123 Oak Ave, Seattle, WA",
tenant_id: e2eUserIds.tenant,
owner_id: e2eUserIds.landlord,
start_date: BASE_TIME + 1000 * 60 * 60 * 24 * 30,
end_date: BASE_TIME + 1000 * 60 * 60 * 24 * 395,
rent: 2200,
security_deposit: 2200,
lease_status: "draft",
approval_status: "pending_approval",
approval_requested_at: BASE_TIME - 1000 * 60 * 60 * 24 * 2,
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 3,
updated_on: BASE_TIME - 1000 * 60 * 60 * 24 * 2,
},
]; ];
const extraE2EMessages: Message[] = Array.from({ length: 9 }, (_, index) => ({ const extraE2EMessages: Message[] = Array.from({ length: 9 }, (_, index) => ({
@ -1040,6 +1061,19 @@ export function getE2ETwitterInvitesByEmail(email: string) {
}).map((invite) => ({ ...invite })); }).map((invite) => ({ ...invite }));
} }
export function getE2ETenantInvitesByLandlord(landlordId: string) {
return e2eTenantInvites
.filter((invite) => invite.landlord_id === landlordId)
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0));
}
export function deleteE2ETenantInvite(inviteId: string) {
const index = e2eTenantInvites.findIndex((invite) => invite.id === inviteId);
if (index !== -1) {
e2eTenantInvites.splice(index, 1);
}
}
export function getE2EPaymentMethodsByUser(userId: string) { export function getE2EPaymentMethodsByUser(userId: string) {
return e2ePaymentMethods.filter((method) => method.user_id === userId); return e2ePaymentMethods.filter((method) => method.user_id === userId);
} }
@ -1220,12 +1254,13 @@ export function getE2ELandlordDashboardData(userId: string) {
maintenanceRequests, maintenanceRequests,
recentMessages, recentMessages,
monthlyRevenue, monthlyRevenue,
leases,
}; };
} }
export function getE2EPendingApprovals() { export function getE2EPendingApprovals() {
return e2eLeases.filter((lease) => return e2eLeases.filter((lease) =>
lease.approval_status === "pending" || lease.approval_status === "requested" lease.approval_status === "pending_approval" || lease.approval_status === "pending" || lease.approval_status === "requested"
); );
} }

View File

@ -75,6 +75,7 @@ export interface LandlordDashboardData {
maintenanceRequests?: DashboardMaintenanceRequest[]; maintenanceRequests?: DashboardMaintenanceRequest[];
recentMessages?: DashboardMessage[]; recentMessages?: DashboardMessage[];
monthlyRevenue?: number; monthlyRevenue?: number;
leases?: Lease[];
} }
// Extended dashboard interface with additional UI data arrays // Extended dashboard interface with additional UI data arrays
@ -422,6 +423,7 @@ export async function getLandlordDashboardData(
(sum, property) => sum + (property.monthly_rent || 0), (sum, property) => sum + (property.monthly_rent || 0),
0, 0,
), ),
leases: leasesResult,
}; };
return dashboardData; return dashboardData;

View File

@ -12,6 +12,7 @@ import {
isE2EEnabled, isE2EEnabled,
getE2EPendingApprovals, getE2EPendingApprovals,
addE2ELeaseWithApproval, addE2ELeaseWithApproval,
getE2ETwitterInviteById,
} from "./e2eFixtures.ts"; } from "./e2eFixtures.ts";
// export interface Lease { // export interface Lease {
@ -324,9 +325,21 @@ export async function requestLeaseApproval(
invite_id: string, invite_id: string,
) { ) {
if (isE2EEnabled()) { if (isE2EEnabled()) {
return [addE2ELeaseWithApproval(tenant_id, property_id, invite_id)]; // For E2E, get the invite to extract landlord_id
const invite = getE2ETwitterInviteById(invite_id);
const landlord_id = invite?.landlord_id || "e2e-landlord-1";
return [addE2ELeaseWithApproval(property_id, tenant_id, landlord_id, "pending_approval")];
} }
// Get the invite to extract landlord_id
const invite = await db.select()
.from(tenant_invites)
.where(eq(tenant_invites.id, invite_id))
.limit(1);
const landlord_id = invite[0]?.landlord_id || null;
const now = Date.now(); const now = Date.now();
return await db.insert(leases) return await db.insert(leases)
.values({ .values({
@ -334,7 +347,7 @@ export async function requestLeaseApproval(
title: `Lease for Property ${property_id}`, title: `Lease for Property ${property_id}`,
property_id, property_id,
tenant_id, tenant_id,
owner_id: null, owner_id: landlord_id,
start_date: now, start_date: now,
end_date: now + 365 * 24 * 60 * 60 * 1000, end_date: now + 365 * 24 * 60 * 60 * 1000,
rent: null, rent: null,

View File

@ -49,6 +49,7 @@ export function InitiPGSessionMiddleware() {
return define.middleware(async (ctx: Context<any>) => { return define.middleware(async (ctx: Context<any>) => {
const url = new URL(ctx.req.url); const url = new URL(ctx.req.url);
console.log('path', url.pathname);
if (isE2EEnabled()) { if (isE2EEnabled()) {
const e2eUser = ctx.req.headers.get("x-e2e-user") ?? const e2eUser = ctx.req.headers.get("x-e2e-user") ??
url.searchParams.get("e2e-user"); url.searchParams.get("e2e-user");

View File

@ -1,15 +1,17 @@
// services/tenantInviteService.ts // services/tenantInviteService.ts
// Handle tenant invite creation and updates // Handle tenant invite creation and updates
import { and, eq, ilike } from "drizzle-orm"; import { and, eq, ilike, desc } from "drizzle-orm";
import { db } from "../db/db.ts"; import { db } from "../db/db.ts";
import { tenant_invites } from "../db/schema.ts"; import { tenant_invites } from "../db/schema.ts";
import type { TenantInvite } from "../db/interfaces.ts"; import type { TenantInvite } from "../db/interfaces.ts";
import { import {
addE2ETenantInvite, addE2ETenantInvite,
deleteE2ETenantInvite,
getE2ETwitterInviteById, getE2ETwitterInviteById,
getE2ETwitterInviteByProperty, getE2ETwitterInviteByProperty,
getE2ETwitterInvitesByEmail, getE2ETwitterInvitesByEmail,
getE2ETenantInvitesByLandlord,
isE2EEnabled, isE2EEnabled,
updateE2ETwitterInvite, updateE2ETwitterInvite,
} from "./e2eFixtures.ts"; } from "./e2eFixtures.ts";
@ -146,3 +148,28 @@ export async function removeTenantInviteEmail(inviteId: string, email: string) {
}); });
return { removed: true, invite: updated[0] ?? invite }; return { removed: true, invite: updated[0] ?? invite };
} }
export async function getTenantInvitesByLandlord(landlordId: string): Promise<TenantInvite[]> {
if (isE2EEnabled()) {
return getE2ETenantInvitesByLandlord(landlordId);
}
const result = await db
.select()
.from(tenant_invites)
.where(eq(tenant_invites.landlord_id, landlordId))
.orderBy(desc(tenant_invites.created_on));
return result;
}
export async function deleteTenantInvite(inviteId: string): Promise<void> {
if (isE2EEnabled()) {
deleteE2ETenantInvite(inviteId);
return;
}
await db
.delete(tenant_invites)
.where(eq(tenant_invites.id, inviteId));
}