2cd35c790d
Mobile (< 640px): - RGI: card-based layout instead of table (shows nr cerere, status, solicitant, termen, rezolutie, UAT in compact card) - Header: compact "Portal" title, smaller tab buttons - Map: selection toolbar centered at bottom (always visible) - UAT info card: smaller text, truncated, doesn't overlap basemap switcher - Feature panel: narrower (w-56 vs w-64) - Filter buttons: smaller text Desktop (>= 640px): - Same table view as before (hidden on mobile) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1638 lines
59 KiB
TypeScript
1638 lines
59 KiB
TypeScript
"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";
|
|
// Simple inline feature panel — no enrichment, no CF extract
|
|
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-full bg-muted/30">
|
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
|
</div>
|
|
),
|
|
},
|
|
);
|
|
|
|
/* ================================================================== */
|
|
/* 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<IssuedDoc[] | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [blockedDocPk, setBlockedDocPk] = useState<number | null>(null);
|
|
const [downloadingAll, setDownloadingAll] = useState(false);
|
|
const [downloadProgress, setDownloadProgress] = useState("");
|
|
const blockedTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, number> = {};
|
|
for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1;
|
|
const typeIdx: Record<string, number> = {};
|
|
|
|
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 (
|
|
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Se incarca documentele...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return <p className="py-3 text-sm text-destructive">{error}</p>;
|
|
}
|
|
|
|
if (!docs || docs.length === 0) {
|
|
return (
|
|
<p className="py-3 text-sm text-muted-foreground">
|
|
Niciun document eliberat.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1.5 py-2">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
{docs.length} document{docs.length > 1 ? "e" : ""} eliberat
|
|
{docs.length > 1 ? "e" : ""}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
{downloadProgress && (
|
|
<span className="text-[11px] text-muted-foreground">{downloadProgress}</span>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
className="gap-1 h-7 text-xs"
|
|
disabled={downloadingAll}
|
|
onClick={(e) => { e.stopPropagation(); void handleDownloadAll(); }}
|
|
>
|
|
{downloadingAll ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Archive className="h-3 w-3" />
|
|
)}
|
|
Descarca toate
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{docs.map((doc, i) => (
|
|
<div key={doc.documentPk || i} className="space-y-0">
|
|
<div className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2 hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">
|
|
{doc.docType || doc.documentTypeCode || "Document"}
|
|
</p>
|
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
<span>{fmtTs(doc.startDate || doc.lastUpdatedDtm)}</span>
|
|
<span className="font-mono opacity-60">
|
|
.{(doc.fileExtension || "PDF").toLowerCase()}
|
|
</span>
|
|
{doc.digitallySigned === 1 && (
|
|
<span className="inline-flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400">
|
|
<Shield className="h-3 w-3" />
|
|
semnat
|
|
</span>
|
|
)}
|
|
{doc.identifierDetails && (
|
|
<span className="truncate max-w-[200px]">
|
|
{doc.identifierDetails}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="gap-1 shrink-0"
|
|
onClick={(e) => void handleDownload(doc, e)}
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
Descarca
|
|
</Button>
|
|
</div>
|
|
{blockedDocPk === doc.documentPk && (
|
|
<div className="mx-3 mt-1 mb-1 px-3 py-1.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
|
|
Documentul nu este inca disponibil pentru descarcare din eTerra
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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<App[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [expandedPk, setExpandedPk] = useState<number | null>(null);
|
|
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
|
const [downloadingAppPk, setDownloadingAppPk] = useState<number | null>(null);
|
|
const [filterMode, setFilterMode] = useState<"all" | "solved" | "confirmed">("solved");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [sortState, setSortState] = useState<SortState>(null);
|
|
|
|
const [visibleCols, setVisibleCols] = useState<Set<string>>(
|
|
() => 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<string, number> = {};
|
|
for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1;
|
|
const typeIdx: Record<string, number> = {};
|
|
|
|
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 <ArrowUpDown className="h-3 w-3 opacity-0 group-hover:opacity-40 transition-opacity" />;
|
|
}
|
|
if (sortState.dir === "asc") {
|
|
return <ArrowUp className="h-3 w-3 text-foreground" />;
|
|
}
|
|
return <ArrowDown className="h-3 w-3 text-foreground" />;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3 sm:space-y-4 max-w-[1400px] mx-auto p-3 sm:p-4">
|
|
<div>
|
|
<h2 className="text-base sm:text-lg font-bold">Documente Eliberate eTerra</h2>
|
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
|
Lucrari depuse cu documente eliberate
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="pt-4 space-y-3">
|
|
<div className="flex items-end gap-3 flex-wrap">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Judet</Label>
|
|
<Select
|
|
value={String(countyId)}
|
|
onValueChange={(v) => setCountyId(parseInt(v, 10))}
|
|
>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="Alege judetul" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{COUNTIES.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">An</Label>
|
|
<Input
|
|
value={year}
|
|
onChange={(e) => setYear(e.target.value)}
|
|
className="w-20"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => void loadApplications()} disabled={loading}>
|
|
{loading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Search className="mr-2 h-4 w-4" />
|
|
)}
|
|
Incarca lucrari
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1"
|
|
onClick={() => setShowColumnPicker(!showColumnPicker)}
|
|
>
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|
Coloane
|
|
</Button>
|
|
</div>
|
|
|
|
{showColumnPicker && (
|
|
<div className="flex flex-wrap gap-1.5 pt-2 border-t">
|
|
{ALL_COLUMNS.map((col) => (
|
|
<button
|
|
key={col.key}
|
|
onClick={() => toggleColumn(col.key)}
|
|
className={cn(
|
|
"px-2 py-1 rounded text-[11px] border transition-colors",
|
|
visibleCols.has(col.key)
|
|
? "bg-foreground/10 border-foreground/20 font-medium"
|
|
: "bg-transparent border-muted-foreground/15 text-muted-foreground hover:border-foreground/20",
|
|
)}
|
|
>
|
|
{col.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 pt-2 border-t flex-wrap">
|
|
<div className="flex gap-1 p-0.5 bg-muted rounded-md">
|
|
{(
|
|
[
|
|
{ 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) => (
|
|
<button
|
|
key={opt.id}
|
|
onClick={() => setFilterMode(opt.id)}
|
|
className={cn(
|
|
"px-2 sm:px-3 py-1 text-[11px] sm:text-xs rounded font-medium transition-colors",
|
|
filterMode === opt.id
|
|
? "bg-background shadow text-foreground"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
title={opt.desc}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{applications.length > 0 && (
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
<div className="relative flex-1 max-w-xs">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Cauta in rezultate..."
|
|
className="pl-8 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{processed.length} din {applications.length} lucrari
|
|
{totalCount > applications.length && ` (${totalCount} total)`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && (
|
|
<Card className="border-destructive">
|
|
<CardContent className="py-3 flex items-center gap-2 text-sm text-destructive">
|
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
{error}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{loading && (
|
|
<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 incarca lucrarile din eTerra RGI...</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Mobile card view */}
|
|
{!loading && processed.length > 0 && (
|
|
<div className="space-y-2 sm:hidden">
|
|
{processed.map((app) => {
|
|
const pk = app.applicationPk;
|
|
const isExpanded = expandedPk === pk;
|
|
const solved = app.hasSolution === 1;
|
|
return (
|
|
<Card key={pk} className={cn(isExpanded && "ring-1 ring-foreground/10")}>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-start gap-2" onClick={() => setExpandedPk(isExpanded ? null : pk)}>
|
|
<button
|
|
type="button"
|
|
className="mt-0.5 shrink-0"
|
|
onClick={(e) => { e.stopPropagation(); void downloadAllForApp(app); }}
|
|
disabled={downloadingAppPk === pk}
|
|
>
|
|
{downloadingAppPk === pk ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
|
|
) : solved ? (
|
|
<Download className="h-4 w-4 text-emerald-500" />
|
|
) : (
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 justify-between">
|
|
<span className="font-mono font-bold text-sm">{app.appNo}</span>
|
|
<Badge
|
|
variant={solved ? "default" : "secondary"}
|
|
className={cn("text-[10px]", solved && "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400")}
|
|
>
|
|
{app.statusName || app.stateCode || "-"}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground truncate">{app.requester || app.deponent || "-"}</p>
|
|
<div className="flex items-center gap-3 text-[11px] text-muted-foreground mt-1">
|
|
<span>Termen: {fmtTs(app.dueDate)}</span>
|
|
<Badge variant="outline" className="text-[9px]">{app.resolutionName || "-"}</Badge>
|
|
{app.uat && <span>{app.uat}</span>}
|
|
</div>
|
|
</div>
|
|
{isExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground shrink-0" /> : <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
|
|
</div>
|
|
{isExpanded && (
|
|
<div className="mt-2 pt-2 border-t">
|
|
<IssuedDocsPanel applicationPk={pk} workspaceId={app.workspaceId} appNo={app.appNo} />
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop table view */}
|
|
{!loading && processed.length > 0 && (
|
|
<Card className="hidden sm:block">
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/40">
|
|
<th className="px-2 py-2 w-8"></th>
|
|
{columns.map((col) => (
|
|
<th
|
|
key={col.key}
|
|
className="px-3 py-2 text-left font-medium text-xs text-muted-foreground whitespace-nowrap cursor-pointer select-none group"
|
|
onClick={() => handleSort(col.key)}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{col.label}
|
|
<SortIcon colKey={col.key} />
|
|
</span>
|
|
</th>
|
|
))}
|
|
<th className="px-2 py-2 w-8"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{processed.map((app) => {
|
|
const pk = app.applicationPk;
|
|
const isExpanded = expandedPk === pk;
|
|
const solved = app.hasSolution === 1;
|
|
|
|
return (
|
|
<React.Fragment key={pk}>
|
|
<tr
|
|
className={cn(
|
|
"border-b cursor-pointer hover:bg-muted/30 transition-colors",
|
|
isExpanded && "bg-muted/20",
|
|
)}
|
|
onClick={() => setExpandedPk(isExpanded ? null : pk)}
|
|
>
|
|
<td className="px-2 py-2.5 w-8">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="focus:outline-none"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void downloadAllForApp(app);
|
|
}}
|
|
disabled={downloadingAppPk === pk}
|
|
>
|
|
{downloadingAppPk === pk ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
|
|
) : solved ? (
|
|
<Download className="h-4 w-4 text-emerald-500" />
|
|
) : (
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="text-xs max-w-xs">
|
|
<p className="font-semibold">Nr. {app.appNo} — click descarca toate</p>
|
|
<p>{app.applicationObject || "-"}</p>
|
|
<p>Status: {app.statusName || app.stateCode}</p>
|
|
<p>Rezolutie: {app.resolutionName || "-"}</p>
|
|
<p>Termen: {fmtTs(app.dueDate)}</p>
|
|
{app.identifiers && (
|
|
<p className="truncate max-w-[250px]">{app.identifiers}</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</td>
|
|
{columns.map((col) => (
|
|
<td
|
|
key={col.key}
|
|
className={cn("px-3 py-2.5 text-sm", col.className)}
|
|
title={col.render(app)}
|
|
>
|
|
{col.key === "statusName" ? (
|
|
<Badge
|
|
variant={solved ? "default" : "secondary"}
|
|
className={cn(
|
|
"text-[10px]",
|
|
solved && "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400",
|
|
)}
|
|
>
|
|
{col.render(app)}
|
|
</Badge>
|
|
) : col.key === "resolutionName" ? (
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{col.render(app)}
|
|
</Badge>
|
|
) : (
|
|
col.render(app)
|
|
)}
|
|
</td>
|
|
))}
|
|
<td className="px-2 py-2.5 w-8">
|
|
{isExpanded ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
{isExpanded && (
|
|
<tr>
|
|
<td
|
|
colSpan={columns.length + 2}
|
|
className="px-4 pb-3 bg-muted/10 border-b"
|
|
>
|
|
<IssuedDocsPanel
|
|
applicationPk={pk}
|
|
workspaceId={app.workspaceId}
|
|
appNo={app.appNo}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!loading && applications.length > 0 && processed.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
<FileText className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
|
<p>Nicio lucrare gasita pentru filtrul selectat.</p>
|
|
<p className="text-xs mt-1">Schimba filtrul sau termenul de cautare.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!loading && applications.length === 0 && !error && (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
<Search className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
|
<p>Apasa "Incarca lucrari" pentru a incepe.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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 (
|
|
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg flex p-0.5 gap-0.5">
|
|
{PORTAL_BASEMAPS.map((b) => {
|
|
const Icon = b.icon;
|
|
const active = value === b.id;
|
|
return (
|
|
<Button
|
|
key={b.id}
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
"px-2 py-1 h-7 text-xs gap-1 rounded-md",
|
|
active && "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
|
|
)}
|
|
onClick={() => onChange(b.id)}
|
|
title={b.label}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">{b.label}</span>
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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;
|
|
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;
|
|
}
|
|
|
|
function HartaContent() {
|
|
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 [mapReady, setMapReady] = useState(false);
|
|
|
|
// UAT selector state
|
|
const [uats, setUats] = useState<UatItem[]>([]);
|
|
const [uatsLoading, setUatsLoading] = useState(false);
|
|
const [selectedSiruta, setSelectedSiruta] = useState("");
|
|
const [uatSearch, setUatSearch] = useState("");
|
|
const [showUatDropdown, setShowUatDropdown] = useState(false);
|
|
const uatInputRef = useRef<HTMLInputElement>(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 boundsFittedForSirutaRef = useRef("");
|
|
const prevBoundsSirutaRef = useRef("");
|
|
|
|
// Layer visibility: show terenuri + cladiri
|
|
const [layerVisibility] = useState<LayerVisibility>({
|
|
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];
|
|
boundsFittedForSirutaRef.current = "";
|
|
const map = asMap(mapHandleRef.current);
|
|
if (map) {
|
|
map.fitBounds([minLng, minLat, maxLng, maxLat], { padding: 40, duration: 1500 });
|
|
boundsFittedForSirutaRef.current = selectedSiruta;
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, [selectedSiruta]);
|
|
|
|
// Hide basemap admin boundaries + our UAT layers
|
|
const cleanedBasemapRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!mapReady) { cleanedBasemapRef.current = false; return; }
|
|
if (cleanedBasemapRef.current) return;
|
|
const map = asMap(mapHandleRef.current);
|
|
if (!map) return;
|
|
cleanedBasemapRef.current = true;
|
|
try {
|
|
const style = (map as unknown as { getStyle(): { layers?: { id: string }[] } }).getStyle();
|
|
if (style?.layers) {
|
|
for (const layer of style.layers) {
|
|
const id = layer.id.toLowerCase();
|
|
if (id.includes("boundary") || id.includes("admin") ||
|
|
(id.includes("place") && !id.includes("place-city") && !id.includes("place-town"))) {
|
|
map.setLayoutProperty(layer.id, "visibility", "none");
|
|
}
|
|
}
|
|
}
|
|
} catch { /* noop */ }
|
|
const uatLayers = [
|
|
"l-uats-z0-line", "l-uats-z5-fill", "l-uats-z5-line",
|
|
"l-uats-z8-fill", "l-uats-z8-line", "l-uats-z8-label",
|
|
"l-uats-z12-fill", "l-uats-z12-line", "l-uats-z12-label",
|
|
];
|
|
for (const lid of uatLayers) {
|
|
try { if (map.getLayer(lid)) map.setLayoutProperty(lid, "visibility", "none"); } catch { /* noop */ }
|
|
}
|
|
}, [mapReady]);
|
|
|
|
// When map becomes ready, fitBounds ONCE per siruta
|
|
useEffect(() => {
|
|
if (!mapReady || !boundsRef.current || !selectedSiruta) return;
|
|
if (boundsFittedForSirutaRef.current === selectedSiruta) return;
|
|
const map = asMap(mapHandleRef.current);
|
|
if (!map) return;
|
|
map.fitBounds(boundsRef.current, { padding: 40, duration: 1500 });
|
|
boundsFittedForSirutaRef.current = selectedSiruta;
|
|
}, [mapReady, selectedSiruta]);
|
|
|
|
// 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 (
|
|
<div className="relative h-[calc(100vh-52px)] w-full">
|
|
{!selectedSiruta ? (
|
|
/* No UAT selected — show selector */
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
|
<MapIcon className="h-16 w-16 text-muted-foreground/30" />
|
|
<h3 className="text-lg font-semibold text-muted-foreground">Selecteaza un UAT</h3>
|
|
<p className="text-sm text-muted-foreground text-center max-w-md">
|
|
Alege o unitate administrativ-teritoriala pentru a vizualiza parcele si cladiri pe harta.
|
|
</p>
|
|
<div className="relative w-80">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
ref={uatInputRef}
|
|
value={uatSearch}
|
|
onChange={(e) => {
|
|
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 && (
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-lg shadow-lg max-h-64 overflow-y-auto z-50">
|
|
{filteredUats.map((uat) => (
|
|
<button
|
|
key={uat.siruta}
|
|
className="w-full text-left px-3 py-2 hover:bg-muted/50 transition-colors text-sm flex justify-between items-center"
|
|
onClick={() => handleSelectUat(uat)}
|
|
>
|
|
<span>
|
|
<span className="font-medium">{uat.name}</span>
|
|
{uat.county && <span className="text-muted-foreground ml-1">({uat.county})</span>}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
{uat.siruta}
|
|
{uat.localFeatures > 0 && (
|
|
<Badge variant="secondary" className="ml-2 text-[10px] h-4 px-1">
|
|
{uat.localFeatures}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* UAT selected — show map */
|
|
<>
|
|
{hasNoData ? (
|
|
/* No data for this UAT */
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
|
<AlertTriangle className="h-12 w-12 text-amber-500/60" />
|
|
<h3 className="text-lg font-semibold">Nu exista date GIS pentru {selectedUat?.name}</h3>
|
|
<p className="text-sm text-muted-foreground text-center max-w-md">
|
|
Acest UAT nu a fost inca sincronizat. Porneste sincronizarea pentru a descarca parcele si cladiri de la eTerra.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleSync} disabled={syncing} className="gap-2">
|
|
{syncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
Sincronizeaza {selectedUat?.name}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSelectedSiruta("");
|
|
setUatSearch("");
|
|
prevBoundsSirutaRef.current = "";
|
|
appliedSirutaRef.current = "";
|
|
}}
|
|
>
|
|
Schimba UAT
|
|
</Button>
|
|
</div>
|
|
{syncMsg && (
|
|
<p className="text-sm text-muted-foreground mt-2">{syncMsg}</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Map view */
|
|
<div className="absolute inset-0">
|
|
<MapViewer
|
|
ref={mapHandleRef}
|
|
className="h-full w-full"
|
|
basemap={basemap}
|
|
selectionType={selectionMode}
|
|
onFeatureClick={handleFeatureClick}
|
|
onSelectionChange={setSelectedFeatures}
|
|
layerVisibility={layerVisibility}
|
|
/>
|
|
|
|
{/* Top-left: UAT info + change button (responsive) */}
|
|
<div className="absolute top-2 left-2 right-[140px] sm:right-auto z-10 flex flex-col gap-2">
|
|
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg px-2 sm:px-3 py-1.5 sm:py-2 flex items-center gap-2">
|
|
<div className="min-w-0">
|
|
<p className="text-xs sm:text-sm font-semibold truncate">{selectedUat?.name}</p>
|
|
<p className="text-[10px] sm:text-[11px] text-muted-foreground truncate">
|
|
{selectedUat?.county && `${selectedUat.county} - `}SIRUTA: {selectedSiruta}
|
|
{selectedUat && selectedUat.localFeatures > 0 && (
|
|
<span className="ml-1 sm:ml-2">{selectedUat.localFeatures.toLocaleString()} parcele</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs shrink-0"
|
|
onClick={() => {
|
|
setSelectedSiruta("");
|
|
setUatSearch("");
|
|
prevBoundsSirutaRef.current = "";
|
|
appliedSirutaRef.current = "";
|
|
setMapReady(false);
|
|
}}
|
|
>
|
|
Schimba
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top-right: basemap switcher + simple feature info */}
|
|
<div className="absolute top-2 right-2 z-10 flex flex-col items-end gap-2">
|
|
<PortalBasemapSwitcher value={basemap} onChange={setBasemap} />
|
|
{clickedFeature && selectionMode === "off" && (
|
|
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg w-56 sm:w-64 overflow-hidden">
|
|
<div className="flex items-center justify-between px-3 py-2 border-b">
|
|
<h3 className="text-sm font-semibold truncate">
|
|
{String(clickedFeature.properties.cadastral_ref ?? clickedFeature.properties.object_id ?? "Parcela")}
|
|
</h3>
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 shrink-0" onClick={() => setClickedFeature(null)}>
|
|
<span className="text-xs">✕</span>
|
|
</Button>
|
|
</div>
|
|
<div className="px-3 py-2 text-xs space-y-1">
|
|
{(() => {
|
|
const p = clickedFeature.properties;
|
|
const sir = String(p.siruta ?? "");
|
|
const cad = String(p.cadastral_ref ?? "");
|
|
const area = Number(p.area_value ?? 0);
|
|
return (
|
|
<>
|
|
{sir && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">SIRUTA</span>
|
|
<span className="font-medium">{sir}</span>
|
|
</div>
|
|
)}
|
|
{cad && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Nr. cadastral</span>
|
|
<span className="font-medium">{cad}</span>
|
|
</div>
|
|
)}
|
|
{area > 0 && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Suprafata</span>
|
|
<span className="font-medium">{area.toLocaleString("ro-RO")} mp</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom: selection toolbar — centered on mobile */}
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 sm:left-3 sm:translate-x-0 z-10">
|
|
<SelectionToolbar
|
|
selectedFeatures={selectedFeatures}
|
|
selectionMode={selectionMode}
|
|
onSelectionModeChange={handleSelectionModeChange}
|
|
onClearSelection={() => {
|
|
mapHandleRef.current?.clearSelection();
|
|
setSelectedFeatures([]);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* Main Portal Page */
|
|
/* ================================================================== */
|
|
|
|
export default function PortalPage() {
|
|
return (
|
|
<div className="fixed inset-0 z-[100] bg-background overflow-auto flex flex-col">
|
|
{/* Header */}
|
|
<Tabs defaultValue="rgi" className="flex-1 flex flex-col gap-0">
|
|
<header className="border-b bg-background/95 backdrop-blur-sm px-3 sm:px-4 py-2 flex items-center gap-2 sm:gap-4 shrink-0">
|
|
<h1 className="text-sm sm:text-lg font-bold whitespace-nowrap">Portal</h1>
|
|
<TabsList className="h-8">
|
|
<TabsTrigger value="rgi" className="text-xs px-2 sm:px-3 h-7">
|
|
RGI
|
|
</TabsTrigger>
|
|
<TabsTrigger value="harta" className="text-xs px-2 sm:px-3 h-7">
|
|
Harta
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</header>
|
|
|
|
<TabsContent value="rgi" className="flex-1 overflow-auto">
|
|
<RgiContent />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="harta" className="flex-1 relative">
|
|
<HartaContent />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|