← 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>
  );
}