feat(geoportal): one-time setup banner for PostGIS views

- GET /api/geoportal/setup-views checks if zoom views exist
- POST creates them (idempotent)
- SetupBanner component: auto-checks on mount, shows amber banner if
  views missing, button to create them, success message with docker
  restart reminder, auto-hides when everything is ready

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 08:05:22 +02:00
parent 7d2fe4ade0
commit 836d60b72f
3 changed files with 109 additions and 13 deletions
+24 -13
View File
@@ -1,9 +1,6 @@
/**
* POST /api/geoportal/setup-views
*
* Creates the zoom-dependent UAT views for Martin vector tiles.
* Safe to re-run (CREATE OR REPLACE VIEW).
* Original geometry in GisUat.geom is NEVER modified.
* GET /api/geoportal/setup-views — check if views exist
* POST /api/geoportal/setup-views — create views (idempotent)
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
@@ -26,25 +23,39 @@ const VIEWS = [
},
{
name: "gis_uats_z12",
sql: `CREATE OR REPLACE VIEW gis_uats_z12 AS SELECT siruta, name, county, ST_SimplifyPreserveTopology(geom, 10) AS geom FROM "GisUat" WHERE geom IS NOT NULL`,
sql: `CREATE OR REPLACE VIEW gis_uats_z12 AS SELECT siruta, name, county, geom FROM "GisUat" WHERE geom IS NOT NULL`,
},
];
/** GET — returns { ready: boolean, missing: string[] } */
export async function GET() {
try {
const existing = await prisma.$queryRaw`
SELECT viewname FROM pg_views
WHERE schemaname = 'public' AND viewname LIKE 'gis_uats_z%'
` as Array<{ viewname: string }>;
const existingNames = new Set(existing.map((r) => r.viewname));
const missing = VIEWS.filter((v) => !existingNames.has(v.name)).map((v) => v.name);
return NextResponse.json({ ready: missing.length === 0, missing });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ ready: false, missing: VIEWS.map((v) => v.name), error: msg });
}
}
/** POST — creates all views (idempotent) */
export async function POST() {
const results: string[] = [];
try {
for (const v of VIEWS) {
await prisma.$executeRawUnsafe(v.sql);
results.push(`${v.name} OK`);
}
return NextResponse.json({ status: "ok", results });
} catch (error) {
const msg = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ status: "error", results, error: msg },
{ status: 500 }
);
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
}
}
@@ -7,6 +7,7 @@ import { BasemapSwitcher } from "./basemap-switcher";
import { SearchBar } from "./search-bar";
import { SelectionToolbar, type SelectionMode } from "./selection-toolbar";
import { FeatureInfoPanel } from "./feature-info-panel";
import { SetupBanner } from "./setup-banner";
import type { MapViewerHandle } from "./map-viewer";
import type {
BasemapId, ClickedFeature, LayerVisibility, SearchResult, SelectedFeature,
@@ -70,6 +71,11 @@ export function GeoportalModule() {
zoom={flyTarget?.zoom}
/>
{/* Setup banner (auto-hides when ready) */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 w-auto max-w-lg">
<SetupBanner />
</div>
{/* Top-left: search + layers */}
<div className="absolute top-3 left-3 z-10 flex flex-col gap-2 max-w-xs">
<SearchBar onResultSelect={handleSearchResult} />
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { Database, Loader2, CheckCircle2, AlertTriangle } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
type Status = "checking" | "needed" | "running" | "done" | "error" | "ready";
export function SetupBanner() {
const [status, setStatus] = useState<Status>("checking");
const [missing, setMissing] = useState<string[]>([]);
const [error, setError] = useState("");
// Check on mount
useEffect(() => {
fetch("/api/geoportal/setup-views")
.then((r) => r.json())
.then((d: { ready: boolean; missing: string[] }) => {
if (d.ready) setStatus("ready");
else { setStatus("needed"); setMissing(d.missing); }
})
.catch(() => setStatus("ready")); // don't block if check fails
}, []);
const runSetup = async () => {
setStatus("running");
try {
const r = await fetch("/api/geoportal/setup-views", { method: "POST" });
const d = await r.json();
if (d.status === "ok") setStatus("done");
else { setStatus("error"); setError(d.error ?? "Eroare necunoscuta"); }
} catch (e) {
setStatus("error");
setError(e instanceof Error ? e.message : "Eroare retea");
}
};
// Don't show if ready or still checking
if (status === "ready" || status === "checking") return null;
// Auto-hide after success
if (status === "done") {
return (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-green-700 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" />
View-uri create cu succes. Restarteaza Martin pe server: <code className="bg-muted px-1 rounded">docker restart martin</code>
</div>
);
}
if (status === "error") {
return (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-red-700 dark:text-red-400">
<AlertTriangle className="h-4 w-4" />
Eroare: {error}
<Button variant="outline" size="sm" className="h-6 text-xs ml-auto" onClick={runSetup}>Reincearca</Button>
</div>
);
}
return (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs">
<Database className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-amber-700 dark:text-amber-300">
{missing.length} view-uri PostGIS lipsesc ({missing.join(", ")}). Necesare pentru tile-uri UAT optimizate.
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-xs ml-auto shrink-0"
onClick={runSetup}
disabled={status === "running"}
>
{status === "running" ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{status === "running" ? "Se creaza..." : "Creaza view-uri"}
</Button>
</div>
);
}