refactor(parcel-sync): split 4800-line module into 9 files + Harta tab + enrichment views
Split parcel-sync-module.tsx (4800 lines) into modular files: - Orchestrator (452 lines): shared state (session, UAT, sync) + tab routing - Types + helpers, ConnectionPill, 6 tab components (search, layers, export, database, cf, map) New ParcelSync Harta tab: - UAT-scoped map: zoom to extent, filter parcels/buildings by siruta - Data-driven styling via gis_terenuri_status enrichment overlay (green=no enrichment, dark green=enriched, blue outline=building, red=no legal docs) - Reuses Geoportal components (MapViewer, SelectionToolbar, FeatureInfoPanel, BasemapSwitcher) - Export DXF/GPKG for selection, legend New PostGIS views (gis_terenuri_status, gis_cladiri_status): - has_enrichment, has_building, build_legal columns from enrichment JSON - Auto-created via /api/geoportal/setup-enrichment-views - Does not modify existing Geoportal views New API: /api/geoportal/uat-bounds (WGS84 bbox from PostGIS geometry) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/geoportal/setup-enrichment-views — check if views exist
|
||||||
|
* POST /api/geoportal/setup-enrichment-views — create enrichment status views
|
||||||
|
*
|
||||||
|
* Creates gis_terenuri_status and gis_cladiri_status views that include
|
||||||
|
* enrichment metadata (has_enrichment, has_building, build_legal).
|
||||||
|
* Martin serves these as vector tile sources, MapLibre uses them for
|
||||||
|
* data-driven styling in the ParcelSync Harta tab.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Does NOT modify existing gis_terenuri/gis_cladiri views
|
||||||
|
* used by the Geoportal module.
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const VIEWS = [
|
||||||
|
{
|
||||||
|
name: "gis_terenuri_status",
|
||||||
|
sql: `CREATE OR REPLACE VIEW gis_terenuri_status AS
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f."layerId" AS layer_id,
|
||||||
|
f.siruta,
|
||||||
|
f."objectId" AS object_id,
|
||||||
|
f."cadastralRef" AS cadastral_ref,
|
||||||
|
f."areaValue" AS area_value,
|
||||||
|
f."isActive" AS is_active,
|
||||||
|
CASE WHEN f.enrichment IS NOT NULL AND f."enrichedAt" IS NOT NULL THEN 1 ELSE 0 END AS has_enrichment,
|
||||||
|
COALESCE((f.enrichment->>'HAS_BUILDING')::int, 0) AS has_building,
|
||||||
|
COALESCE((f.enrichment->>'BUILD_LEGAL')::int, 0) AS build_legal,
|
||||||
|
f.geom
|
||||||
|
FROM "GisFeature" f
|
||||||
|
WHERE f.geom IS NOT NULL
|
||||||
|
AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gis_cladiri_status",
|
||||||
|
sql: `CREATE OR REPLACE VIEW gis_cladiri_status AS
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f."layerId" AS layer_id,
|
||||||
|
f.siruta,
|
||||||
|
f."objectId" AS object_id,
|
||||||
|
f."cadastralRef" AS cadastral_ref,
|
||||||
|
f."areaValue" AS area_value,
|
||||||
|
f."isActive" AS is_active,
|
||||||
|
f.geom
|
||||||
|
FROM "GisFeature" f
|
||||||
|
WHERE f.geom IS NOT NULL
|
||||||
|
AND (f."layerId" LIKE 'CLADIRI%' OR f."layerId" LIKE 'CADGEN_BUILDING%')`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** GET — check if enrichment views exist */
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.$queryRaw`
|
||||||
|
SELECT viewname FROM pg_views
|
||||||
|
WHERE schemaname = 'public' AND (viewname = 'gis_terenuri_status' OR viewname = 'gis_cladiri_status')
|
||||||
|
` 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 — create enrichment 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 : "Eroare";
|
||||||
|
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/geoportal/uat-bounds?siruta=57582
|
||||||
|
*
|
||||||
|
* Returns WGS84 bounding box for a UAT from PostGIS geometry.
|
||||||
|
* Used by ParcelSync Harta tab to zoom to selected UAT.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const siruta = req.nextUrl.searchParams.get("siruta");
|
||||||
|
if (!siruta) {
|
||||||
|
return NextResponse.json({ error: "siruta required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await prisma.$queryRaw`
|
||||||
|
SELECT
|
||||||
|
ST_XMin(ST_Transform(geom, 4326)) AS min_lng,
|
||||||
|
ST_YMin(ST_Transform(geom, 4326)) AS min_lat,
|
||||||
|
ST_XMax(ST_Transform(geom, 4326)) AS max_lng,
|
||||||
|
ST_YMax(ST_Transform(geom, 4326)) AS max_lat
|
||||||
|
FROM "GisUat"
|
||||||
|
WHERE siruta = ${siruta} AND geom IS NOT NULL
|
||||||
|
LIMIT 1
|
||||||
|
` as Array<{
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const first = rows[0];
|
||||||
|
if (!first) {
|
||||||
|
return NextResponse.json({ error: "UAT not found or no geometry" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
siruta,
|
||||||
|
bounds: [
|
||||||
|
[first.min_lng, first.min_lat],
|
||||||
|
[first.max_lng, first.max_lat],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { SessionStatus } from "./parcel-sync-types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Connection Status Pill */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function ConnectionPill({
|
||||||
|
session,
|
||||||
|
connecting,
|
||||||
|
connectionError,
|
||||||
|
onDisconnect,
|
||||||
|
}: {
|
||||||
|
session: SessionStatus;
|
||||||
|
connecting: boolean;
|
||||||
|
connectionError: string;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}) {
|
||||||
|
const elapsed = session.connectedAt
|
||||||
|
? Math.floor(
|
||||||
|
(Date.now() - new Date(session.connectedAt).getTime()) / 60_000,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const elapsedLabel =
|
||||||
|
elapsed < 1
|
||||||
|
? "acum"
|
||||||
|
: elapsed < 60
|
||||||
|
? `${elapsed} min`
|
||||||
|
: `${Math.floor(elapsed / 60)}h ${elapsed % 60}m`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all",
|
||||||
|
"hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
session.connected
|
||||||
|
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
|
||||||
|
: session.eterraMaintenance
|
||||||
|
? "border-amber-200 bg-amber-50/80 text-amber-600 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-400"
|
||||||
|
: connectionError
|
||||||
|
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
|
||||||
|
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{connecting ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : session.connected ? (
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
) : session.eterraMaintenance ? (
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
) : connectionError ? (
|
||||||
|
<WifiOff className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Wifi className="h-3 w-3 opacity-50" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{connecting
|
||||||
|
? "Se conecteaz\u0103\u2026"
|
||||||
|
: session.connected
|
||||||
|
? "eTerra"
|
||||||
|
: session.eterraMaintenance
|
||||||
|
? "Mentenan\u021b\u0103"
|
||||||
|
: connectionError
|
||||||
|
? "Eroare"
|
||||||
|
: "Deconectat"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="w-72 p-0">
|
||||||
|
{/* Status header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2.5 border-b",
|
||||||
|
session.connected
|
||||||
|
? "bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||||
|
: "bg-muted/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DropdownMenuLabel className="p-0 text-xs font-semibold">
|
||||||
|
Conexiune eTerra
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{session.connected && (
|
||||||
|
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-mono">
|
||||||
|
{elapsedLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{session.connected && session.username && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||||
|
{session.username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{connectionError && (
|
||||||
|
<p className="text-[11px] text-rose-500 mt-0.5">
|
||||||
|
{connectionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maintenance banner */}
|
||||||
|
{!session.connected && session.eterraMaintenance && (
|
||||||
|
<div className="px-3 py-3 text-xs border-b bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-amber-700 dark:text-amber-400">
|
||||||
|
eTerra este \u00een mentenan\u021b\u0103
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-amber-600/80 dark:text-amber-400/70">
|
||||||
|
Platforma ANCPI nu este disponibil\u0103 momentan. Conectarea va fi
|
||||||
|
reactivat\u0103 automat c\u00e2nd serviciul revine online.
|
||||||
|
</p>
|
||||||
|
{session.eterraHealthMessage && (
|
||||||
|
<p className="mt-1 text-[10px] opacity-60 font-mono">
|
||||||
|
{session.eterraHealthMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info when not connected (and not in maintenance) */}
|
||||||
|
{!session.connected &&
|
||||||
|
!connectionError &&
|
||||||
|
!session.eterraMaintenance && (
|
||||||
|
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||||
|
<p>Conexiunea se face automat c\u00e2nd \u00eencepi s\u0103 scrii un UAT.</p>
|
||||||
|
<p className="mt-1 text-[11px] opacity-70">
|
||||||
|
Creden\u021bialele sunt preluate din configurarea serverului.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error detail (only when NOT maintenance) */}
|
||||||
|
{!session.connected &&
|
||||||
|
connectionError &&
|
||||||
|
!session.eterraMaintenance && (
|
||||||
|
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Conexiunea automat\u0103 a e\u0219uat. Verific\u0103 creden\u021bialele din
|
||||||
|
variabilele de mediu (ETERRA_USERNAME / ETERRA_PASSWORD).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connected — active jobs info + disconnect */}
|
||||||
|
{session.connected && (
|
||||||
|
<>
|
||||||
|
{session.activeJobCount > 0 && (
|
||||||
|
<div className="px-3 py-2 border-b bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
<p className="text-[11px] text-amber-700 dark:text-amber-400">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{session.activeJobCount} job
|
||||||
|
{session.activeJobCount > 1 ? "-uri" : ""} activ
|
||||||
|
{session.activeJobCount > 1 ? "e" : ""}
|
||||||
|
</span>
|
||||||
|
{session.activeJobPhase && (
|
||||||
|
<span className="opacity-70">
|
||||||
|
{" "}
|
||||||
|
\u2014 {session.activeJobPhase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator className="m-0" />
|
||||||
|
<div className="p-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
|
onClick={onDisconnect}
|
||||||
|
>
|
||||||
|
<LogOut className="h-3 w-3" />
|
||||||
|
Deconectare
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
|||||||
|
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||||
|
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type UatEntry = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county?: string;
|
||||||
|
workspacePk?: number;
|
||||||
|
localFeatures?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionStatus = {
|
||||||
|
connected: boolean;
|
||||||
|
username?: string;
|
||||||
|
connectedAt?: string;
|
||||||
|
activeJobCount: number;
|
||||||
|
activeJobPhase?: string;
|
||||||
|
/** eTerra platform health */
|
||||||
|
eterraAvailable?: boolean;
|
||||||
|
/** True when eTerra is in maintenance */
|
||||||
|
eterraMaintenance?: boolean;
|
||||||
|
/** Human-readable health message */
|
||||||
|
eterraHealthMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportProgress = {
|
||||||
|
jobId: string;
|
||||||
|
downloaded: number;
|
||||||
|
total?: number;
|
||||||
|
status: "running" | "done" | "error" | "unknown";
|
||||||
|
phase?: string;
|
||||||
|
message?: string;
|
||||||
|
note?: string;
|
||||||
|
phaseCurrent?: number;
|
||||||
|
phaseTotal?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncRunInfo = {
|
||||||
|
id: string;
|
||||||
|
layerId: string;
|
||||||
|
status: string;
|
||||||
|
totalRemote: number;
|
||||||
|
totalLocal: number;
|
||||||
|
newFeatures: number;
|
||||||
|
removedFeatures: number;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DbUatSummary = {
|
||||||
|
siruta: string;
|
||||||
|
uatName: string;
|
||||||
|
county: string | null;
|
||||||
|
layers: {
|
||||||
|
layerId: string;
|
||||||
|
count: number;
|
||||||
|
enrichedCount: number;
|
||||||
|
noGeomCount: number;
|
||||||
|
lastSynced: string | null;
|
||||||
|
}[];
|
||||||
|
totalFeatures: number;
|
||||||
|
totalEnriched: number;
|
||||||
|
totalNoGeom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DbSummary = {
|
||||||
|
uats: DbUatSummary[];
|
||||||
|
totalFeatures: number;
|
||||||
|
totalUats: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const normalizeText = (text: string) =>
|
||||||
|
text
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
export function formatDate(iso?: string | null) {
|
||||||
|
if (!iso) return "\u2014";
|
||||||
|
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatArea(val?: number | null) {
|
||||||
|
if (val == null) return "\u2014";
|
||||||
|
return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format ISO date as DD.MM.YYYY (no time) */
|
||||||
|
export function formatShortDate(iso?: string | null) {
|
||||||
|
if (!iso) return "\u2014";
|
||||||
|
const d = new Date(iso);
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeTime(date: Date | null) {
|
||||||
|
if (!date) return "niciodat\u0103";
|
||||||
|
const mins = Math.floor((Date.now() - date.getTime()) / 60_000);
|
||||||
|
if (mins < 1) return "acum";
|
||||||
|
if (mins < 60) return `acum ${mins} min`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `acum ${hours}h`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `acum ${days}z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-export for convenience */
|
||||||
|
export type { ParcelDetail, OwnerSearchResult };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EpayTab } from "../epay-tab";
|
||||||
|
|
||||||
|
export function CfTab() {
|
||||||
|
return <EpayTab />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Database,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { DbSummary } from "../parcel-sync-types";
|
||||||
|
import { relativeTime } from "../parcel-sync-types";
|
||||||
|
import {
|
||||||
|
LAYER_CATEGORY_LABELS,
|
||||||
|
type LayerCategory,
|
||||||
|
findLayerById,
|
||||||
|
} from "../../services/eterra-layers";
|
||||||
|
import { UatDashboard } from "../uat-dashboard";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Props */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type DatabaseTabProps = {
|
||||||
|
siruta: string;
|
||||||
|
sirutaValid: boolean;
|
||||||
|
dbSummary: DbSummary | null;
|
||||||
|
dbSummaryLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function DatabaseTab({
|
||||||
|
siruta,
|
||||||
|
sirutaValid,
|
||||||
|
dbSummary,
|
||||||
|
dbSummaryLoading,
|
||||||
|
onRefresh,
|
||||||
|
}: DatabaseTabProps) {
|
||||||
|
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/* ── Loading state ──────────────────────────────────────────────── */
|
||||||
|
if (dbSummaryLoading && !dbSummary) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
|
||||||
|
<p>Se \u00eencarc\u0103 datele din baza de date\u2026</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────────────── */
|
||||||
|
if (!dbSummary || dbSummary.totalFeatures === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<Database className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="font-medium">Nicio dat\u0103 \u00een baza de date</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Folose\u0219te tab-ul Export pentru a sincroniza date din eTerra.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Populated state ────────────────────────────────────────────── */
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{dbSummary.totalFeatures.toLocaleString("ro-RO")} entit\u0103\u021bi
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
din {dbSummary.totalUats} UAT-uri
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={dbSummaryLoading}
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
|
{dbSummaryLoading ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Re\u00eencarc\u0103
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UAT cards */}
|
||||||
|
{dbSummary.uats.map((uat) => {
|
||||||
|
const catCounts: Record<string, number> = {};
|
||||||
|
let enrichedTotal = 0;
|
||||||
|
let noGeomTotal = 0;
|
||||||
|
let oldestSync: Date | null = null;
|
||||||
|
for (const layer of uat.layers) {
|
||||||
|
const cat =
|
||||||
|
findLayerById(layer.layerId)?.category ?? "administrativ";
|
||||||
|
catCounts[cat] = (catCounts[cat] ?? 0) + layer.count;
|
||||||
|
enrichedTotal += layer.enrichedCount;
|
||||||
|
noGeomTotal += layer.noGeomCount ?? 0;
|
||||||
|
if (layer.lastSynced) {
|
||||||
|
const d = new Date(layer.lastSynced);
|
||||||
|
if (!oldestSync || d < oldestSync) oldestSync = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isCurrentUat = sirutaValid && uat.siruta === siruta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={uat.siruta} className="space-y-3">
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
isCurrentUat && "ring-1 ring-emerald-400/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="py-3 px-4 space-y-2">
|
||||||
|
{/* UAT header row */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{uat.uatName}
|
||||||
|
</span>
|
||||||
|
{uat.county && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
({uat.county})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
#{uat.siruta}
|
||||||
|
</span>
|
||||||
|
{isCurrentUat && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[9px] h-4 text-emerald-600 border-emerald-300 dark:text-emerald-400 dark:border-emerald-700"
|
||||||
|
>
|
||||||
|
selectat
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-2 text-[10px] gap-1 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 hover:bg-indigo-50 dark:hover:bg-indigo-950"
|
||||||
|
onClick={() =>
|
||||||
|
setDashboardSiruta(
|
||||||
|
dashboardSiruta === uat.siruta
|
||||||
|
? null
|
||||||
|
: uat.siruta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-3 w-3" />
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{oldestSync ? relativeTime(oldestSync) : "\u2014"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category counts in a single compact row */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||||
|
{(
|
||||||
|
Object.entries(LAYER_CATEGORY_LABELS) as [
|
||||||
|
LayerCategory,
|
||||||
|
string,
|
||||||
|
][]
|
||||||
|
).map(([cat, label]) => {
|
||||||
|
const count = catCounts[cat] ?? 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={cat}
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{label}:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{count.toLocaleString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{enrichedTotal > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3 text-teal-600 dark:text-teal-400" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Magic:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">
|
||||||
|
{enrichedTotal.toLocaleString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{noGeomTotal > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
F\u0103r\u0103 geom:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
|
||||||
|
{noGeomTotal.toLocaleString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layer detail pills */}
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{uat.layers
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.map((layer) => {
|
||||||
|
const meta = findLayerById(layer.layerId);
|
||||||
|
const label =
|
||||||
|
meta?.label ?? layer.layerId.replace(/_/g, " ");
|
||||||
|
const isEnriched = layer.enrichedCount > 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={layer.layerId}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px]",
|
||||||
|
isEnriched
|
||||||
|
? "border-teal-200 bg-teal-50/50 dark:border-teal-800 dark:bg-teal-950/30"
|
||||||
|
: "border-muted bg-muted/30",
|
||||||
|
)}
|
||||||
|
title={`${label}: ${layer.count} entit\u0103\u021bi${isEnriched ? `, ${layer.enrichedCount} \u00eembog\u0103\u021bite` : ""}${layer.lastSynced ? `, sync: ${new Date(layer.lastSynced).toLocaleDateString("ro-RO")}` : ""}`}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[120px]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{layer.count.toLocaleString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
{isEnriched && (
|
||||||
|
<Sparkles className="h-2.5 w-2.5 text-teal-600 dark:text-teal-400" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dashboard panel (expanded below card) */}
|
||||||
|
{dashboardSiruta === uat.siruta && (
|
||||||
|
<UatDashboard
|
||||||
|
siruta={uat.siruta}
|
||||||
|
uatName={uat.uatName}
|
||||||
|
onClose={() => setDashboardSiruta(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,987 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Download,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Database,
|
||||||
|
HardDrive,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import {
|
||||||
|
LAYER_CATALOG,
|
||||||
|
LAYER_CATEGORY_LABELS,
|
||||||
|
type LayerCategory,
|
||||||
|
type LayerCatalogItem,
|
||||||
|
} from "../../services/eterra-layers";
|
||||||
|
import type {
|
||||||
|
SessionStatus,
|
||||||
|
SyncRunInfo,
|
||||||
|
ExportProgress,
|
||||||
|
} from "../parcel-sync-types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Props */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type LayersTabProps = {
|
||||||
|
siruta: string;
|
||||||
|
sirutaValid: boolean;
|
||||||
|
session: SessionStatus;
|
||||||
|
syncLocalCounts: Record<string, number>;
|
||||||
|
syncRuns: SyncRunInfo[];
|
||||||
|
syncingSiruta: string;
|
||||||
|
onSyncRefresh: () => void;
|
||||||
|
exporting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function LayersTab({
|
||||||
|
siruta,
|
||||||
|
sirutaValid,
|
||||||
|
session,
|
||||||
|
syncLocalCounts,
|
||||||
|
syncRuns,
|
||||||
|
syncingSiruta,
|
||||||
|
onSyncRefresh,
|
||||||
|
exporting,
|
||||||
|
}: LayersTabProps) {
|
||||||
|
/* ── Local state ──────────────────────────────────────────────── */
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null);
|
||||||
|
const [layerCounts, setLayerCounts] = useState<
|
||||||
|
Record<string, { count: number; error?: string }>
|
||||||
|
>({});
|
||||||
|
const [countingLayers, setCountingLayers] = useState(false);
|
||||||
|
const [layerCountSiruta, setLayerCountSiruta] = useState("");
|
||||||
|
const [layerHistory, setLayerHistory] = useState<
|
||||||
|
{
|
||||||
|
layerId: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
time: string;
|
||||||
|
siruta: string;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
const [syncingLayer, setSyncingLayer] = useState<string | null>(null);
|
||||||
|
const [syncProgress, setSyncProgress] = useState("");
|
||||||
|
const [syncQueue, setSyncQueue] = useState<string[]>([]);
|
||||||
|
const syncQueueRef = useRef<string[]>([]);
|
||||||
|
const [exportingLocal, setExportingLocal] = useState(false);
|
||||||
|
const [postgisRunning, setPostgisRunning] = useState(false);
|
||||||
|
const [postgisResult, setPostgisResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [phaseTrail, setPhaseTrail] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
/* ── Derived ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const layersByCategory = useMemo(() => {
|
||||||
|
const grouped: Record<string, LayerCatalogItem[]> = {};
|
||||||
|
for (const layer of LAYER_CATALOG) {
|
||||||
|
if (!grouped[layer.category]) grouped[layer.category] = [];
|
||||||
|
grouped[layer.category]!.push(layer);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progressPct =
|
||||||
|
exportProgress?.total && exportProgress.total > 0
|
||||||
|
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
/* ── Layer history: load from localStorage on mount ───────────── */
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("parcel-sync:layer-history");
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as typeof layerHistory;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const todayEntries = parsed.filter(
|
||||||
|
(e) => e.time.slice(0, 10) === today,
|
||||||
|
);
|
||||||
|
setLayerHistory(todayEntries);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Cleanup polling on unmount ───────────────────────────────── */
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── startPolling ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const startPolling = useCallback((jid: string) => {
|
||||||
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as ExportProgress;
|
||||||
|
setExportProgress(data);
|
||||||
|
if (data.phase) {
|
||||||
|
setPhaseTrail((prev) => {
|
||||||
|
if (prev[prev.length - 1] === data.phase) return prev;
|
||||||
|
return [...prev, data.phase!];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.status === "done" || data.status === "error") {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore polling errors */
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── fetchLayerCounts ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const fetchLayerCounts = useCallback(async () => {
|
||||||
|
if (!siruta || countingLayers) return;
|
||||||
|
setCountingLayers(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/layers/summary", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ siruta }),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
counts?: Record<string, { count: number; error?: string }>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (data.counts) {
|
||||||
|
setLayerCounts(data.counts);
|
||||||
|
setLayerCountSiruta(siruta);
|
||||||
|
|
||||||
|
// Save non-zero counts to history
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const today = now.slice(0, 10);
|
||||||
|
const newEntries: typeof layerHistory = [];
|
||||||
|
for (const [layerId, info] of Object.entries(data.counts)) {
|
||||||
|
if (info.count > 0) {
|
||||||
|
const layer = LAYER_CATALOG.find((l) => l.id === layerId);
|
||||||
|
newEntries.push({
|
||||||
|
layerId,
|
||||||
|
label: layer?.label ?? layerId,
|
||||||
|
count: info.count,
|
||||||
|
time: now,
|
||||||
|
siruta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLayerHistory((prev) => {
|
||||||
|
// Keep today's entries only, add new batch
|
||||||
|
const kept = prev.filter(
|
||||||
|
(e) => e.time.slice(0, 10) === today && e.siruta !== siruta,
|
||||||
|
);
|
||||||
|
const merged = [...kept, ...newEntries];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
"parcel-sync:layer-history",
|
||||||
|
JSON.stringify(merged),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// quota
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
setCountingLayers(false);
|
||||||
|
}, [siruta, countingLayers]);
|
||||||
|
|
||||||
|
/* ── handleSyncLayer ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleSyncLayer = useCallback(
|
||||||
|
async (layerId: string) => {
|
||||||
|
if (!siruta || syncingLayer) return;
|
||||||
|
setSyncingLayer(layerId);
|
||||||
|
setSyncProgress("Sincronizare pornit\u0103\u2026");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siruta,
|
||||||
|
layerId,
|
||||||
|
jobId: crypto.randomUUID(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
status?: string;
|
||||||
|
newFeatures?: number;
|
||||||
|
removedFeatures?: number;
|
||||||
|
totalLocal?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (data.error) {
|
||||||
|
setSyncProgress(`Eroare: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setSyncProgress(
|
||||||
|
`Finalizat \u2014 ${data.newFeatures ?? 0} noi, ${data.removedFeatures ?? 0} \u0219terse, ${data.totalLocal ?? 0} total local`,
|
||||||
|
);
|
||||||
|
// Refresh sync status
|
||||||
|
onSyncRefresh();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSyncProgress("Eroare re\u021bea");
|
||||||
|
}
|
||||||
|
// Clear progress after 8s
|
||||||
|
setTimeout(() => {
|
||||||
|
setSyncingLayer(null);
|
||||||
|
setSyncProgress("");
|
||||||
|
}, 8_000);
|
||||||
|
},
|
||||||
|
[siruta, syncingLayer, onSyncRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── handleExportLocal ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleExportLocal = useCallback(
|
||||||
|
async (layerIds?: string[]) => {
|
||||||
|
if (!siruta || exportingLocal) return;
|
||||||
|
setExportingLocal(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/export-local", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siruta,
|
||||||
|
...(layerIds ? { layerIds } : { allLayers: true }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||||
|
const filename =
|
||||||
|
match?.[1] ??
|
||||||
|
`eterra_local_${siruta}.${layerIds?.length === 1 ? "gpkg" : "zip"}`;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare export";
|
||||||
|
setSyncProgress(msg);
|
||||||
|
setTimeout(() => setSyncProgress(""), 5_000);
|
||||||
|
}
|
||||||
|
setExportingLocal(false);
|
||||||
|
},
|
||||||
|
[siruta, exportingLocal],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── handleSyncMultiple ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleSyncMultiple = useCallback(
|
||||||
|
async (layerIds: string[]) => {
|
||||||
|
if (!siruta || syncingLayer || syncQueue.length > 0) return;
|
||||||
|
syncQueueRef.current = [...layerIds];
|
||||||
|
setSyncQueue([...layerIds]);
|
||||||
|
|
||||||
|
for (const layerId of layerIds) {
|
||||||
|
setSyncingLayer(layerId);
|
||||||
|
setSyncProgress(
|
||||||
|
`Sincronizare ${LAYER_CATALOG.find((l) => l.id === layerId)?.label ?? layerId}\u2026`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siruta,
|
||||||
|
layerId,
|
||||||
|
jobId: crypto.randomUUID(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
error?: string;
|
||||||
|
newFeatures?: number;
|
||||||
|
removedFeatures?: number;
|
||||||
|
totalLocal?: number;
|
||||||
|
};
|
||||||
|
if (data.error) {
|
||||||
|
setSyncProgress(`Eroare: ${data.error}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSyncProgress("Eroare re\u021bea");
|
||||||
|
}
|
||||||
|
// Remove from queue
|
||||||
|
syncQueueRef.current = syncQueueRef.current.filter(
|
||||||
|
(id) => id !== layerId,
|
||||||
|
);
|
||||||
|
setSyncQueue([...syncQueueRef.current]);
|
||||||
|
}
|
||||||
|
// Done — refresh status
|
||||||
|
onSyncRefresh();
|
||||||
|
setSyncingLayer(null);
|
||||||
|
setSyncProgress("");
|
||||||
|
setSyncQueue([]);
|
||||||
|
syncQueueRef.current = [];
|
||||||
|
},
|
||||||
|
[siruta, syncingLayer, syncQueue.length, onSyncRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── handleSetupPostgis ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleSetupPostgis = useCallback(async () => {
|
||||||
|
if (postgisRunning) return;
|
||||||
|
setPostgisRunning(true);
|
||||||
|
setPostgisResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/setup-postgis", { method: "POST" });
|
||||||
|
const json = await res.json();
|
||||||
|
setPostgisResult(json as typeof postgisResult);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare setup";
|
||||||
|
setPostgisResult({ success: false, error: msg });
|
||||||
|
}
|
||||||
|
setPostgisRunning(false);
|
||||||
|
}, [postgisRunning, postgisResult]);
|
||||||
|
|
||||||
|
/* ── handleExportLayer ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleExportLayer = useCallback(
|
||||||
|
async (layerId: string) => {
|
||||||
|
if (!siruta || downloadingLayer) return;
|
||||||
|
setDownloadingLayer(layerId);
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
setExportProgress(null);
|
||||||
|
setPhaseTrail([]);
|
||||||
|
|
||||||
|
startPolling(jobId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/export-layer-gpkg", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siruta,
|
||||||
|
layerId,
|
||||||
|
jobId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||||
|
const filename =
|
||||||
|
match?.[1] ?? `eterra_uat_${siruta}_${layerId}.gpkg`;
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Mark progress as done after successful download
|
||||||
|
setExportProgress((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
status: "done",
|
||||||
|
phase: "Finalizat",
|
||||||
|
downloaded: prev.total ?? 100,
|
||||||
|
total: prev.total ?? 100,
|
||||||
|
message: `Desc\u0103rcare complet\u0103 \u2014 ${filename}`,
|
||||||
|
note: undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare export";
|
||||||
|
setExportProgress((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, status: "error", message: msg }
|
||||||
|
: {
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
status: "error",
|
||||||
|
message: msg,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
setDownloadingLayer(null);
|
||||||
|
// Refresh sync status — layer was synced to DB
|
||||||
|
onSyncRefresh();
|
||||||
|
},
|
||||||
|
[siruta, downloadingLayer, startPolling, onSyncRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── Suppress unused warnings for sync multiple (used externally) */
|
||||||
|
void handleSyncMultiple;
|
||||||
|
void phaseTrail;
|
||||||
|
void syncQueue;
|
||||||
|
|
||||||
|
/* ── JSX ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
if (!sirutaValid || !session.connected) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<Layers className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>
|
||||||
|
{!session.connected
|
||||||
|
? "Conecteaz\u0103-te la eTerra \u0219i selecteaz\u0103 un UAT."
|
||||||
|
: "Selecteaz\u0103 un UAT pentru a vedea catalogul de layere."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Action bar */}
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{layerCountSiruta === siruta &&
|
||||||
|
Object.keys(layerCounts).length > 0
|
||||||
|
? `Num\u0103r features pentru SIRUTA ${siruta}`
|
||||||
|
: "Apas\u0103 pentru a num\u0103ra features-urile din fiecare layer."}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Export from local DB */}
|
||||||
|
{syncingSiruta === siruta &&
|
||||||
|
Object.values(syncLocalCounts).some((c) => c > 0) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={exportingLocal}
|
||||||
|
onClick={() => void handleExportLocal()}
|
||||||
|
className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
|
||||||
|
>
|
||||||
|
{exportingLocal ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<HardDrive className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
Export local
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={countingLayers}
|
||||||
|
onClick={() => void fetchLayerCounts()}
|
||||||
|
>
|
||||||
|
{countingLayers ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{countingLayers ? "Se num\u0103r\u0103\u2026" : "Num\u0103r\u0103"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync progress message */}
|
||||||
|
{syncProgress && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
|
||||||
|
{syncingLayer ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{syncProgress}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map((cat) => {
|
||||||
|
const layers = layersByCategory[cat];
|
||||||
|
if (!layers?.length) return null;
|
||||||
|
const isExpanded = expandedCategories[cat] ?? false;
|
||||||
|
|
||||||
|
// Sum counts for category badge
|
||||||
|
const catTotal =
|
||||||
|
layerCountSiruta === siruta
|
||||||
|
? layers.reduce(
|
||||||
|
(sum, l) => sum + (layerCounts[l.id]?.count ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Sum local counts for category
|
||||||
|
const catLocal =
|
||||||
|
syncingSiruta === siruta
|
||||||
|
? layers.reduce(
|
||||||
|
(sum, l) => sum + (syncLocalCounts[l.id] ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={cat}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/50 transition-colors rounded-t-xl"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedCategories((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[cat]: !prev[cat],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{LAYER_CATEGORY_LABELS[cat]}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-normal text-[11px]"
|
||||||
|
>
|
||||||
|
{layers.length}
|
||||||
|
</Badge>
|
||||||
|
{catTotal != null && catTotal > 0 && (
|
||||||
|
<Badge className="font-mono text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-0">
|
||||||
|
{catTotal.toLocaleString("ro-RO")} remote
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{catLocal != null && catLocal > 0 && (
|
||||||
|
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0">
|
||||||
|
{catLocal.toLocaleString("ro-RO")} local
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent className="pt-0 pb-3 space-y-1.5">
|
||||||
|
{layers.map((layer) => {
|
||||||
|
const isDownloading = downloadingLayer === layer.id;
|
||||||
|
const isSyncing = syncingLayer === layer.id;
|
||||||
|
const lc =
|
||||||
|
layerCountSiruta === siruta
|
||||||
|
? layerCounts[layer.id]
|
||||||
|
: undefined;
|
||||||
|
const localCount =
|
||||||
|
syncingSiruta === siruta
|
||||||
|
? (syncLocalCounts[layer.id] ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Find last sync run for this layer
|
||||||
|
const lastRun = syncRuns.find(
|
||||||
|
(r) =>
|
||||||
|
r.layerId === layer.id && r.status === "done",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2.5 transition-colors",
|
||||||
|
isDownloading || isSyncing
|
||||||
|
? "border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/20"
|
||||||
|
: "hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{layer.label}
|
||||||
|
</p>
|
||||||
|
{lc != null && !lc.error && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-[10px] shrink-0",
|
||||||
|
lc.count === 0
|
||||||
|
? "opacity-40"
|
||||||
|
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{lc.count.toLocaleString("ro-RO")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{lc?.error && (
|
||||||
|
<span className="text-[10px] text-rose-500">
|
||||||
|
eroare
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{localCount > 0 && (
|
||||||
|
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0 shrink-0">
|
||||||
|
<Database className="h-2.5 w-2.5 mr-0.5" />
|
||||||
|
{localCount.toLocaleString("ro-RO")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-[11px] text-muted-foreground font-mono">
|
||||||
|
{layer.id}
|
||||||
|
</p>
|
||||||
|
{lastRun && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/70">
|
||||||
|
sync{" "}
|
||||||
|
{new Date(
|
||||||
|
lastRun.completedAt ??
|
||||||
|
lastRun.startedAt,
|
||||||
|
).toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{/* Sync to DB */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={
|
||||||
|
!!syncingLayer ||
|
||||||
|
!!downloadingLayer ||
|
||||||
|
exporting
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void handleSyncLayer(layer.id)
|
||||||
|
}
|
||||||
|
className="border-violet-200 dark:border-violet-800"
|
||||||
|
title="Sincronizeaz\u0103 \u00een baza de date"
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5 hidden sm:inline">
|
||||||
|
Sync
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{/* GPKG (sync-first: syncs to DB if needed, then exports from DB) */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!!downloadingLayer || exporting}
|
||||||
|
onClick={() =>
|
||||||
|
void handleExportLayer(layer.id)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
localCount > 0
|
||||||
|
? "Descarc\u0103 GPKG (din cache dac\u0103 e proasp\u0103t)"
|
||||||
|
: "Sincronizeaz\u0103 + descarc\u0103 GPKG"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5 hidden sm:inline">
|
||||||
|
GPKG
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Drumul de azi — today's layer count history */}
|
||||||
|
{layerHistory.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="px-4 py-3 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="text-sm font-semibold">Drumul de azi</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-normal text-[11px]"
|
||||||
|
>
|
||||||
|
{layerHistory.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardContent className="pt-3 pb-3">
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{/* Group by siruta */}
|
||||||
|
{(() => {
|
||||||
|
const grouped = new Map<string, typeof layerHistory>();
|
||||||
|
for (const e of layerHistory) {
|
||||||
|
if (!grouped.has(e.siruta)) grouped.set(e.siruta, []);
|
||||||
|
grouped.get(e.siruta)!.push(e);
|
||||||
|
}
|
||||||
|
return Array.from(grouped.entries()).map(
|
||||||
|
([sir, entries]) => (
|
||||||
|
<div key={sir} className="space-y-1">
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground">
|
||||||
|
SIRUTA {sir}{" "}
|
||||||
|
<span className="font-normal opacity-70">
|
||||||
|
\u2014{" "}
|
||||||
|
{new Date(entries[0]!.time).toLocaleTimeString(
|
||||||
|
"ro-RO",
|
||||||
|
{ hour: "2-digit", minute: "2-digit" },
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
||||||
|
{entries
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.layerId}
|
||||||
|
className="flex items-center justify-between gap-1 rounded border px-2 py-1 text-[11px]"
|
||||||
|
>
|
||||||
|
<span className="truncate">{e.label}</span>
|
||||||
|
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||||
|
{e.count.toLocaleString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PostGIS / QGIS setup */}
|
||||||
|
<Card>
|
||||||
|
<div className="px-4 py-3 border-b">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-violet-500" />
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
Conectare QGIS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!postgisResult?.success && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={postgisRunning}
|
||||||
|
onClick={() => void handleSetupPostgis()}
|
||||||
|
className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
|
||||||
|
>
|
||||||
|
{postgisRunning ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<Database className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{postgisRunning
|
||||||
|
? "Se activeaza\u2026"
|
||||||
|
: "Activeaza compatibilitate QGIS"}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="font-medium">
|
||||||
|
Operatie sigura, reversibila
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Creeaza coloane native PostGIS + view-uri read-only
|
||||||
|
pentru QGIS. Nu modifica datele existente. Ruleaza o
|
||||||
|
singura data (~30s).
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardContent className="py-3 space-y-3">
|
||||||
|
{postgisResult ? (
|
||||||
|
postgisResult.success ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
|
||||||
|
QGIS compatibil \u2014 gata de conectare
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-muted/50 p-3 text-xs space-y-2">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
Cum te conectezi din QGIS:
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
QGIS \u2192 Layer \u2192 Add Layer \u2192 Add PostGIS Layers
|
||||||
|
</li>
|
||||||
|
<li>New connection:</li>
|
||||||
|
</ol>
|
||||||
|
<div className="font-mono bg-background rounded px-2 py-1.5 space-y-0.5">
|
||||||
|
<p>
|
||||||
|
Host: <strong>10.10.10.166</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Port: <strong>5432</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Database: <strong>architools_db</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Username: <strong>architools_user</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
View-uri disponibile (read-only):
|
||||||
|
</p>
|
||||||
|
<div className="font-mono text-muted-foreground">
|
||||||
|
gis_terenuri, gis_cladiri, gis_documentatii,
|
||||||
|
gis_administrativ
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
SRID: 3844 (Stereo70)
|
||||||
|
</p>
|
||||||
|
{postgisResult.details && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{String(
|
||||||
|
(
|
||||||
|
postgisResult.details as {
|
||||||
|
totalFeaturesWithGeom?: number;
|
||||||
|
}
|
||||||
|
).totalFeaturesWithGeom ?? 0,
|
||||||
|
)}{" "}
|
||||||
|
features cu geometrie nativa
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-red-700 dark:text-red-400">
|
||||||
|
PostGIS nu este instalat pe server
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Contacteaza administratorul pentru instalare:
|
||||||
|
</p>
|
||||||
|
<code className="text-xs block mt-1 bg-muted rounded px-2 py-1">
|
||||||
|
apt install postgresql-16-postgis-3
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-2">
|
||||||
|
<p>
|
||||||
|
Permite conectarea din QGIS direct la baza de date pentru
|
||||||
|
vizualizare si analiza spatiala a parcelelor, cladirilor
|
||||||
|
si limitelor UAT.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Apasa butonul pentru a activa \u2014 creeaza view-uri read-only
|
||||||
|
(nu modifica datele, nu afecteaza performanta aplicatiei).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Progress bar for layer download */}
|
||||||
|
{downloadingLayer && exportProgress && (
|
||||||
|
<Card className="border-blue-200 dark:border-blue-800">
|
||||||
|
<CardContent className="pt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="h-5 w-5 text-blue-600 animate-spin shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{exportProgress.phase}
|
||||||
|
{exportProgress.phaseCurrent != null &&
|
||||||
|
exportProgress.phaseTotal
|
||||||
|
? ` \u2014 ${exportProgress.phaseCurrent} / ${exportProgress.phaseTotal}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono font-semibold tabular-nums">
|
||||||
|
{progressPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full rounded-full bg-blue-200/50 dark:bg-blue-800/30">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-blue-600 transition-all duration-300"
|
||||||
|
style={{ width: `${Math.max(2, progressPct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Map as MapIcon, Loader2 } from "lucide-react";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { BasemapSwitcher } from "@/modules/geoportal/components/basemap-switcher";
|
||||||
|
import {
|
||||||
|
SelectionToolbar,
|
||||||
|
type SelectionMode,
|
||||||
|
} from "@/modules/geoportal/components/selection-toolbar";
|
||||||
|
import { FeatureInfoPanel } from "@/modules/geoportal/components/feature-info-panel";
|
||||||
|
import type { MapViewerHandle } from "@/modules/geoportal/components/map-viewer";
|
||||||
|
import type {
|
||||||
|
BasemapId,
|
||||||
|
ClickedFeature,
|
||||||
|
LayerVisibility,
|
||||||
|
SelectedFeature,
|
||||||
|
} from "@/modules/geoportal/types";
|
||||||
|
|
||||||
|
/* MapLibre uses WebGL — must disable SSR */
|
||||||
|
const MapViewer = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/modules/geoportal/components/map-viewer").then((m) => ({
|
||||||
|
default: m.MapViewer,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Layer IDs — must match map-viewer.tsx LAYER_IDS */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const BASE_LAYERS = [
|
||||||
|
"l-terenuri-fill",
|
||||||
|
"l-terenuri-line",
|
||||||
|
"l-terenuri-label",
|
||||||
|
"l-cladiri-fill",
|
||||||
|
"l-cladiri-line",
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Props */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type MapTabProps = {
|
||||||
|
siruta: string;
|
||||||
|
sirutaValid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers — typed map operations */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type MapLike = {
|
||||||
|
getLayer(id: string): unknown;
|
||||||
|
getSource(id: string): unknown;
|
||||||
|
addSource(id: string, source: Record<string, unknown>): void;
|
||||||
|
addLayer(layer: Record<string, unknown>, before?: string): void;
|
||||||
|
setFilter(id: string, filter: unknown[] | null): void;
|
||||||
|
setLayoutProperty(id: string, prop: string, value: unknown): void;
|
||||||
|
fitBounds(bounds: [number, number, number, number], opts?: Record<string, unknown>): void;
|
||||||
|
isStyleLoaded(): boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asMap(handle: MapViewerHandle | null): MapLike | null {
|
||||||
|
const m = handle?.getMap();
|
||||||
|
return m ? (m as unknown as MapLike) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
||||||
|
const mapHandleRef = useRef<MapViewerHandle>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||||
|
const [clickedFeature, setClickedFeature] =
|
||||||
|
useState<ClickedFeature | null>(null);
|
||||||
|
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
|
||||||
|
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [boundsLoading, setBoundsLoading] = useState(false);
|
||||||
|
const [flyTarget, setFlyTarget] = useState<
|
||||||
|
{ center: [number, number]; zoom?: number } | undefined
|
||||||
|
>();
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
const [viewsReady, setViewsReady] = useState<boolean | null>(null);
|
||||||
|
const appliedSirutaRef = useRef("");
|
||||||
|
|
||||||
|
/* Layer visibility: show terenuri + cladiri, hide admin */
|
||||||
|
const [layerVisibility] = useState<LayerVisibility>({
|
||||||
|
terenuri: true,
|
||||||
|
cladiri: true,
|
||||||
|
administrativ: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Check if enrichment views exist, create if not ────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/geoportal/setup-enrichment-views")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { ready?: boolean }) => {
|
||||||
|
if (data.ready) {
|
||||||
|
setViewsReady(true);
|
||||||
|
} else {
|
||||||
|
// Auto-create views
|
||||||
|
fetch("/api/geoportal/setup-enrichment-views", { method: "POST" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((res: { status?: string }) => {
|
||||||
|
setViewsReady(res.status === "ok");
|
||||||
|
})
|
||||||
|
.catch(() => setViewsReady(false));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setViewsReady(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Detect when map is ready ──────────────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sirutaValid) return;
|
||||||
|
const check = setInterval(() => {
|
||||||
|
const map = asMap(mapHandleRef.current);
|
||||||
|
if (map && map.isStyleLoaded()) {
|
||||||
|
setMapReady(true);
|
||||||
|
clearInterval(check);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => clearInterval(check);
|
||||||
|
}, [sirutaValid]);
|
||||||
|
|
||||||
|
/* ── Apply siruta filter on base map layers ────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapReady || !sirutaValid || !siruta) return;
|
||||||
|
if (appliedSirutaRef.current === siruta) return;
|
||||||
|
|
||||||
|
const map = asMap(mapHandleRef.current);
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
appliedSirutaRef.current = siruta;
|
||||||
|
const filter = ["==", ["get", "siruta"], siruta];
|
||||||
|
|
||||||
|
for (const layerId of BASE_LAYERS) {
|
||||||
|
try {
|
||||||
|
if (!map.getLayer(layerId)) continue;
|
||||||
|
map.setFilter(layerId, filter);
|
||||||
|
} catch {
|
||||||
|
/* layer may not exist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mapReady, siruta, sirutaValid]);
|
||||||
|
|
||||||
|
/* ── Add enrichment overlay source + layers ────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapReady || !viewsReady || !sirutaValid || !siruta) return;
|
||||||
|
|
||||||
|
const map = asMap(mapHandleRef.current);
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const martinBase = typeof window !== "undefined"
|
||||||
|
? `${window.location.origin}/tiles`
|
||||||
|
: "/tiles";
|
||||||
|
|
||||||
|
// Add gis_terenuri_status source (only once)
|
||||||
|
if (!map.getSource("gis_terenuri_status")) {
|
||||||
|
map.addSource("gis_terenuri_status", {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [`${martinBase}/gis_terenuri_status/{z}/{x}/{y}`],
|
||||||
|
minzoom: 10,
|
||||||
|
maxzoom: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data-driven fill: color by enrichment status
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: "l-ps-terenuri-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "gis_terenuri_status",
|
||||||
|
"source-layer": "gis_terenuri_status",
|
||||||
|
minzoom: 13,
|
||||||
|
filter: ["==", ["get", "siruta"], siruta],
|
||||||
|
paint: {
|
||||||
|
"fill-color": [
|
||||||
|
"case",
|
||||||
|
// Enriched parcels: darker green
|
||||||
|
["==", ["get", "has_enrichment"], 1],
|
||||||
|
"#15803d",
|
||||||
|
// No enrichment: lighter green
|
||||||
|
"#86efac",
|
||||||
|
],
|
||||||
|
"fill-opacity": 0.25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"l-terenuri-line", // insert before line layer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Data-driven outline
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: "l-ps-terenuri-line",
|
||||||
|
type: "line",
|
||||||
|
source: "gis_terenuri_status",
|
||||||
|
"source-layer": "gis_terenuri_status",
|
||||||
|
minzoom: 13,
|
||||||
|
filter: ["==", ["get", "siruta"], siruta],
|
||||||
|
paint: {
|
||||||
|
"line-color": [
|
||||||
|
"case",
|
||||||
|
// Has building without legal docs: red
|
||||||
|
[
|
||||||
|
"all",
|
||||||
|
["==", ["get", "has_building"], 1],
|
||||||
|
["==", ["get", "build_legal"], 0],
|
||||||
|
],
|
||||||
|
"#ef4444",
|
||||||
|
// Has building with legal: blue
|
||||||
|
["==", ["get", "has_building"], 1],
|
||||||
|
"#3b82f6",
|
||||||
|
// Default: green
|
||||||
|
"#15803d",
|
||||||
|
],
|
||||||
|
"line-width": [
|
||||||
|
"case",
|
||||||
|
["==", ["get", "has_building"], 1],
|
||||||
|
1.8,
|
||||||
|
0.8,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"l-cladiri-fill",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Source already exists — just update filters for new siruta
|
||||||
|
const sirutaFilter = ["==", ["get", "siruta"], siruta];
|
||||||
|
try {
|
||||||
|
if (map.getLayer("l-ps-terenuri-fill"))
|
||||||
|
map.setFilter("l-ps-terenuri-fill", sirutaFilter);
|
||||||
|
if (map.getLayer("l-ps-terenuri-line"))
|
||||||
|
map.setFilter("l-ps-terenuri-line", sirutaFilter);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the base terenuri-fill (we replaced it with enrichment-aware version)
|
||||||
|
try {
|
||||||
|
if (map.getLayer("l-terenuri-fill"))
|
||||||
|
map.setLayoutProperty("l-terenuri-fill", "visibility", "none");
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}, [mapReady, viewsReady, siruta, sirutaValid]);
|
||||||
|
|
||||||
|
/* ── Fetch UAT bounds and zoom ─────────────────────────────── */
|
||||||
|
const prevBoundsSirutaRef = useRef("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sirutaValid || !siruta) return;
|
||||||
|
if (prevBoundsSirutaRef.current === siruta) return;
|
||||||
|
prevBoundsSirutaRef.current = siruta;
|
||||||
|
|
||||||
|
setBoundsLoading(true);
|
||||||
|
fetch(`/api/geoportal/uat-bounds?siruta=${siruta}`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then(
|
||||||
|
(data: {
|
||||||
|
bounds?: [[number, number], [number, number]];
|
||||||
|
} | null) => {
|
||||||
|
if (data?.bounds) {
|
||||||
|
const [[minLng, minLat], [maxLng, maxLat]] = data.bounds;
|
||||||
|
const centerLng = (minLng + maxLng) / 2;
|
||||||
|
const centerLat = (minLat + maxLat) / 2;
|
||||||
|
setFlyTarget({ center: [centerLng, centerLat], zoom: 13 });
|
||||||
|
|
||||||
|
// Fit bounds if map is already ready
|
||||||
|
const map = asMap(mapHandleRef.current);
|
||||||
|
if (map) {
|
||||||
|
map.fitBounds([minLng, minLat, maxLng, maxLat], {
|
||||||
|
padding: 40,
|
||||||
|
duration: 1500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setBoundsLoading(false));
|
||||||
|
}, [siruta, sirutaValid]);
|
||||||
|
|
||||||
|
/* ── Feature click handler ─────────────────────────────────── */
|
||||||
|
const handleFeatureClick = useCallback(
|
||||||
|
(feature: ClickedFeature | null) => {
|
||||||
|
if (!feature || !feature.properties) {
|
||||||
|
setClickedFeature(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClickedFeature(feature);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── Selection mode handler ────────────────────────────────── */
|
||||||
|
const handleSelectionModeChange = useCallback((mode: SelectionMode) => {
|
||||||
|
if (mode === "off") {
|
||||||
|
mapHandleRef.current?.clearSelection();
|
||||||
|
setSelectedFeatures([]);
|
||||||
|
}
|
||||||
|
setSelectionMode(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Render ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
if (!sirutaValid) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<MapIcon className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>Selecteaz\u0103 un UAT din lista de mai sus</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[600px] rounded-lg border overflow-hidden">
|
||||||
|
{boundsLoading && (
|
||||||
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-background/90 border px-3 py-1.5 text-xs shadow-sm">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Se \u00eencarc\u0103 zona UAT...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MapViewer
|
||||||
|
ref={mapHandleRef}
|
||||||
|
className="h-full w-full"
|
||||||
|
basemap={basemap}
|
||||||
|
selectionType={selectionMode}
|
||||||
|
onFeatureClick={handleFeatureClick}
|
||||||
|
onSelectionChange={setSelectedFeatures}
|
||||||
|
layerVisibility={layerVisibility}
|
||||||
|
center={flyTarget?.center}
|
||||||
|
zoom={flyTarget?.zoom}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top-right: basemap switcher + feature panel */}
|
||||||
|
<div className="absolute top-3 right-3 z-10 flex flex-col items-end gap-2">
|
||||||
|
<BasemapSwitcher value={basemap} onChange={setBasemap} />
|
||||||
|
{clickedFeature && selectionMode === "off" && (
|
||||||
|
<FeatureInfoPanel
|
||||||
|
feature={clickedFeature}
|
||||||
|
onClose={() => setClickedFeature(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom-left: selection toolbar */}
|
||||||
|
<div className="absolute bottom-8 left-3 z-10">
|
||||||
|
<SelectionToolbar
|
||||||
|
selectedFeatures={selectedFeatures}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onSelectionModeChange={handleSelectionModeChange}
|
||||||
|
onClearSelection={() => {
|
||||||
|
mapHandleRef.current?.clearSelection();
|
||||||
|
setSelectedFeatures([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom-right: legend */}
|
||||||
|
<div className="absolute bottom-3 right-3 z-10 rounded-lg bg-background/90 border p-2 text-[10px] space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-3 w-3 rounded-sm border" style={{ backgroundColor: "rgba(134,239,172,0.25)", borderColor: "#15803d" }} />
|
||||||
|
F\u0103r\u0103 enrichment
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-3 w-3 rounded-sm border" style={{ backgroundColor: "rgba(21,128,61,0.25)", borderColor: "#15803d" }} />
|
||||||
|
Cu enrichment
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-3 w-3 rounded-sm" style={{ border: "2px solid #3b82f6" }} />
|
||||||
|
Cu cl\u0103dire
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-3 w-3 rounded-sm" style={{ border: "2px solid #ef4444" }} />
|
||||||
|
Cl\u0103dire f\u0103r\u0103 acte
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user