gendesign/frontend/src/components/site-finder/NspdOpportunityBlock.tsx
lekss361 ed3c128528 feat(nspd): TIER 4 opportunity layers + red lines (#94 PR2 of 4)
- NSPDClient: QUARTER_OPPORTUNITY_LAYERS (auction/scheme/free/future/oopt)
  + QuarterDump.opportunity field
- nspd_sync: harvest_quarter accepts include_opportunity, denorm cols
  has_auction_parcels + opportunity_count in UPSERT
- quarter_dump_lookup:
  - _get_opportunity_parcels (sort by distance, early-exit on count=0)
  - _get_red_lines (query existing dump.red_lines core path, layer='red_lines')
- SQL 89_*: has_auction_parcels + opportunity_count + partial index
- Pydantic: OpportunityParcel + RedLine schemas
- /analyze: nspd_opportunity_parcels + nspd_red_lines fields
- Frontend: NspdOpportunityBlock + NspdRedLinesBlock + LandTab integration
- Tests: 28 total (11 PR1 + 17 PR2), all pass

Red lines uses existing core harvest path (layer 879243 already in dump.red_lines
from PR1+core) — no duplicate harvest, no red_lines_count_v2 column needed.

Part of #94
2026-05-16 19:06:22 +03:00

162 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type { OpportunityParcel } from "@/types/nspd";
interface Props {
opportunityParcels: OpportunityParcel[] | null | undefined;
}
type LayerKey = OpportunityParcel["layer"];
const LAYER_CONFIG: Record<
LayerKey,
{ label: string; bg: string; badgeBg: string; color: string }
> = {
auction_parcels: {
label: "На аукционе",
bg: "#fff7ed",
badgeBg: "#fed7aa",
color: "#c2410c",
},
scheme_parcels: {
label: "Схема расположения",
bg: "#eff6ff",
badgeBg: "#bfdbfe",
color: "#1d4ed8",
},
free_parcels: {
label: "Свободный от прав",
bg: "#f0fdf4",
badgeBg: "#bbf7d0",
color: "#15803d",
},
future_parcels: {
label: "Планируемый (проект межевания)",
bg: "#f9fafb",
badgeBg: "#e5e7eb",
color: "#374151",
},
oopt: {
label: "ООПТ",
bg: "#faf5ff",
badgeBg: "#e9d5ff",
color: "#7e22ce",
},
};
function formatDistance(m: number | null): string {
if (m === null) return "";
if (m < 1000) return `${Math.round(m)} м`;
return `${(m / 1000).toFixed(1)} км`;
}
function buildNspdViewerUrl(cadNum: string): string {
return `https://nspd.gov.ru/map?cadastralNumber=${encodeURIComponent(cadNum)}`;
}
export function NspdOpportunityBlock({ opportunityParcels }: Props) {
const parcels = opportunityParcels ?? [];
if (parcels.length === 0) {
return null;
}
// Group by layer type
const grouped = parcels.reduce<Record<LayerKey, OpportunityParcel[]>>(
(acc, p) => {
const key = p.layer as LayerKey;
if (!acc[key]) acc[key] = [];
acc[key].push(p);
return acc;
},
{} as Record<LayerKey, OpportunityParcel[]>,
);
// Stable display order: auction first (highest priority)
const layerOrder: LayerKey[] = [
"auction_parcels",
"free_parcels",
"scheme_parcels",
"future_parcels",
"oopt",
];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{layerOrder.map((layerKey) => {
const items = grouped[layerKey];
if (!items || items.length === 0) return null;
const cfg = LAYER_CONFIG[layerKey];
return (
<div
key={layerKey}
style={{
background: cfg.bg,
border: `1px solid ${cfg.badgeBg}`,
borderRadius: 8,
padding: "10px 14px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: items.length > 1 ? 8 : 0,
}}
>
<span
style={{
background: cfg.badgeBg,
color: cfg.color,
borderRadius: 5,
padding: "2px 10px",
fontSize: 12,
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{cfg.label}
</span>
<span style={{ fontSize: 13, color: "#4b5563" }}>
{items.length} уч.
</span>
</div>
{items.map((p, idx) => (
<div
key={idx}
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 6,
fontSize: 13,
}}
>
{p.cad_num ? (
<a
href={buildNspdViewerUrl(p.cad_num)}
target="_blank"
rel="noopener noreferrer"
style={{ color: cfg.color, textDecoration: "underline" }}
>
{p.cad_num}
</a>
) : (
<span style={{ color: "#6b7280" }}></span>
)}
{p.distance_m !== null && (
<span style={{ color: "#6b7280" }}>
{formatDistance(p.distance_m)}
</span>
)}
</div>
))}
</div>
);
})}
</div>
);
}