From c4122cea014ce79be727cf96c1bcff67dcfde727 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 24 Mar 2026 10:48:08 +0200 Subject: [PATCH] feat(geoportal): enrichment API + CF download + bulk enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New API endpoints: - POST /api/geoportal/enrich — enriches all parcels for a SIRUTA, skips already-enriched, persists in GisFeature.enrichment column - GET /api/geoportal/cf-status?nrCad=... — checks if CF extract exists, returns download URL if available Feature panel: - No enrichment: "Enrichment" button (triggers eTerra sync for UAT) - Has enrichment + CF available: "Descarca CF" button (direct download) - Has enrichment + no CF: "Comanda CF" button (link to ePay tab) - Copy button always visible - After enrichment completes, panel auto-reloads data Selection toolbar: - Bulk "Enrichment" button for selected parcels (per unique SIRUTA) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/geoportal/cf-status/route.ts | 61 +++++++++ src/app/api/geoportal/enrich/route.ts | 54 ++++++++ .../components/feature-info-panel.tsx | 129 ++++++++++-------- .../components/selection-toolbar.tsx | 28 +++- 4 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 src/app/api/geoportal/cf-status/route.ts create mode 100644 src/app/api/geoportal/enrich/route.ts diff --git a/src/app/api/geoportal/cf-status/route.ts b/src/app/api/geoportal/cf-status/route.ts new file mode 100644 index 0000000..97fe689 --- /dev/null +++ b/src/app/api/geoportal/cf-status/route.ts @@ -0,0 +1,61 @@ +/** + * GET /api/geoportal/cf-status?nrCad=... + * + * Checks if a CF extract exists for a given cadastral number. + * Returns download info if available. + */ +import { NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const url = new URL(req.url); + const nrCad = url.searchParams.get("nrCad")?.trim(); + + if (!nrCad) { + return NextResponse.json({ error: "nrCad obligatoriu" }, { status: 400 }); + } + + try { + // Find the latest completed CF extract for this cadastral number + const extract = await prisma.cfExtract.findFirst({ + where: { + nrCadastral: nrCad, + status: "completed", + minioPath: { not: "" }, + }, + orderBy: { completedAt: "desc" }, + select: { + id: true, + nrCadastral: true, + nrCF: true, + status: true, + minioPath: true, + documentName: true, + completedAt: true, + expiresAt: true, + }, + }); + + if (!extract || !extract.minioPath) { + return NextResponse.json({ available: false }); + } + + const expired = extract.expiresAt && new Date(extract.expiresAt) < new Date(); + + return NextResponse.json({ + available: !expired, + expired: !!expired, + id: extract.id, + nrCF: extract.nrCF, + documentName: extract.documentName, + completedAt: extract.completedAt?.toISOString(), + downloadUrl: `/api/ancpi/download?id=${extract.id}`, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/geoportal/enrich/route.ts b/src/app/api/geoportal/enrich/route.ts new file mode 100644 index 0000000..8b724fc --- /dev/null +++ b/src/app/api/geoportal/enrich/route.ts @@ -0,0 +1,54 @@ +/** + * POST /api/geoportal/enrich + * + * Enriches parcels for a given SIRUTA. Skips already-enriched features. + * Uses existing eTerra session or env credentials. + * Enrichment data is PERSISTED in GisFeature.enrichment column. + * + * Body: { siruta: string } or { ids: string[] } (feature UUIDs) + */ +import { NextResponse } from "next/server"; +import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; +import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service"; +import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as { siruta?: string }; + const siruta = String(body.siruta ?? "").trim(); + + if (!siruta) { + return NextResponse.json({ error: "SIRUTA obligatoriu" }, { status: 400 }); + } + + // Get credentials: session first, then env + const session = getSessionCredentials(); + const username = session?.username || process.env.ETERRA_USERNAME || ""; + const password = session?.password || process.env.ETERRA_PASSWORD || ""; + + if (!username || !password) { + return NextResponse.json( + { error: "Credentiale eTerra lipsa. Logheaza-te in eTerra Parcele mai intai." }, + { status: 401 } + ); + } + + const client = await EterraClient.create(username, password); + const result = await enrichFeatures(client, siruta); + + return NextResponse.json({ + status: result.status, + enrichedCount: result.enrichedCount, + buildingCrossRefs: result.buildingCrossRefs, + message: result.enrichedCount > 0 + ? `${result.enrichedCount} parcele imbogatite cu succes` + : "Toate parcelele au deja date de enrichment", + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare la enrichment"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/modules/geoportal/components/feature-info-panel.tsx b/src/modules/geoportal/components/feature-info-panel.tsx index 871e91f..d2c9143 100644 --- a/src/modules/geoportal/components/feature-info-panel.tsx +++ b/src/modules/geoportal/components/feature-info-panel.tsx @@ -1,10 +1,12 @@ "use client"; import { useEffect, useState } from "react"; -import { X, Loader2, Sparkles, FileText, Download } from "lucide-react"; +import { X, Loader2, Sparkles, FileDown, Download, ClipboardCopy } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import type { ClickedFeature, FeatureDetail, FeatureEnrichmentData } from "../types"; +type CfStatus = { available: boolean; expired?: boolean; downloadUrl?: string; documentName?: string }; + type FeatureInfoPanelProps = { feature: ClickedFeature | null; onClose: () => void; @@ -15,9 +17,11 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) { const [loading, setLoading] = useState(false); const [enriching, setEnriching] = useState(false); const [enrichMsg, setEnrichMsg] = useState(""); + const [cfStatus, setCfStatus] = useState(null); + // Fetch feature detail useEffect(() => { - if (!feature) { setDetail(null); return; } + if (!feature) { setDetail(null); setCfStatus(null); return; } const objectId = feature.properties.object_id ?? feature.properties.objectId; const siruta = feature.properties.siruta; @@ -26,10 +30,23 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) { let cancelled = false; setLoading(true); setEnrichMsg(""); + setCfStatus(null); fetch(`/api/geoportal/feature?objectId=${objectId}&siruta=${siruta}&sourceLayer=${feature.sourceLayer}`) .then((r) => r.ok ? r.json() : Promise.reject()) - .then((data: { feature: FeatureDetail }) => { if (!cancelled) setDetail(data.feature); }) + .then((data: { feature: FeatureDetail }) => { + if (cancelled) return; + setDetail(data.feature); + // Check CF status if we have a cadastral ref + const e = data.feature.enrichment as FeatureEnrichmentData | null; + const nrCad = e?.NR_CAD ?? data.feature.cadastralRef; + if (nrCad) { + fetch(`/api/geoportal/cf-status?nrCad=${encodeURIComponent(nrCad)}`) + .then((r) => r.ok ? r.json() : null) + .then((cf: CfStatus | null) => { if (!cancelled && cf) setCfStatus(cf); }) + .catch(() => {}); + } + }) .catch(() => { if (!cancelled) setDetail(null); }) .finally(() => { if (!cancelled) setLoading(false); }); @@ -45,24 +62,32 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) { ? String(feature.properties.name ?? "UAT") : cadRef ? `Parcela ${cadRef}` : `#${feature.properties.object_id ?? "?"}`; const hasEnrichment = !!e && !!e.NR_CAD; - const nrCf = e?.NR_CF ?? ""; + const siruta = String(feature.properties.siruta ?? detail?.siruta ?? ""); const handleEnrich = async () => { - if (!detail?.siruta || !detail?.objectId) return; + if (!siruta) return; setEnriching(true); setEnrichMsg(""); try { - // Trigger enrichment for this specific parcel's UAT - const resp = await fetch("/api/eterra/enrich", { + const resp = await fetch("/api/geoportal/enrich", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ siruta: detail.siruta, featureId: detail.id }), + body: JSON.stringify({ siruta }), }); + const d = await resp.json(); if (resp.ok) { - setEnrichMsg("Enrichment pornit. Reincarca pagina dupa cateva secunde."); + setEnrichMsg(d.message ?? "Enrichment finalizat"); + // Reload feature detail after enrichment + const objectId = feature.properties.object_id ?? feature.properties.objectId; + if (objectId) { + const r = await fetch(`/api/geoportal/feature?objectId=${objectId}&siruta=${siruta}&sourceLayer=${feature.sourceLayer}`); + if (r.ok) { + const data = await r.json(); + setDetail(data.feature); + } + } } else { - const d = await resp.json().catch(() => null); - setEnrichMsg((d && typeof d === "object" && "error" in d) ? String((d as { error: string }).error) : "Eroare la enrichment"); + setEnrichMsg(d.error ?? "Eroare la enrichment"); } } catch { setEnrichMsg("Eroare retea"); @@ -71,6 +96,17 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) { } }; + const handleCopy = () => { + const text = [ + cadRef && `Nr. cad: ${cadRef}`, + e?.NR_CF && `CF: ${e.NR_CF}`, + (e?.SUPRAFATA_2D ?? feature.properties.area_value) && `S: ${e?.SUPRAFATA_2D ?? feature.properties.area_value} mp`, + e?.PROPRIETARI && e.PROPRIETARI !== "-" && `Prop: ${e.PROPRIETARI}`, + siruta && `SIRUTA: ${siruta}`, + ].filter(Boolean).join("\n"); + navigator.clipboard.writeText(text); + }; + return (
{/* Header */} @@ -81,7 +117,6 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) {
- {/* Content */}
{loading && (
@@ -99,22 +134,16 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) { {!loading && !isUat && ( <> - + - + {hasEnrichment && ( <> - {e?.PROPRIETARI && e.PROPRIETARI !== "-" && ( - - )} - {e?.INTRAVILAN && e.INTRAVILAN !== "-" && ( - - )} - {e?.CATEGORIE_FOLOSINTA && e.CATEGORIE_FOLOSINTA !== "-" && ( - - )} + {e?.PROPRIETARI && e.PROPRIETARI !== "-" && } + {e?.INTRAVILAN && e.INTRAVILAN !== "-" && } + {e?.CATEGORIE_FOLOSINTA && e.CATEGORIE_FOLOSINTA !== "-" && } )} @@ -122,50 +151,42 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) {
{!hasEnrichment && ( )} - {hasEnrichment && nrCf && ( + {cfStatus?.available && cfStatus.downloadUrl && ( + )} + + {hasEnrichment && !cfStatus?.available && ( + )}
diff --git a/src/modules/geoportal/components/selection-toolbar.tsx b/src/modules/geoportal/components/selection-toolbar.tsx index db00c04..2abced6 100644 --- a/src/modules/geoportal/components/selection-toolbar.tsx +++ b/src/modules/geoportal/components/selection-toolbar.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Download, Trash2, MousePointerClick, Square, PenTool, Loader2 } from "lucide-react"; +import { Download, Trash2, MousePointerClick, Square, PenTool, Loader2, Sparkles } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { DropdownMenu, @@ -42,6 +42,7 @@ export function SelectionToolbar({ className, }: SelectionToolbarProps) { const [exporting, setExporting] = useState(false); + const [enriching, setEnriching] = useState(false); const active = selectionMode !== "off"; const handleExport = async (format: ExportFormat) => { @@ -136,6 +137,31 @@ export function SelectionToolbar({ + +