Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
4.9 KiB
TypeScript
114 lines
4.9 KiB
TypeScript
import { notFound, redirect } from "next/navigation"
|
|
import { and, eq } from "drizzle-orm"
|
|
import { db } from "@/lib/db"
|
|
import { maintenance_requests } from "@/lib/db/schema"
|
|
import { getSessionUser } from "@/lib/session"
|
|
import { getEffectiveOwnerId } from "@/lib/account"
|
|
import Link from "next/link"
|
|
import { formatCurrency, formatDate } from "@/lib/utils"
|
|
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
|
|
import { MaintenanceStatusUpdater } from "@/components/forms/maintenance-status-updater"
|
|
|
|
export default async function MaintenanceDetailPage({ params }: { params: Promise<{ requestId: string }> }) {
|
|
const user = await getSessionUser()
|
|
if (!user) redirect("/login")
|
|
|
|
const ownerId = await getEffectiveOwnerId(user.id)
|
|
|
|
const { requestId } = await params
|
|
|
|
const req = await db.query.maintenance_requests.findFirst({
|
|
where: and(
|
|
eq(maintenance_requests.id, requestId),
|
|
eq(maintenance_requests.user_id, ownerId)
|
|
),
|
|
with: {
|
|
property: { columns: { name: true, address_line1: true, city: true } },
|
|
unit: { columns: { unit_number: true } },
|
|
tenant: { columns: { first_name: true, last_name: true, email: true, phone: true } },
|
|
},
|
|
})
|
|
|
|
if (!req) notFound()
|
|
const request = req as any
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl space-y-6">
|
|
{/* Breadcrumb */}
|
|
<div className="flex items-center gap-2 text-sm text-white/40">
|
|
<Link href="/maintenance" className="hover:text-white transition">Maintenance</Link>
|
|
<span>/</span>
|
|
<span className="text-white/70 truncate">{request.title}</span>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h2 className="text-xl font-bold text-white">{request.title}</h2>
|
|
<PriorityBadge priority={request.priority} />
|
|
</div>
|
|
<p className="mt-1 text-sm text-white/40">
|
|
{request.property?.name}{request.unit ? ` · Unit ${request.unit.unit_number}` : ""} · Opened {formatDate(request.created_at)}
|
|
</p>
|
|
</div>
|
|
<MaintenanceStatusBadge status={request.status} />
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
{/* Main details */}
|
|
<div className="lg:col-span-2 space-y-5">
|
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
|
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-white/30">Description</h3>
|
|
<p className="text-sm text-white/80 whitespace-pre-wrap">{request.description}</p>
|
|
|
|
{request.resolution_notes && (
|
|
<>
|
|
<h3 className="mb-2 mt-5 text-xs font-medium uppercase tracking-wider text-white/30">Resolution Notes</h3>
|
|
<p className="text-sm text-white/80 whitespace-pre-wrap">{request.resolution_notes}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status updater */}
|
|
<MaintenanceStatusUpdater request={req} />
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-4">
|
|
{/* Details card */}
|
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
|
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Details</h3>
|
|
<Row label="Category" value={request.category} />
|
|
<Row label="Priority" value={request.priority} />
|
|
<Row label="Status" value={request.status.replace("_", " ")} />
|
|
{request.assigned_to && <Row label="Assigned To" value={request.assigned_to} />}
|
|
{request.estimated_cost && <Row label="Est. Cost" value={formatCurrency(request.estimated_cost)} />}
|
|
{request.actual_cost && <Row label="Actual Cost" value={formatCurrency(request.actual_cost)} />}
|
|
{request.resolved_at && <Row label="Resolved" value={formatDate(request.resolved_at)} />}
|
|
</div>
|
|
|
|
{/* Tenant card */}
|
|
{request.tenant && (
|
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
|
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Tenant</h3>
|
|
<p className="text-sm font-medium text-white">{request.tenant.first_name} {request.tenant.last_name}</p>
|
|
{request.tenant.email && <p className="text-xs text-white/40">{request.tenant.email}</p>}
|
|
{request.tenant.phone && <p className="text-xs text-white/40">{request.tenant.phone}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-white/40">{label}</span>
|
|
<span className="text-xs font-medium text-white capitalize">{value}</span>
|
|
</div>
|
|
)
|
|
}
|