← back to Stayclaim
src/app/parcel/[apn]/page.tsx
264 lines
import Link from 'next/link';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { pool } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { SourceLink } from '@/components/SourceLink';
import { PermitTable, CodeEnforcementTable } from '@/components/PermitTable';
import type { RealPermit, CodeEnforcementCase } from '@/lib/db';
export const dynamic = 'force-dynamic';
function normalizeApn(input: string): string {
return input.replace(/[^0-9]/g, '');
}
function formatApn(apn: string): string {
// 4349040002 → 4349-040-002 (LA County standard)
if (apn.length !== 10) return apn;
return `${apn.slice(0, 4)}-${apn.slice(4, 7)}-${apn.slice(7, 10)}`;
}
export async function generateMetadata({ params }: { params: { apn: string } }): Promise<Metadata> {
const apn = normalizeApn(params.apn);
if (apn.length !== 10) {
return {
title: 'Parcel not found',
robots: { index: false, follow: false },
};
}
return {
title: `Parcel ${formatApn(apn)}`,
description: `LA County parcel ${formatApn(apn)} — assessor record, permits, code enforcement.`,
alternates: { canonical: `/parcel/${formatApn(apn)}` },
robots: { index: true, follow: true },
};
}
type ParcelRow = {
id: string;
apn: string;
jurisdiction: string | null;
situs_full: string | null;
city: string | null;
zip_code: string | null;
use_code: string | null;
use_desc: string | null;
year_built: number | null;
bedrooms: number | null;
bathrooms: string | null;
bldg_sqft: number | null;
assessed_total: string | null;
assessed_land: string | null;
assessed_improvements: string | null;
roll_year: number | null;
latitude: string | null;
longitude: string | null;
current_listing_id: string | null;
source_url: string | null;
};
async function getParcel(apn: string): Promise<ParcelRow[]> {
const { rows } = await pool.query<ParcelRow>(
`SELECT id, apn, jurisdiction, situs_full, city, zip_code, use_code, use_desc,
year_built, bedrooms, bathrooms::text as bathrooms, bldg_sqft,
assessed_total::text as assessed_total,
assessed_land::text as assessed_land,
assessed_improvements::text as assessed_improvements,
roll_year, latitude::text as latitude, longitude::text as longitude,
current_listing_id, source_url
FROM parcel
WHERE apn = $1
ORDER BY roll_year DESC NULLS LAST, id DESC`,
[apn]
);
return rows;
}
async function getPermitsForApn(apn: string): Promise<RealPermit[]> {
// Wrap DISTINCT ON in subquery: pick latest row per permit_number first,
// THEN sort the deduped set by issue_date DESC and cap at 100. Without the
// subquery, LIMIT 100 takes the alphabetically-lowest permit numbers, so
// recent permits silently disappear for parcels with >100 historical permits.
const { rows } = await pool.query<RealPermit>(
`SELECT * FROM (
SELECT DISTINCT ON (permit_number)
id, source, source_dataset, permit_number, primary_address,
permit_group, permit_type, permit_sub_type, use_desc,
issue_date::text as issue_date, status_desc, valuation::text as valuation,
work_desc, source_url, source_label
FROM permit
WHERE apn = $1
ORDER BY permit_number, issue_date DESC NULLS LAST
) s
ORDER BY issue_date DESC NULLS LAST
LIMIT 100`,
[apn]
);
return rows;
}
async function getCodeForApn(apn: string): Promise<CodeEnforcementCase[]> {
const { rows } = await pool.query<CodeEnforcementCase>(
`SELECT id, source_dataset, case_number, case_type, status,
opened_at::text as opened_at, source_url, source_label
FROM code_enforcement_case
WHERE apn = $1
ORDER BY opened_at DESC NULLS LAST
LIMIT 25`,
[apn]
);
return rows;
}
function fmt$(s: string | null): string {
if (!s) return '—';
const n = Number(s);
if (!Number.isFinite(n) || n === 0) return '—';
return `$${n.toLocaleString()}`;
}
export default async function ParcelPage({ params }: { params: { apn: string } }) {
const apnRaw = params.apn;
const apn = normalizeApn(apnRaw);
if (apn.length !== 10) notFound();
const [parcels, permits, codeCases] = await Promise.all([
getParcel(apn).catch(e => { console.error('parcel.getParcel', apn, e); return [] as ParcelRow[]; }),
getPermitsForApn(apn).catch(e => { console.error('parcel.getPermits', apn, e); return [] as RealPermit[]; }),
getCodeForApn(apn).catch(e => { console.error('parcel.getCode', apn, e); return [] as CodeEnforcementCase[]; }),
]);
const latest = parcels[0] ?? null;
const assessorPortalUrl = `https://portal.assessor.lacounty.gov/parceldetail/${apn}`;
const formattedApn = formatApn(apn);
const inDb = !!latest;
return (
<>
<div className="max-w-6xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Parcel', href: '/search?kind=parcel' }, { label: formattedApn }]} />
</div>
<header className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
<p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">LA County parcel</p>
<h1 className="font-display text-5xl md:text-7xl text-ink tracking-[-0.02em] leading-[1.0] tabular-nums">
{formattedApn}
</h1>
{latest?.situs_full ? (
<p className="mt-4 font-display italic text-2xl text-ink/70">{latest.situs_full}</p>
) : !inDb ? (
<p className="mt-4 font-display italic text-xl text-ink/55">
APN not in our 2021-Present roll snapshot. View the canonical record on the Assessor portal →
</p>
) : null}
<div className="mt-6">
<SourceLink
label="LA County Assessor Property Detail"
url={assessorPortalUrl}
tier="A"
/>
</div>
</header>
{inDb && latest && (
<section className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
<h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-5">
Latest roll record <span className="font-mono text-sm text-ink/45 ml-2">({latest.roll_year ?? '—'})</span>
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Fact label="Year built" value={latest.year_built ?? '—'} />
<Fact label="Bldg sqft" value={latest.bldg_sqft != null ? latest.bldg_sqft.toLocaleString() : '—'} />
<Fact label="Bedrooms" value={latest.bedrooms ?? '—'} />
<Fact label="Bathrooms" value={latest.bathrooms ?? '—'} />
<Fact label="City" value={latest.city ?? '—'} />
<Fact label="ZIP" value={latest.zip_code ?? '—'} />
<Fact label="Use code" value={latest.use_code ?? '—'} />
<Fact label="Use" value={latest.use_desc ?? '—'} />
<Fact label="Assessed total" value={fmt$(latest.assessed_total)} highlight />
<Fact label="Land value" value={fmt$(latest.assessed_land)} />
<Fact label="Improvements" value={fmt$(latest.assessed_improvements)} />
<Fact label="Jurisdiction" value={latest.jurisdiction ?? '—'} />
</div>
</section>
)}
{parcels.length > 1 && (
<section className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
<h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-5">
Roll history <span className="font-mono text-sm text-ink/45 ml-2">{parcels.length} years</span>
</h2>
<div className="bg-white border border-ink/10 overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-ink/[0.04] text-[10px] uppercase tracking-[0.15em] text-ink/55">
<tr>
<th className="text-left p-3">roll year</th>
<th className="text-right p-3">total AV</th>
<th className="text-right p-3">land</th>
<th className="text-right p-3">improvements</th>
<th className="text-left p-3">use</th>
</tr>
</thead>
<tbody>
{parcels.map(p => (
<tr key={p.id} className="border-t border-ink/5">
<td className="p-3 font-mono">{p.roll_year ?? '—'}</td>
<td className="p-3 text-right tabular-nums font-mono">{fmt$(p.assessed_total)}</td>
<td className="p-3 text-right tabular-nums font-mono text-ink/65">{fmt$(p.assessed_land)}</td>
<td className="p-3 text-right tabular-nums font-mono text-ink/65">{fmt$(p.assessed_improvements)}</td>
<td className="p-3 text-ink/65">{p.use_desc ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<PermitTable permits={permits} />
<CodeEnforcementTable cases={codeCases} />
<section className="max-w-6xl mx-auto px-6 py-12 border-t border-ink/10">
<h2 className="font-display text-2xl text-ink tracking-[-0.01em] mb-3">External records</h2>
<ul className="space-y-2 text-sm">
<li>
<a href={assessorPortalUrl} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
Assessor Property Detail (PAIS) ↗
</a>
<span className="text-ink/45 ml-2">— official current value, owner, characteristics</span>
</li>
<li>
<a href={`https://lavote.gov/home/recorder/online-public-records-search?apn=${apn}`} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
LA Recorder/County Clerk index ↗
</a>
<span className="text-ink/45 ml-2">— deeds, liens (in-person index only, free)</span>
</li>
<li>
<a href={`https://zimas.lacity.org/?address=&apn=${apn}`} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
ZIMAS zoning lookup ↗
</a>
<span className="text-ink/45 ml-2">— LA City zoning, historic flags, planning cases</span>
</li>
<li>
<a href={`https://www.ladbsservices2.lacity.org/OnlineServices/PermitReport/PermitReport?apn=${apn}`} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
LADBS Permit Report ↗
</a>
<span className="text-ink/45 ml-2">— LA City permits, inspections, complaints</span>
</li>
</ul>
</section>
</>
);
}
function Fact({ label, value, highlight = false }: { label: string; value: string | number; highlight?: boolean }) {
return (
<div className="border-l-2 border-ink/10 pl-3">
<p className="text-[10px] uppercase tracking-[0.15em] text-ink/50">{label}</p>
<p className={`font-display ${highlight ? 'text-2xl text-coral' : 'text-xl text-ink'} mt-1 tabular-nums`}>
{value}
</p>
</div>
);
}