- 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
162 lines
4.2 KiB
TypeScript
162 lines
4.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|