diff --git a/src/app/(portal)/layout.tsx b/src/app/(portal)/layout.tsx new file mode 100644 index 0000000..4965394 --- /dev/null +++ b/src/app/(portal)/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "../globals.css"; +import { Providers } from "../providers"; + +const inter = Inter({ subsets: ["latin", "latin-ext"] }); + +export const metadata: Metadata = { + title: "Portal Tiurbe — ArchiTools", + description: "Portal extern — documente RGI si harta cadastrala", +}; + +export default function PortalLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+ {children} +
+
+ + + ); +} diff --git a/src/app/(portal)/portal/page.tsx b/src/app/(portal)/portal/page.tsx new file mode 100644 index 0000000..d5aee9e --- /dev/null +++ b/src/app/(portal)/portal/page.tsx @@ -0,0 +1,1505 @@ +"use client"; + +import React, { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import dynamic from "next/dynamic"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { Label } from "@/shared/components/ui/label"; +import { Badge } from "@/shared/components/ui/badge"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; +import { + Loader2, + ChevronDown, + ChevronUp, + Download, + Search, + FileText, + Clock, + AlertTriangle, + Settings2, + Shield, + ArrowUpDown, + ArrowUp, + ArrowDown, + Archive, + Map as MapIcon, + Moon, + Satellite, + RefreshCw, +} from "lucide-react"; +import { cn } from "@/shared/lib/utils"; +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: () => ( +
+

Se incarca harta...

+
+ ), + }, +); + +/* ================================================================== */ +/* RGI Types & Constants */ +/* ================================================================== */ + +type App = { + actorName: string; + adminUnit: number; + appDate: number; + appNo: number; + applicationObject: string; + applicationPk: number; + colorNumber: number; + communicationType: string; + deponent: string; + dueDate: number; + hasSolution: number; + identifiers: string; + initialAppNo: string; + orgUnit: string; + requester: string; + resolutionName: string; + stateCode: string; + statusName: string; + totalFee: number; + uat: string; + workspace: string; + workspaceId: number; + [key: string]: unknown; +}; + +type IssuedDoc = { + applicationId: number; + docType: string; + documentPk: number; + documentTypeCode: string; + documentTypeId: number; + fileExtension: string; + digitallySigned: number; + startDate: number; + lastUpdatedDtm: number; + initialAppNo: string; + workspaceId: number; + identifierDetails: string | null; + [key: string]: unknown; +}; + +type SortDir = "asc" | "desc"; +type SortState = { key: string; dir: SortDir } | null; + +const COUNTIES = [ + { id: 10, name: "Alba" }, + { id: 29, name: "Arad" }, + { id: 38, name: "Arges" }, + { id: 47, name: "Bacau" }, + { id: 56, name: "Bihor" }, + { id: 65, name: "Bistrita-Nasaud" }, + { id: 74, name: "Botosani" }, + { id: 83, name: "Brasov" }, + { id: 92, name: "Braila" }, + { id: 108, name: "Buzau" }, + { id: 117, name: "Caras-Severin" }, + { id: 127, name: "Cluj" }, + { id: 136, name: "Constanta" }, + { id: 145, name: "Covasna" }, + { id: 154, name: "Dambovita" }, + { id: 163, name: "Dolj" }, + { id: 172, name: "Galati" }, + { id: 181, name: "Giurgiu" }, + { id: 190, name: "Gorj" }, + { id: 199, name: "Harghita" }, + { id: 208, name: "Hunedoara" }, + { id: 217, name: "Ialomita" }, + { id: 226, name: "Iasi" }, + { id: 235, name: "Ilfov" }, + { id: 244, name: "Maramures" }, + { id: 253, name: "Mehedinti" }, + { id: 262, name: "Mures" }, + { id: 271, name: "Neamt" }, + { id: 280, name: "Olt" }, + { id: 289, name: "Prahova" }, + { id: 298, name: "Satu Mare" }, + { id: 307, name: "Salaj" }, + { id: 316, name: "Sibiu" }, + { id: 325, name: "Suceava" }, + { id: 334, name: "Teleorman" }, + { id: 343, name: "Timis" }, + { id: 352, name: "Tulcea" }, + { id: 361, name: "Vaslui" }, + { id: 370, name: "Valcea" }, + { id: 379, name: "Vrancea" }, + { id: 401, name: "Bucuresti" }, +] as const; + +/* ================================================================== */ +/* RGI Column definitions */ +/* ================================================================== */ + +type ColumnDef = { + key: string; + label: string; + defaultVisible: boolean; + render: (app: App) => string; + className?: string; +}; + +function fmtTs(ts: number | null | undefined): string { + if (!ts) return "-"; + const d = new Date(ts); + if (isNaN(d.getTime())) return "-"; + return d.toLocaleDateString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +const ALL_COLUMNS: ColumnDef[] = [ + { + key: "appNo", + label: "Nr. cerere", + defaultVisible: true, + render: (a) => String(a.appNo ?? "-"), + className: "font-mono font-semibold", + }, + { + key: "initialAppNo", + label: "Nr. initial", + defaultVisible: false, + render: (a) => a.initialAppNo || "-", + className: "font-mono text-xs", + }, + { + key: "applicationObject", + label: "Obiect", + defaultVisible: false, + render: (a) => a.applicationObject || "-", + }, + { + key: "identifiers", + label: "Identificatori (IE/CF)", + defaultVisible: false, + render: (a) => a.identifiers || "-", + className: "text-xs max-w-[300px] truncate", + }, + { + key: "deponent", + label: "Deponent", + defaultVisible: false, + render: (a) => a.deponent || "-", + }, + { + key: "requester", + label: "Solicitant", + defaultVisible: true, + render: (a) => a.requester || "-", + }, + { + key: "appDate", + label: "Data depunere", + defaultVisible: false, + render: (a) => fmtTs(a.appDate), + className: "tabular-nums", + }, + { + key: "dueDate", + label: "Termen", + defaultVisible: true, + render: (a) => fmtTs(a.dueDate), + className: "tabular-nums", + }, + { + key: "statusName", + label: "Status", + defaultVisible: true, + render: (a) => a.statusName || a.stateCode || "-", + }, + { + key: "resolutionName", + label: "Rezolutie", + defaultVisible: true, + render: (a) => a.resolutionName || "-", + }, + { + key: "hasSolution", + label: "Solutionat", + defaultVisible: false, + render: (a) => (a.hasSolution === 1 ? "DA" : "NU"), + }, + { + key: "totalFee", + label: "Taxa (lei)", + defaultVisible: false, + render: (a) => (a.totalFee != null ? String(a.totalFee) : "-"), + className: "tabular-nums", + }, + { + key: "uat", + label: "UAT", + defaultVisible: true, + render: (a) => a.uat || "-", + }, + { + key: "orgUnit", + label: "OCPI", + defaultVisible: false, + render: (a) => a.orgUnit || "-", + }, + { + key: "communicationType", + label: "Comunicare", + defaultVisible: false, + render: (a) => a.communicationType || "-", + className: "text-xs", + }, + { + key: "actorName", + label: "Actor curent", + defaultVisible: false, + render: (a) => a.actorName || "-", + }, + { + key: "applicationPk", + label: "Application PK", + defaultVisible: false, + render: (a) => String(a.applicationPk ?? "-"), + className: "font-mono text-xs", + }, +]; + +/* ================================================================== */ +/* RGI Helpers */ +/* ================================================================== */ + +function removeDiacritics(str: string): string { + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); +} + +function matchesSearch(text: string, query: string): boolean { + return removeDiacritics(text.toLowerCase()).includes( + removeDiacritics(query.toLowerCase()), + ); +} + +function sanitize(raw: string): string { + return raw + .replace(/[ăâ]/g, "a") + .replace(/[ĂÂ]/g, "A") + .replace(/[îÎ]/g, "i") + .replace(/[țȚ]/g, "t") + .replace(/[șȘ]/g, "s") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); +} + +/* ================================================================== */ +/* Issued Documents Panel */ +/* ================================================================== */ + +function IssuedDocsPanel({ + applicationPk, + workspaceId, + appNo, +}: { + applicationPk: number; + workspaceId: number; + appNo: number; +}) { + const [docs, setDocs] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [blockedDocPk, setBlockedDocPk] = useState(null); + const [downloadingAll, setDownloadingAll] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(""); + const blockedTimerRef = useRef | null>(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetch( + `/api/eterra/rgi/issued-docs?applicationId=${applicationPk}&workspaceId=${workspaceId}`, + ); + const data = await res.json(); + const items: IssuedDoc[] = Array.isArray(data) + ? data + : data?.content ?? data?.data ?? data?.list ?? []; + if (!cancelled) setDocs(items); + } catch { + if (!cancelled) setError("Eroare la incarcarea documentelor"); + } + if (!cancelled) setLoading(false); + })(); + return () => { cancelled = true; }; + }, [applicationPk, workspaceId]); + + useEffect(() => { + return () => { + if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); + }; + }, []); + + const handleDownloadAll = useCallback(async () => { + if (!docs || docs.length === 0 || downloadingAll) return; + setDownloadingAll(true); + let downloaded = 0; + let blocked = 0; + + const typeCounts: Record = {}; + for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1; + const typeIdx: Record = {}; + + for (const doc of docs) { + const docName = sanitize(doc.docType || doc.documentTypeCode || "Document"); + const ext = (doc.fileExtension || "pdf").toLowerCase(); + const typeKey = doc.docType || "Document"; + typeIdx[typeKey] = (typeIdx[typeKey] || 0) + 1; + const suffix = (typeCounts[typeKey] ?? 0) > 1 ? `_${typeIdx[typeKey]}` : ""; + const filename = `${docName}_${appNo}${suffix}.${ext}`; + setDownloadProgress(`${downloaded + blocked + 1}/${docs.length}: ${doc.docType || "Document"}...`); + + const url = + `/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || workspaceId}` + + `&applicationId=${doc.applicationId || applicationPk}` + + `&documentPk=${doc.documentPk}` + + `&documentTypeId=${doc.documentTypeId}` + + `&docType=${encodeURIComponent(doc.docType || "")}` + + `&appNo=${appNo}`; + + try { + const res = await fetch(url); + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + blocked++; + continue; + } + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + downloaded++; + await new Promise((r) => setTimeout(r, 300)); + } catch { + blocked++; + } + } + + setDownloadProgress( + blocked > 0 + ? `${downloaded} descarcat${downloaded !== 1 ? "e" : ""}, ${blocked} indisponibil${blocked !== 1 ? "e" : ""}` + : `${downloaded} document${downloaded !== 1 ? "e" : ""} descarcat${downloaded !== 1 ? "e" : ""}`, + ); + setDownloadingAll(false); + setTimeout(() => setDownloadProgress(""), 5000); + }, [docs, downloadingAll, workspaceId, applicationPk, appNo]); + + const handleDownload = useCallback( + async (doc: IssuedDoc, e: React.MouseEvent) => { + e.stopPropagation(); + const url = + `/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || workspaceId}` + + `&applicationId=${doc.applicationId || applicationPk}` + + `&documentPk=${doc.documentPk}` + + `&documentTypeId=${doc.documentTypeId}` + + `&docType=${encodeURIComponent(doc.docType || doc.documentTypeCode || "Document")}` + + `&appNo=${appNo}`; + + try { + const res = await fetch(url); + const contentType = res.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const json = await res.json(); + if (json.blocked || json.error) { + setBlockedDocPk(doc.documentPk); + if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); + blockedTimerRef.current = setTimeout(() => setBlockedDocPk(null), 5000); + return; + } + } + + const blob = await res.blob(); + const disposition = res.headers.get("content-disposition") || ""; + let filename = `document_${doc.documentPk}.pdf`; + const match = disposition.match(/filename="?([^";\n]+)"?/); + if (match) { + const decoded = match[1]; + if (decoded) filename = decodeURIComponent(decoded); + } + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + } catch { + setBlockedDocPk(doc.documentPk); + if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); + blockedTimerRef.current = setTimeout(() => setBlockedDocPk(null), 5000); + } + }, + [workspaceId, applicationPk, appNo], + ); + + if (loading) { + return ( +
+ + Se incarca documentele... +
+ ); + } + + if (error) { + return

{error}

; + } + + if (!docs || docs.length === 0) { + return ( +

+ Niciun document eliberat. +

+ ); + } + + return ( +
+
+

+ {docs.length} document{docs.length > 1 ? "e" : ""} eliberat + {docs.length > 1 ? "e" : ""} +

+
+ {downloadProgress && ( + {downloadProgress} + )} + +
+
+ {docs.map((doc, i) => ( +
+
+
+ +
+

+ {doc.docType || doc.documentTypeCode || "Document"} +

+
+ {fmtTs(doc.startDate || doc.lastUpdatedDtm)} + + .{(doc.fileExtension || "PDF").toLowerCase()} + + {doc.digitallySigned === 1 && ( + + + semnat + + )} + {doc.identifierDetails && ( + + {doc.identifierDetails} + + )} +
+
+
+ +
+ {blockedDocPk === doc.documentPk && ( +
+ Documentul nu este inca disponibil pentru descarcare din eTerra +
+ )} +
+ ))} +
+ ); +} + +/* ================================================================== */ +/* RGI Content Tab */ +/* ================================================================== */ + +function RgiContent() { + const [countyId, setCountyId] = useState(127); + const orgUnitId = countyId * 1000 + 2; + const [year, setYear] = useState("2026"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [applications, setApplications] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [expandedPk, setExpandedPk] = useState(null); + const [showColumnPicker, setShowColumnPicker] = useState(false); + const [downloadingAppPk, setDownloadingAppPk] = useState(null); + const [filterMode, setFilterMode] = useState<"all" | "solved" | "confirmed">("solved"); + const [searchQuery, setSearchQuery] = useState(""); + const [sortState, setSortState] = useState(null); + + const [visibleCols, setVisibleCols] = useState>( + () => new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key)), + ); + + const toggleColumn = (key: string) => { + setVisibleCols((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const columns = useMemo( + () => ALL_COLUMNS.filter((c) => visibleCols.has(c.key)), + [visibleCols], + ); + + const downloadAllForApp = useCallback(async (app: App) => { + if (downloadingAppPk) return; + setDownloadingAppPk(app.applicationPk); + try { + const res = await fetch( + `/api/eterra/rgi/issued-docs?applicationId=${app.applicationPk}&workspaceId=${app.workspaceId}`, + ); + const data = await res.json(); + const docs: IssuedDoc[] = Array.isArray(data) + ? data + : data?.content ?? data?.data ?? data?.list ?? []; + + if (docs.length === 0) { + setDownloadingAppPk(null); + return; + } + + const typeCounts: Record = {}; + for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1; + const typeIdx: Record = {}; + + for (const doc of docs) { + const docName = sanitize(doc.docType || doc.documentTypeCode || "Document"); + const ext = (doc.fileExtension || "pdf").toLowerCase(); + const typeKey = doc.docType || "Document"; + typeIdx[typeKey] = (typeIdx[typeKey] || 0) + 1; + const suffix = (typeCounts[typeKey] ?? 0) > 1 ? `_${typeIdx[typeKey]}` : ""; + const filename = `${docName}_${app.appNo}${suffix}.${ext}`; + + const url = + `/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || app.workspaceId}` + + `&applicationId=${doc.applicationId || app.applicationPk}` + + `&documentPk=${doc.documentPk}` + + `&documentTypeId=${doc.documentTypeId}` + + `&docType=${encodeURIComponent(doc.docType || "")}` + + `&appNo=${app.appNo}`; + + try { + const r = await fetch(url); + const ct = r.headers.get("content-type") || ""; + if (ct.includes("application/json")) continue; + const blob = await r.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + await new Promise((resolve) => setTimeout(resolve, 300)); + } catch { + // skip + } + } + } catch { + // silent + } + setDownloadingAppPk(null); + }, [downloadingAppPk]); + + const handleSort = useCallback( + (key: string) => { + setSortState((prev) => { + if (prev && prev.key === key) { + return prev.dir === "asc" ? { key, dir: "desc" } : null; + } + return { key, dir: "asc" }; + }); + }, + [], + ); + + const loadApplications = useCallback(async () => { + setLoading(true); + setError(""); + setApplications([]); + setExpandedPk(null); + try { + const res = await fetch("/api/eterra/rgi/applications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspaceId: countyId, + orgUnitId, + year, + page: 0, + nrElements: 200, + }), + }); + const data = await res.json(); + if (data.error) { + setError(data.error); + return; + } + const items: App[] = Array.isArray(data) + ? data + : data?.content ?? data?.data ?? data?.list ?? []; + setApplications(items); + setTotalCount( + typeof data?.totalElements === "number" + ? data.totalElements + : items.length, + ); + } catch { + setError("Eroare de retea. Verifica conexiunea la eTerra."); + } + setLoading(false); + }, [countyId, orgUnitId, year]); + + const processed = useMemo(() => { + let result = applications; + if (filterMode === "solved") { + result = result.filter((a) => a.hasSolution === 1); + } else if (filterMode === "confirmed") { + result = result.filter((a) => a.stateCode === "CONFIRMED"); + } + + if (searchQuery.trim()) { + const q = searchQuery.trim(); + result = result.filter((app) => + columns.some((col) => matchesSearch(col.render(app), q)), + ); + } + + if (sortState) { + const col = ALL_COLUMNS.find((c) => c.key === sortState.key); + if (col) { + const dir = sortState.dir === "asc" ? 1 : -1; + const dateKeys = new Set(["dueDate", "appDate"]); + result = [...result].sort((a, b) => { + if (dateKeys.has(sortState.key)) { + const va = (a[sortState.key] as number) || 0; + const vb = (b[sortState.key] as number) || 0; + return (va - vb) * dir; + } + const va = col.render(a); + const vb = col.render(b); + const na = parseFloat(va); + const nb = parseFloat(vb); + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * dir; + return va.localeCompare(vb, "ro") * dir; + }); + } + } + + return result; + }, [applications, filterMode, searchQuery, columns, sortState]); + + const SortIcon = ({ colKey }: { colKey: string }) => { + if (!sortState || sortState.key !== colKey) { + return ; + } + if (sortState.dir === "asc") { + return ; + } + return ; + }; + + return ( +
+
+

Documente Eliberate eTerra

+

+ Lucrari depuse cu documente eliberate — descarca direct din eTerra RGI +

+
+ + + +
+
+ + +
+
+ + setYear(e.target.value)} + className="w-20" + /> +
+ + +
+ + {showColumnPicker && ( +
+ {ALL_COLUMNS.map((col) => ( + + ))} +
+ )} + +
+
+ {( + [ + { id: "solved" as const, label: "Solutionate", desc: "lucrari cu solutie" }, + { id: "confirmed" as const, label: "Confirmate", desc: "solutie confirmata" }, + { id: "all" as const, label: "Toate", desc: "" }, + ] as const + ).map((opt) => ( + + ))} +
+ + {applications.length > 0 && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder="Cauta in rezultate..." + className="pl-8 h-8 text-xs" + /> +
+ + {processed.length} din {applications.length} lucrari + {totalCount > applications.length && ` (${totalCount} total)`} + +
+ )} +
+
+
+ + {error && ( + + + + {error} + + + )} + + {loading && ( + + + +

Se incarca lucrarile din eTerra RGI...

+
+
+ )} + + {!loading && processed.length > 0 && ( + + +
+ + + + + {columns.map((col) => ( + + ))} + + + + + {processed.map((app) => { + const pk = app.applicationPk; + const isExpanded = expandedPk === pk; + const solved = app.hasSolution === 1; + + return ( + + setExpandedPk(isExpanded ? null : pk)} + > + + {columns.map((col) => ( + + ))} + + + {isExpanded && ( + + + + )} + + ); + })} + +
handleSort(col.key)} + > + + {col.label} + + +
+ + + + + + +

Nr. {app.appNo} — click descarca toate

+

{app.applicationObject || "-"}

+

Status: {app.statusName || app.stateCode}

+

Rezolutie: {app.resolutionName || "-"}

+

Termen: {fmtTs(app.dueDate)}

+ {app.identifiers && ( +

{app.identifiers}

+ )} +
+
+
+
+ {col.key === "statusName" ? ( + + {col.render(app)} + + ) : col.key === "resolutionName" ? ( + + {col.render(app)} + + ) : ( + col.render(app) + )} + + {isExpanded ? ( + + ) : ( + + )} +
+ +
+
+
+
+ )} + + {!loading && applications.length > 0 && processed.length === 0 && ( + + + +

Nicio lucrare gasita pentru filtrul selectat.

+

Schimba filtrul sau termenul de cautare.

+
+
+ )} + + {!loading && applications.length === 0 && !error && ( + + + +

Apasa "Incarca lucrari" pentru a incepe.

+
+
+ )} +
+ ); +} + +/* ================================================================== */ +/* Simplified Basemap Switcher (3 options only) */ +/* ================================================================== */ + +const PORTAL_BASEMAPS: { id: BasemapId; label: string; icon: typeof MapIcon }[] = [ + { id: "liberty", label: "Harta", icon: MapIcon }, + { id: "dark", label: "Noapte", icon: Moon }, + { id: "google", label: "Google", icon: Satellite }, +]; + +function PortalBasemapSwitcher({ value, onChange }: { value: BasemapId; onChange: (id: BasemapId) => void }) { + return ( +
+ {PORTAL_BASEMAPS.map((b) => { + const Icon = b.icon; + const active = value === b.id; + return ( + + ); + })} +
+ ); +} + +/* ================================================================== */ +/* UAT type for selector */ +/* ================================================================== */ + +type UatItem = { + siruta: string; + name: string; + county: string; + localFeatures: number; +}; + +/* ================================================================== */ +/* Map layer IDs — must match map-viewer.tsx */ +/* ================================================================== */ + +const BASE_LAYERS = [ + "l-terenuri-fill", + "l-terenuri-line", + "l-terenuri-label", + "l-cladiri-fill", + "l-cladiri-line", +]; + +/* ================================================================== */ +/* Harta (Map) Tab */ +/* ================================================================== */ + +type MapLike = { + getLayer(id: string): unknown; + getSource(id: string): unknown; + setFilter(id: string, filter: unknown[] | null): void; + fitBounds( + bounds: [number, number, number, number], + opts?: Record, + ): void; + isStyleLoaded(): boolean; +}; + +function asMap(handle: MapViewerHandle | null): MapLike | null { + const m = handle?.getMap(); + return m ? (m as unknown as MapLike) : null; +} + +function HartaContent() { + const mapHandleRef = useRef(null); + const [basemap, setBasemap] = useState("liberty"); + const [clickedFeature, setClickedFeature] = useState(null); + const [selectionMode, setSelectionMode] = useState("off"); + const [selectedFeatures, setSelectedFeatures] = useState([]); + const [mapReady, setMapReady] = useState(false); + + // UAT selector state + const [uats, setUats] = useState([]); + const [uatsLoading, setUatsLoading] = useState(false); + const [selectedSiruta, setSelectedSiruta] = useState(""); + const [uatSearch, setUatSearch] = useState(""); + const [showUatDropdown, setShowUatDropdown] = useState(false); + const uatInputRef = useRef(null); + + // Sync state + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(""); + + // Bounds state + const boundsRef = useRef<[number, number, number, number] | null>(null); + const appliedSirutaRef = useRef(""); + const prevBoundsSirutaRef = useRef(""); + + // Layer visibility: show terenuri + cladiri + const [layerVisibility] = useState({ + terenuri: true, + cladiri: true, + administrativ: false, + }); + + // Load UATs on mount + useEffect(() => { + setUatsLoading(true); + fetch("/api/eterra/uats") + .then((r) => r.ok ? r.json() : Promise.reject()) + .then((data: { uats: UatItem[] }) => { + setUats(data.uats ?? []); + }) + .catch(() => {}) + .finally(() => setUatsLoading(false)); + }, []); + + // Selected UAT info + const selectedUat = useMemo( + () => uats.find((u) => u.siruta === selectedSiruta), + [uats, selectedSiruta], + ); + + // Filtered UATs for dropdown + const filteredUats = useMemo(() => { + if (!uatSearch.trim()) return uats.slice(0, 50); + const q = removeDiacritics(uatSearch.toLowerCase()); + return uats.filter((u) => + removeDiacritics(u.name.toLowerCase()).includes(q) || + u.siruta.includes(uatSearch) || + removeDiacritics((u.county ?? "").toLowerCase()).includes(q) + ).slice(0, 50); + }, [uats, uatSearch]); + + // Detect map ready + useEffect(() => { + if (!selectedSiruta) return; + const check = setInterval(() => { + const map = asMap(mapHandleRef.current); + if (!map || !map.isStyleLoaded()) { + if (mapReady) { + setMapReady(false); + appliedSirutaRef.current = ""; + } + return; + } + if (!mapReady) { + setMapReady(true); + } + }, 300); + return () => clearInterval(check); + }, [selectedSiruta, mapReady]); + + // Fetch UAT bounds when selected + useEffect(() => { + if (!selectedSiruta) return; + if (prevBoundsSirutaRef.current === selectedSiruta) return; + prevBoundsSirutaRef.current = selectedSiruta; + + fetch(`/api/geoportal/uat-bounds?siruta=${selectedSiruta}`) + .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; + boundsRef.current = [minLng, minLat, maxLng, maxLat]; + const map = asMap(mapHandleRef.current); + if (map) { + map.fitBounds([minLng, minLat, maxLng, maxLat], { padding: 40, duration: 1500 }); + } + } + }) + .catch(() => {}); + }, [selectedSiruta]); + + // When map becomes ready, fitBounds and apply filter + useEffect(() => { + if (!mapReady || !boundsRef.current) return; + const map = asMap(mapHandleRef.current); + if (!map) return; + map.fitBounds(boundsRef.current, { padding: 40, duration: 1500 }); + }, [mapReady]); + + // Apply siruta filter to layers + useEffect(() => { + if (!mapReady || !selectedSiruta) return; + if (appliedSirutaRef.current === selectedSiruta) return; + + const map = asMap(mapHandleRef.current); + if (!map) return; + + appliedSirutaRef.current = selectedSiruta; + const filter = ["==", ["get", "siruta"], selectedSiruta]; + + for (const layerId of BASE_LAYERS) { + try { + if (map.getLayer(layerId)) map.setFilter(layerId, filter); + } catch { /* noop */ } + } + }, [mapReady, selectedSiruta]); + + const handleFeatureClick = useCallback((feature: ClickedFeature | null) => { + if (!feature || !feature.properties) { + setClickedFeature(null); + return; + } + setClickedFeature(feature); + }, []); + + const handleSelectionModeChange = useCallback((mode: SelectionMode) => { + if (mode === "off") { + mapHandleRef.current?.clearSelection(); + setSelectedFeatures([]); + } + setSelectionMode(mode); + }, []); + + const handleSelectUat = (uat: UatItem) => { + setSelectedSiruta(uat.siruta); + setUatSearch(uat.name + (uat.county ? ` (${uat.county})` : "")); + setShowUatDropdown(false); + // Reset map state for new UAT + appliedSirutaRef.current = ""; + prevBoundsSirutaRef.current = ""; + setMapReady(false); + setClickedFeature(null); + setSelectedFeatures([]); + setSelectionMode("off"); + setSyncMsg(""); + }; + + const handleSync = async () => { + if (!selectedSiruta || syncing) return; + setSyncing(true); + setSyncMsg("Se porneste sincronizarea..."); + try { + const res = await fetch("/api/eterra/sync-background", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ siruta: selectedSiruta, mode: "base" }), + }); + const data = await res.json(); + if (res.ok) { + setSyncMsg(`Sincronizare pornita (job: ${data.jobId ?? "?"}). Datele vor aparea dupa finalizare.`); + } else { + setSyncMsg(data.error ?? "Eroare la pornirea sincronizarii"); + } + } catch { + setSyncMsg("Eroare de retea la sincronizare"); + } + setSyncing(false); + }; + + const hasNoData = selectedUat && selectedUat.localFeatures === 0; + + return ( +
+ {!selectedSiruta ? ( + /* No UAT selected — show selector */ +
+ +

Selecteaza un UAT

+

+ Alege o unitate administrativ-teritoriala pentru a vizualiza parcele si cladiri pe harta. +

+
+ + { + setUatSearch(e.target.value); + setShowUatDropdown(true); + }} + onFocus={() => setShowUatDropdown(true)} + placeholder={uatsLoading ? "Se incarca UAT-urile..." : "Cauta UAT (nume, SIRUTA, judet)..."} + className="pl-10" + disabled={uatsLoading} + /> + {showUatDropdown && filteredUats.length > 0 && ( +
+ {filteredUats.map((uat) => ( + + ))} +
+ )} +
+
+ ) : ( + /* UAT selected — show map */ + <> + {hasNoData ? ( + /* No data for this UAT */ +
+ +

Nu exista date GIS pentru {selectedUat?.name}

+

+ Acest UAT nu a fost inca sincronizat. Porneste sincronizarea pentru a descarca parcele si cladiri de la eTerra. +

+
+ + +
+ {syncMsg && ( +

{syncMsg}

+ )} +
+ ) : ( + /* Map view */ +
+ + + {/* Top-left: UAT info + change button */} +
+
+
+

{selectedUat?.name}

+

+ {selectedUat?.county && `${selectedUat.county} - `}SIRUTA: {selectedSiruta} + {selectedUat && selectedUat.localFeatures > 0 && ( + {selectedUat.localFeatures.toLocaleString()} parcele + )} +

+
+ +
+
+ + {/* Top-right: basemap switcher + feature panel */} +
+ + {clickedFeature && selectionMode === "off" && ( + setClickedFeature(null)} /> + )} +
+ + {/* Bottom-left: selection toolbar */} +
+ { + mapHandleRef.current?.clearSelection(); + setSelectedFeatures([]); + }} + /> +
+
+ )} + + )} +
+ ); +} + +/* ================================================================== */ +/* Main Portal Page */ +/* ================================================================== */ + +export default function PortalPage() { + return ( +
+ {/* Header */} + +
+

Portal Cadastral

+ + + Documente RGI + + + Harta + + +
+ + + + + + + + +
+
+ ); +}