2886703d0f
React's useDeferredValue lets the input update immediately while deferring the expensive filter (3186 items) to a lower priority. Removes the setTimeout debounce in favor of React's built-in concurrent rendering scheduler. Input stays responsive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4732 lines
204 KiB
TypeScript
4732 lines
204 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useMemo, useRef, useDeferredValue } from "react";
|
||
import {
|
||
Search,
|
||
Download,
|
||
CheckCircle2,
|
||
XCircle,
|
||
Loader2,
|
||
MapPin,
|
||
Layers,
|
||
Sparkles,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
FileDown,
|
||
LogOut,
|
||
Wifi,
|
||
WifiOff,
|
||
ClipboardCopy,
|
||
Trash2,
|
||
Plus,
|
||
RefreshCw,
|
||
Database,
|
||
HardDrive,
|
||
Clock,
|
||
ArrowDownToLine,
|
||
AlertTriangle,
|
||
BarChart3,
|
||
} from "lucide-react";
|
||
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 {
|
||
Tabs,
|
||
TabsContent,
|
||
TabsList,
|
||
TabsTrigger,
|
||
} from "@/shared/components/ui/tabs";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/shared/components/ui/dropdown-menu";
|
||
import { cn } from "@/shared/lib/utils";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/shared/components/ui/tooltip";
|
||
import {
|
||
LAYER_CATALOG,
|
||
LAYER_CATEGORY_LABELS,
|
||
findLayerById,
|
||
type LayerCategory,
|
||
type LayerCatalogItem,
|
||
} from "../services/eterra-layers";
|
||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
||
import { User, FileText, Archive } from "lucide-react";
|
||
import { UatDashboard } from "./uat-dashboard";
|
||
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
|
||
import { EpayOrderButton } from "./epay-order-button";
|
||
import { EpayTab } from "./epay-tab";
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Types */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
type UatEntry = {
|
||
siruta: string;
|
||
name: string;
|
||
county?: string;
|
||
workspacePk?: number;
|
||
localFeatures?: number;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
type ExportProgress = {
|
||
jobId: string;
|
||
downloaded: number;
|
||
total?: number;
|
||
status: "running" | "done" | "error" | "unknown";
|
||
phase?: string;
|
||
message?: string;
|
||
note?: string;
|
||
phaseCurrent?: number;
|
||
phaseTotal?: number;
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Helpers */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const normalizeText = (text: string) =>
|
||
text
|
||
.normalize("NFD")
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.toLowerCase()
|
||
.trim();
|
||
|
||
function formatDate(iso?: string | null) {
|
||
if (!iso) return "—";
|
||
return new Date(iso).toLocaleDateString("ro-RO", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function formatArea(val?: number | null) {
|
||
if (val == null) return "—";
|
||
return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp";
|
||
}
|
||
|
||
/** Format ISO date as DD.MM.YYYY (no time) */
|
||
function formatShortDate(iso?: string | null) {
|
||
if (!iso) return "—";
|
||
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()}`;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Connection Status Pill */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
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ă…"
|
||
: session.connected
|
||
? "eTerra"
|
||
: session.eterraMaintenance
|
||
? "Mentenanță"
|
||
: 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 în mentenanță
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-amber-600/80 dark:text-amber-400/70">
|
||
Platforma ANCPI nu este disponibilă momentan. Conectarea va fi
|
||
reactivată automat când 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ând începi să scrii un UAT.</p>
|
||
<p className="mt-1 text-[11px] opacity-70">
|
||
Credențialele sunt preluate din configurarea serverului.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error detail (only when NOT maintenance — to avoid confusing users) */}
|
||
{!session.connected &&
|
||
connectionError &&
|
||
!session.eterraMaintenance && (
|
||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||
<p>
|
||
Conexiunea automată a eșuat. Verifică credențialele 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">
|
||
{" "}
|
||
— {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>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Main Component */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
export function ParcelSyncModule() {
|
||
/* ── Server session ─────────────────────────────────────────── */
|
||
const [session, setSession] = useState<SessionStatus>({
|
||
connected: false,
|
||
activeJobCount: 0,
|
||
});
|
||
const [connecting, setConnecting] = useState(false);
|
||
const [connectionError, setConnectionError] = useState("");
|
||
const autoConnectAttempted = useRef(false);
|
||
const sessionPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
/* ── UAT autocomplete ───────────────────────────────────────── */
|
||
const [uatData, setUatData] = useState<UatEntry[]>([]);
|
||
const [uatQuery, setUatQuery] = useState("");
|
||
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
|
||
const [showUatResults, setShowUatResults] = useState(false);
|
||
const [siruta, setSiruta] = useState("");
|
||
const [workspacePk, setWorkspacePk] = useState<number | null>(null);
|
||
const uatRef = useRef<HTMLDivElement>(null);
|
||
|
||
/* ── Export state ────────────────────────────────────────────── */
|
||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(
|
||
null,
|
||
);
|
||
const [phaseTrail, setPhaseTrail] = useState<string[]>([]);
|
||
const [exporting, setExporting] = useState(false);
|
||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
/* ── Layer catalog UI ───────────────────────────────────────── */
|
||
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(""); // siruta for which counts were fetched
|
||
const [layerHistory, setLayerHistory] = useState<
|
||
{
|
||
layerId: string;
|
||
label: string;
|
||
count: number;
|
||
time: string;
|
||
siruta: string;
|
||
}[]
|
||
>([]);
|
||
|
||
/* ── Sync status ────────────────────────────────────────────── */
|
||
type SyncRunInfo = {
|
||
id: string;
|
||
layerId: string;
|
||
status: string;
|
||
totalRemote: number;
|
||
totalLocal: number;
|
||
newFeatures: number;
|
||
removedFeatures: number;
|
||
startedAt: string;
|
||
completedAt?: string;
|
||
};
|
||
const [syncLocalCounts, setSyncLocalCounts] = useState<
|
||
Record<string, number>
|
||
>({});
|
||
const [syncRuns, setSyncRuns] = useState<SyncRunInfo[]>([]);
|
||
const [syncingSiruta, setSyncingSiruta] = useState("");
|
||
const [syncingLayer, setSyncingLayer] = useState<string | null>(null);
|
||
const [syncProgress, setSyncProgress] = useState("");
|
||
const [exportingLocal, setExportingLocal] = useState(false);
|
||
const refreshSyncRef = useRef<(() => void) | null>(null);
|
||
|
||
/* ── Global DB summary (all UATs) ────────────────────────────── */
|
||
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;
|
||
};
|
||
type DbSummary = {
|
||
uats: DbUatSummary[];
|
||
totalFeatures: number;
|
||
totalUats: number;
|
||
};
|
||
const [dbSummary, setDbSummary] = useState<DbSummary | null>(null);
|
||
const [dbSummaryLoading, setDbSummaryLoading] = useState(false);
|
||
|
||
/* ── PostGIS setup ───────────────────────────────────────────── */
|
||
const [postgisRunning, setPostgisRunning] = useState(false);
|
||
const [postgisResult, setPostgisResult] = useState<{
|
||
success: boolean;
|
||
message?: string;
|
||
details?: Record<string, unknown>;
|
||
error?: string;
|
||
} | null>(null);
|
||
|
||
/* ── Parcel search tab ──────────────────────────────────────── */
|
||
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">(
|
||
"cadastral",
|
||
);
|
||
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
||
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||
const [featuresSearch, setFeaturesSearch] = useState("");
|
||
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||
const [searchError, setSearchError] = useState("");
|
||
/* owner search */
|
||
const [ownerSearch, setOwnerSearch] = useState("");
|
||
const [ownerResults, setOwnerResults] = useState<OwnerSearchResult[]>([]);
|
||
const [ownerLoading, setOwnerLoading] = useState(false);
|
||
const [ownerError, setOwnerError] = useState("");
|
||
const [ownerNote, setOwnerNote] = useState("");
|
||
/* dashboard */
|
||
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
|
||
|
||
/* ── ePay status (for CF extract features) ──────────────────── */
|
||
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({ connected: false });
|
||
/** CF status map: nrCadastral -> "valid" | "expired" | "none" | "processing" */
|
||
const [cfStatusMap, setCfStatusMap] = useState<Record<string, string>>({});
|
||
/** Latest completed extract IDs per nrCadastral */
|
||
const [cfLatestIds, setCfLatestIds] = useState<Record<string, string>>({});
|
||
/** Expiry dates per nrCadastral (ISO string) */
|
||
const [cfExpiryDates, setCfExpiryDates] = useState<Record<string, string>>({});
|
||
/** Whether we're currently loading CF statuses */
|
||
const [cfStatusLoading, setCfStatusLoading] = useState(false);
|
||
/** List CF batch order state */
|
||
const [listCfOrdering, setListCfOrdering] = useState(false);
|
||
const [listCfOrderResult, setListCfOrderResult] = useState("");
|
||
/** Downloading ZIP state */
|
||
const [listCfDownloading, setListCfDownloading] = useState(false);
|
||
|
||
/* ── No-geometry import option ──────────────────────────────── */
|
||
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
||
const [noGeomScanning, setNoGeomScanning] = useState(false);
|
||
const [noGeomScan, setNoGeomScan] = useState<{
|
||
totalImmovables: number;
|
||
withGeometry: number;
|
||
remoteGisCount: number;
|
||
remoteCladiriCount: number;
|
||
noGeomCount: number;
|
||
matchedByRef: number;
|
||
matchedById: number;
|
||
qualityBreakdown: {
|
||
withCadRef: number;
|
||
withPaperCad: number;
|
||
withPaperLb: number;
|
||
withLandbook: number;
|
||
withArea: number;
|
||
withActiveStatus: number;
|
||
useful: number;
|
||
empty: number;
|
||
};
|
||
localDbTotal: number;
|
||
localDbWithGeom: number;
|
||
localDbNoGeom: number;
|
||
localDbEnriched: number;
|
||
localDbEnrichedComplete: number;
|
||
localSyncFresh: boolean;
|
||
scannedAt: string;
|
||
} | null>(null);
|
||
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done
|
||
|
||
/* ── Background sync state ──────────────────────────────────── */
|
||
const [bgJobId, setBgJobId] = useState<string | null>(null);
|
||
const [bgProgress, setBgProgress] = useState<ExportProgress | null>(null);
|
||
const [bgPhaseTrail, setBgPhaseTrail] = useState<string[]>([]);
|
||
const bgPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
const [downloadingFromDb, setDownloadingFromDb] = useState(false);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Load UAT data + check server session on mount */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const fetchSession = useCallback(async () => {
|
||
try {
|
||
const res = await fetch("/api/eterra/session");
|
||
const data = (await res.json()) as SessionStatus;
|
||
setSession((prev) => {
|
||
// If eTerra was in maintenance but is now back online, reset auto-connect
|
||
if (
|
||
prev.eterraMaintenance &&
|
||
data.eterraAvailable &&
|
||
!data.eterraMaintenance
|
||
) {
|
||
autoConnectAttempted.current = false;
|
||
}
|
||
return data;
|
||
});
|
||
if (data.connected) setConnectionError("");
|
||
return data;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Load UATs from local DB (fast — no eTerra needed)
|
||
fetch("/api/eterra/uats")
|
||
.then((res) => res.json())
|
||
.then((data: { uats?: UatEntry[]; total?: number }) => {
|
||
if (data.uats && data.uats.length > 0) {
|
||
setUatData(data.uats);
|
||
} else {
|
||
// DB empty — seed from uat.json via POST, then load from uat.json
|
||
fetch("/api/eterra/uats", { method: "POST" }).catch(() => {});
|
||
fetch("/uat.json")
|
||
.then((res) => res.json())
|
||
.then((fallback: UatEntry[]) => setUatData(fallback))
|
||
.catch(() => {});
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// API failed — fall back to static uat.json
|
||
fetch("/uat.json")
|
||
.then((res) => res.json())
|
||
.then((fallback: UatEntry[]) => setUatData(fallback))
|
||
.catch(() => {});
|
||
});
|
||
|
||
// Check existing server session on mount
|
||
void fetchSession();
|
||
|
||
// Poll session every 30s to stay in sync with other clients
|
||
sessionPollRef.current = setInterval(() => void fetchSession(), 30_000);
|
||
return () => {
|
||
if (sessionPollRef.current) clearInterval(sessionPollRef.current);
|
||
};
|
||
}, [fetchSession]);
|
||
|
||
/* ── Fetch global DB summary ─────────────────────────────────── */
|
||
const fetchDbSummary = useCallback(async () => {
|
||
setDbSummaryLoading(true);
|
||
try {
|
||
const res = await fetch("/api/eterra/db-summary");
|
||
const data = (await res.json()) as DbSummary;
|
||
if (data.uats) setDbSummary(data);
|
||
} catch {
|
||
// silent
|
||
}
|
||
setDbSummaryLoading(false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void fetchDbSummary();
|
||
}, [fetchDbSummary]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Reload UAT data when session connects (county data may */
|
||
/* have been populated by the login flow) */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const prevConnected = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (session.connected && !prevConnected.current) {
|
||
// Just connected — reload UATs after a short delay to let
|
||
// the server-side county refresh finish
|
||
const timer = setTimeout(() => {
|
||
fetch("/api/eterra/uats")
|
||
.then((res) => res.json())
|
||
.then((data: { uats?: UatEntry[] }) => {
|
||
if (data.uats && data.uats.length > 0) setUatData(data.uats);
|
||
})
|
||
.catch(() => {});
|
||
}, 5000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
prevConnected.current = session.connected;
|
||
}, [session.connected]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* UAT autocomplete filter */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
// useDeferredValue lets React prioritize the input update over the filter
|
||
const deferredUatQuery = useDeferredValue(uatQuery);
|
||
|
||
useEffect(() => {
|
||
const raw = deferredUatQuery.trim();
|
||
if (raw.length < 2) {
|
||
setUatResults([]);
|
||
return;
|
||
}
|
||
const isDigit = /^\d+$/.test(raw);
|
||
const query = normalizeText(raw);
|
||
const nameMatches: typeof uatData = [];
|
||
const countyOnlyMatches: typeof uatData = [];
|
||
|
||
for (const item of uatData) {
|
||
if (isDigit) {
|
||
if (item.siruta.startsWith(raw)) nameMatches.push(item);
|
||
} else {
|
||
const nameMatch = normalizeText(item.name).includes(query);
|
||
const countyMatch =
|
||
item.county && normalizeText(item.county).includes(query);
|
||
if (nameMatch) nameMatches.push(item);
|
||
else if (countyMatch) countyOnlyMatches.push(item);
|
||
}
|
||
}
|
||
|
||
const results = [...nameMatches, ...countyOnlyMatches].slice(0, 12);
|
||
setUatResults(results);
|
||
}, [deferredUatQuery, uatData]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Auto-connect: trigger on first UAT keystroke */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const triggerAutoConnect = useCallback(async () => {
|
||
if (session.connected || connecting || autoConnectAttempted.current) return;
|
||
// Don't attempt login when eTerra is in maintenance
|
||
if (session.eterraMaintenance) return;
|
||
autoConnectAttempted.current = true;
|
||
setConnecting(true);
|
||
setConnectionError("");
|
||
try {
|
||
const res = await fetch("/api/eterra/session", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: "connect" }),
|
||
});
|
||
const data = (await res.json()) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
maintenance?: boolean;
|
||
};
|
||
if (data.success) {
|
||
await fetchSession();
|
||
} else if (data.maintenance) {
|
||
// eTerra is in maintenance — set flag, DON'T show as connection error
|
||
setSession((prev) => ({
|
||
...prev,
|
||
eterraMaintenance: true,
|
||
eterraAvailable: false,
|
||
eterraHealthMessage: data.error ?? "eTerra în mentenanță",
|
||
}));
|
||
// Allow retry later when maintenance ends
|
||
autoConnectAttempted.current = false;
|
||
} else {
|
||
setConnectionError(data.error ?? "Eroare conectare");
|
||
}
|
||
} catch {
|
||
setConnectionError("Eroare rețea");
|
||
}
|
||
setConnecting(false);
|
||
}, [session.connected, session.eterraMaintenance, connecting, fetchSession]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Disconnect */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const handleDisconnect = useCallback(async () => {
|
||
try {
|
||
const res = await fetch("/api/eterra/session", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: "disconnect" }),
|
||
});
|
||
const data = (await res.json()) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
};
|
||
if (data.success) {
|
||
setSession({ connected: false, activeJobCount: 0 });
|
||
autoConnectAttempted.current = false;
|
||
} else {
|
||
// Jobs are running — show warning
|
||
setConnectionError(data.error ?? "Nu se poate deconecta");
|
||
}
|
||
} catch {
|
||
setConnectionError("Eroare rețea");
|
||
}
|
||
}, []);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Progress polling */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
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);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (pollingRef.current) clearInterval(pollingRef.current);
|
||
};
|
||
}, []);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Export bundle (base / magic) */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const handleExportBundle = useCallback(
|
||
async (mode: "base" | "magic") => {
|
||
if (!siruta || exporting) return;
|
||
const jobId = crypto.randomUUID();
|
||
setExportJobId(jobId);
|
||
setExportProgress(null);
|
||
setPhaseTrail([]);
|
||
setExporting(true);
|
||
|
||
startPolling(jobId);
|
||
|
||
try {
|
||
const res = await fetch("/api/eterra/export-bundle", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
siruta,
|
||
jobId,
|
||
mode,
|
||
includeNoGeometry: includeNoGeom,
|
||
}),
|
||
});
|
||
|
||
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] ??
|
||
(mode === "magic"
|
||
? `eterra_uat_${siruta}_magic.zip`
|
||
: `eterra_uat_${siruta}_terenuri_cladiri.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);
|
||
|
||
// 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ărcare completă — ${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;
|
||
}
|
||
setExporting(false);
|
||
// Refresh sync status — data was synced to DB
|
||
refreshSyncRef.current?.();
|
||
},
|
||
[siruta, exporting, startPolling, includeNoGeom],
|
||
);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* No-geometry scan */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const handleNoGeomScan = useCallback(
|
||
async (targetSiruta?: string) => {
|
||
const s = targetSiruta ?? siruta;
|
||
if (!s) return;
|
||
setNoGeomScanning(true);
|
||
setNoGeomScan(null);
|
||
setNoGeomScanSiruta(s);
|
||
const emptyQuality = {
|
||
withCadRef: 0,
|
||
withPaperCad: 0,
|
||
withPaperLb: 0,
|
||
withLandbook: 0,
|
||
withArea: 0,
|
||
withActiveStatus: 0,
|
||
useful: 0,
|
||
empty: 0,
|
||
};
|
||
const emptyResult = {
|
||
totalImmovables: 0,
|
||
withGeometry: 0,
|
||
remoteGisCount: 0,
|
||
remoteCladiriCount: 0,
|
||
noGeomCount: 0,
|
||
matchedByRef: 0,
|
||
matchedById: 0,
|
||
qualityBreakdown: emptyQuality,
|
||
localDbTotal: 0,
|
||
localDbWithGeom: 0,
|
||
localDbNoGeom: 0,
|
||
localDbEnriched: 0,
|
||
localDbEnrichedComplete: 0,
|
||
localSyncFresh: false,
|
||
scannedAt: "",
|
||
};
|
||
try {
|
||
// 2min timeout — scan is informational, should not block the page
|
||
const scanAbort = new AbortController();
|
||
const scanTimer = setTimeout(() => scanAbort.abort(), 120_000);
|
||
const res = await fetch("/api/eterra/no-geom-scan", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
siruta: s,
|
||
workspacePk: workspacePk ?? undefined,
|
||
}),
|
||
signal: scanAbort.signal,
|
||
});
|
||
clearTimeout(scanTimer);
|
||
const data = (await res.json()) as Record<string, unknown>;
|
||
if (data.error) {
|
||
console.warn("[no-geom-scan]", data.error);
|
||
setNoGeomScan(emptyResult);
|
||
} else {
|
||
const qb = (data.qualityBreakdown ?? {}) as Record<string, unknown>;
|
||
setNoGeomScan({
|
||
totalImmovables: Number(data.totalImmovables ?? 0),
|
||
withGeometry: Number(data.withGeometry ?? 0),
|
||
remoteGisCount: Number(data.remoteGisCount ?? 0),
|
||
remoteCladiriCount: Number(data.remoteCladiriCount ?? 0),
|
||
noGeomCount: Number(data.noGeomCount ?? 0),
|
||
matchedByRef: Number(data.matchedByRef ?? 0),
|
||
matchedById: Number(data.matchedById ?? 0),
|
||
qualityBreakdown: {
|
||
withCadRef: Number(qb.withCadRef ?? 0),
|
||
withPaperCad: Number(qb.withPaperCad ?? 0),
|
||
withPaperLb: Number(qb.withPaperLb ?? 0),
|
||
withLandbook: Number(qb.withLandbook ?? 0),
|
||
withArea: Number(qb.withArea ?? 0),
|
||
withActiveStatus: Number(qb.withActiveStatus ?? 0),
|
||
useful: Number(qb.useful ?? 0),
|
||
empty: Number(qb.empty ?? 0),
|
||
},
|
||
localDbTotal: Number(data.localDbTotal ?? 0),
|
||
localDbWithGeom: Number(data.localDbWithGeom ?? 0),
|
||
localDbNoGeom: Number(data.localDbNoGeom ?? 0),
|
||
localDbEnriched: Number(data.localDbEnriched ?? 0),
|
||
localDbEnrichedComplete: Number(data.localDbEnrichedComplete ?? 0),
|
||
localSyncFresh: Boolean(data.localSyncFresh),
|
||
scannedAt: String(data.scannedAt ?? new Date().toISOString()),
|
||
});
|
||
}
|
||
} catch (err) {
|
||
// Distinguish timeout from other errors for the user
|
||
const isTimeout =
|
||
err instanceof DOMException && err.name === "AbortError";
|
||
if (isTimeout) {
|
||
console.warn(
|
||
"[no-geom-scan] Timeout after 2 min — server eTerra lent",
|
||
);
|
||
}
|
||
setNoGeomScan({
|
||
...emptyResult,
|
||
scannedAt: isTimeout ? "timeout" : "",
|
||
});
|
||
}
|
||
setNoGeomScanning(false);
|
||
},
|
||
[siruta, workspacePk],
|
||
);
|
||
|
||
// Auto-scan for no-geometry parcels when UAT is selected + connected
|
||
const noGeomAutoScanRef = useRef("");
|
||
useEffect(() => {
|
||
if (!siruta || !session.connected) return;
|
||
// Don't re-scan if we already scanned (or are scanning) this siruta
|
||
if (noGeomAutoScanRef.current === siruta) return;
|
||
noGeomAutoScanRef.current = siruta;
|
||
void handleNoGeomScan(siruta);
|
||
}, [siruta, session.connected, handleNoGeomScan]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Layer feature counts */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
// Load history from localStorage on mount
|
||
useEffect(() => {
|
||
try {
|
||
const raw = localStorage.getItem("parcel-sync:layer-history");
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw) as typeof layerHistory;
|
||
// Only keep today's entries
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const todayEntries = parsed.filter(
|
||
(e) => e.time.slice(0, 10) === today,
|
||
);
|
||
setLayerHistory(todayEntries);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, []);
|
||
|
||
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]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Sync status — load local feature counts for current UAT */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const fetchSyncStatus = useCallback(async () => {
|
||
if (!siruta) return;
|
||
try {
|
||
const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`);
|
||
const data = (await res.json()) as {
|
||
localCounts?: Record<string, number>;
|
||
runs?: SyncRunInfo[];
|
||
};
|
||
if (data.localCounts) setSyncLocalCounts(data.localCounts);
|
||
if (data.runs) setSyncRuns(data.runs);
|
||
setSyncingSiruta(siruta);
|
||
} catch {
|
||
// silent
|
||
}
|
||
}, [siruta]);
|
||
|
||
// Keep ref in sync so callbacks defined earlier can trigger refresh
|
||
refreshSyncRef.current = () => void fetchSyncStatus();
|
||
|
||
// Auto-fetch sync status when siruta changes
|
||
useEffect(() => {
|
||
if (siruta && /^\d+$/.test(siruta)) {
|
||
void fetchSyncStatus();
|
||
}
|
||
}, [siruta, fetchSyncStatus]);
|
||
|
||
const handleSyncLayer = useCallback(
|
||
async (layerId: string) => {
|
||
if (!siruta || syncingLayer) return;
|
||
setSyncingLayer(layerId);
|
||
setSyncProgress("Sincronizare pornită…");
|
||
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 — ${data.newFeatures ?? 0} noi, ${data.removedFeatures ?? 0} șterse, ${data.totalLocal ?? 0} total local`,
|
||
);
|
||
// Refresh sync status
|
||
await fetchSyncStatus();
|
||
}
|
||
} catch {
|
||
setSyncProgress("Eroare rețea");
|
||
}
|
||
// Clear progress after 8s
|
||
setTimeout(() => {
|
||
setSyncingLayer(null);
|
||
setSyncProgress("");
|
||
}, 8_000);
|
||
},
|
||
[siruta, syncingLayer, fetchSyncStatus],
|
||
);
|
||
|
||
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],
|
||
);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Background sync — fire-and-forget server-side processing */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const startBgPolling = useCallback(
|
||
(jid: string) => {
|
||
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
|
||
bgPollingRef.current = setInterval(async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
|
||
);
|
||
const data = (await res.json()) as ExportProgress;
|
||
setBgProgress(data);
|
||
if (data.phase) {
|
||
setBgPhaseTrail((prev) => {
|
||
if (prev[prev.length - 1] === data.phase) return prev;
|
||
return [...prev, data.phase!];
|
||
});
|
||
}
|
||
if (data.status === "done" || data.status === "error") {
|
||
if (bgPollingRef.current) {
|
||
clearInterval(bgPollingRef.current);
|
||
bgPollingRef.current = null;
|
||
}
|
||
// Clean localStorage marker
|
||
try {
|
||
localStorage.removeItem("parcel-sync:bg-job");
|
||
} catch {
|
||
/* */
|
||
}
|
||
// Refresh sync status and DB summary
|
||
refreshSyncRef.current?.();
|
||
void fetchDbSummary();
|
||
}
|
||
} catch {
|
||
/* ignore polling errors */
|
||
}
|
||
}, 1500);
|
||
},
|
||
[fetchDbSummary],
|
||
);
|
||
|
||
// Cleanup bg polling on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
|
||
};
|
||
}, []);
|
||
|
||
// Recover background job from localStorage on mount
|
||
useEffect(() => {
|
||
try {
|
||
const raw = localStorage.getItem("parcel-sync:bg-job");
|
||
if (!raw) return;
|
||
const saved = JSON.parse(raw) as {
|
||
jobId: string;
|
||
siruta: string;
|
||
startedAt: string;
|
||
};
|
||
// Ignore jobs older than 8 hours
|
||
const age = Date.now() - new Date(saved.startedAt).getTime();
|
||
if (age > 8 * 60 * 60 * 1000) {
|
||
localStorage.removeItem("parcel-sync:bg-job");
|
||
return;
|
||
}
|
||
// Check if job is still running
|
||
void (async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/eterra/progress?jobId=${encodeURIComponent(saved.jobId)}`,
|
||
);
|
||
const data = (await res.json()) as ExportProgress;
|
||
if (data.status === "running") {
|
||
setBgJobId(saved.jobId);
|
||
setBgProgress(data);
|
||
if (data.phase) setBgPhaseTrail([data.phase]);
|
||
startBgPolling(saved.jobId);
|
||
} else if (data.status === "done") {
|
||
setBgJobId(saved.jobId);
|
||
setBgProgress(data);
|
||
if (data.phase) setBgPhaseTrail(["Sincronizare completă"]);
|
||
localStorage.removeItem("parcel-sync:bg-job");
|
||
} else {
|
||
localStorage.removeItem("parcel-sync:bg-job");
|
||
}
|
||
} catch {
|
||
localStorage.removeItem("parcel-sync:bg-job");
|
||
}
|
||
})();
|
||
} catch {
|
||
/* */
|
||
}
|
||
}, [startBgPolling]);
|
||
|
||
const handleSyncBackground = useCallback(
|
||
async (mode: "base" | "magic") => {
|
||
if (!siruta || exporting) return;
|
||
try {
|
||
const res = await fetch("/api/eterra/sync-background", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
siruta,
|
||
mode,
|
||
includeNoGeometry: includeNoGeom,
|
||
}),
|
||
});
|
||
const data = (await res.json()) as { jobId?: string; error?: string };
|
||
if (!res.ok || data.error) {
|
||
setSyncProgress(data.error ?? `Eroare ${res.status}`);
|
||
setTimeout(() => setSyncProgress(""), 5_000);
|
||
return;
|
||
}
|
||
const jid = data.jobId!;
|
||
setBgJobId(jid);
|
||
setBgProgress({
|
||
jobId: jid,
|
||
downloaded: 0,
|
||
total: 100,
|
||
status: "running",
|
||
phase: "Pornire sincronizare fundal",
|
||
});
|
||
setBgPhaseTrail(["Pornire sincronizare fundal"]);
|
||
// Persist in localStorage so we can recover on page refresh
|
||
try {
|
||
localStorage.setItem(
|
||
"parcel-sync:bg-job",
|
||
JSON.stringify({
|
||
jobId: jid,
|
||
siruta,
|
||
startedAt: new Date().toISOString(),
|
||
}),
|
||
);
|
||
} catch {
|
||
/* */
|
||
}
|
||
startBgPolling(jid);
|
||
} catch (error) {
|
||
const msg = error instanceof Error ? error.message : "Eroare rețea";
|
||
setSyncProgress(msg);
|
||
setTimeout(() => setSyncProgress(""), 5_000);
|
||
}
|
||
},
|
||
[siruta, exporting, includeNoGeom, startBgPolling],
|
||
);
|
||
|
||
const handleDownloadFromDb = useCallback(
|
||
async (mode: "base" | "magic") => {
|
||
if (!siruta || downloadingFromDb) return;
|
||
setDownloadingFromDb(true);
|
||
try {
|
||
const res = await fetch("/api/eterra/export-local", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ siruta, mode }),
|
||
});
|
||
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}_${mode}_local.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 descărcare";
|
||
setSyncProgress(msg);
|
||
setTimeout(() => setSyncProgress(""), 5_000);
|
||
}
|
||
setDownloadingFromDb(false);
|
||
},
|
||
[siruta, downloadingFromDb],
|
||
);
|
||
|
||
// Sync multiple layers sequentially (for "sync all" / "sync category")
|
||
const [syncQueue, setSyncQueue] = useState<string[]>([]);
|
||
const syncQueueRef = useRef<string[]>([]);
|
||
|
||
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}…`,
|
||
);
|
||
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țea");
|
||
}
|
||
// Remove from queue
|
||
syncQueueRef.current = syncQueueRef.current.filter(
|
||
(id) => id !== layerId,
|
||
);
|
||
setSyncQueue([...syncQueueRef.current]);
|
||
}
|
||
// Done — refresh status
|
||
await fetchSyncStatus();
|
||
setSyncingLayer(null);
|
||
setSyncProgress("");
|
||
setSyncQueue([]);
|
||
syncQueueRef.current = [];
|
||
},
|
||
[siruta, syncingLayer, syncQueue.length, fetchSyncStatus],
|
||
);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* PostGIS setup (one-time) */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
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]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Export individual layer */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const handleExportLayer = useCallback(
|
||
async (layerId: string) => {
|
||
if (!siruta || downloadingLayer) return;
|
||
setDownloadingLayer(layerId);
|
||
const jobId = crypto.randomUUID();
|
||
setExportJobId(jobId);
|
||
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ărcare completă — ${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
|
||
refreshSyncRef.current?.();
|
||
},
|
||
[siruta, downloadingLayer, startPolling],
|
||
);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Search parcels by cadastral number (eTerra app API) */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
const handleSearch = useCallback(async () => {
|
||
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||
if (!featuresSearch.trim()) {
|
||
setSearchResults([]);
|
||
setSearchError("");
|
||
return;
|
||
}
|
||
setLoadingFeatures(true);
|
||
setSearchError("");
|
||
try {
|
||
const res = await fetch("/api/eterra/search", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
siruta,
|
||
search: featuresSearch.trim(),
|
||
...(workspacePk ? { workspacePk } : {}),
|
||
}),
|
||
});
|
||
const data = (await res.json()) as {
|
||
results?: ParcelDetail[];
|
||
total?: number;
|
||
error?: string;
|
||
};
|
||
if (data.error) {
|
||
setSearchResults([]);
|
||
setSearchError(data.error);
|
||
} else {
|
||
setSearchResults(data.results ?? []);
|
||
setSearchError("");
|
||
}
|
||
} catch {
|
||
setSearchError("Eroare de rețea.");
|
||
}
|
||
setLoadingFeatures(false);
|
||
}, [siruta, featuresSearch, workspacePk]);
|
||
|
||
/** Fetch CF extract status for a set of cadastral numbers */
|
||
const fetchCfStatuses = useCallback(async (cadastralNumbers: string[]) => {
|
||
if (cadastralNumbers.length === 0) return;
|
||
setCfStatusLoading(true);
|
||
try {
|
||
const nrs = cadastralNumbers.join(",");
|
||
const res = await fetch(`/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrs)}&limit=1`);
|
||
const data = (await res.json()) as {
|
||
statusMap?: Record<string, string>;
|
||
latestById?: Record<string, { id: string; expiresAt: string | null }>;
|
||
};
|
||
if (data.statusMap) {
|
||
setCfStatusMap((prev) => ({ ...prev, ...data.statusMap }));
|
||
}
|
||
if (data.latestById) {
|
||
const idMap: Record<string, string> = {};
|
||
const expiryMap: Record<string, string> = {};
|
||
for (const [nr, rec] of Object.entries(data.latestById)) {
|
||
if (rec && typeof rec === "object" && "id" in rec) {
|
||
idMap[nr] = (rec as { id: string }).id;
|
||
const expires = (rec as { expiresAt: string | null }).expiresAt;
|
||
if (expires) {
|
||
expiryMap[nr] = expires;
|
||
}
|
||
}
|
||
}
|
||
setCfLatestIds((prev) => ({ ...prev, ...idMap }));
|
||
setCfExpiryDates((prev) => ({ ...prev, ...expiryMap }));
|
||
}
|
||
} catch {
|
||
/* silent */
|
||
} finally {
|
||
setCfStatusLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
/** Refresh CF statuses for current search results + list items */
|
||
const refreshCfStatuses = useCallback(() => {
|
||
const allNrs = new Set<string>();
|
||
for (const r of searchResults) {
|
||
if (r.nrCad) allNrs.add(r.nrCad);
|
||
}
|
||
for (const p of searchList) {
|
||
if (p.nrCad) allNrs.add(p.nrCad);
|
||
}
|
||
if (allNrs.size > 0) {
|
||
void fetchCfStatuses(Array.from(allNrs));
|
||
}
|
||
}, [searchResults, searchList, fetchCfStatuses]);
|
||
|
||
// Auto-fetch CF statuses when search results change
|
||
useEffect(() => {
|
||
const nrs = searchResults.map((r) => r.nrCad).filter(Boolean);
|
||
if (nrs.length > 0) {
|
||
void fetchCfStatuses(nrs);
|
||
}
|
||
}, [searchResults, fetchCfStatuses]);
|
||
|
||
// Auto-fetch CF statuses when list changes
|
||
useEffect(() => {
|
||
const nrs = searchList.map((p) => p.nrCad).filter(Boolean);
|
||
if (nrs.length > 0) {
|
||
void fetchCfStatuses(nrs);
|
||
}
|
||
}, [searchList, fetchCfStatuses]);
|
||
|
||
// No auto-search — user clicks button or presses Enter
|
||
const handleSearchKeyDown = useCallback(
|
||
(e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void handleSearch();
|
||
}
|
||
},
|
||
[handleSearch],
|
||
);
|
||
|
||
/* ── Owner search handler ────────────────────────────────── */
|
||
const handleOwnerSearch = useCallback(async () => {
|
||
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||
if (!ownerSearch.trim() || ownerSearch.trim().length < 2) {
|
||
setOwnerError("Minim 2 caractere.");
|
||
return;
|
||
}
|
||
setOwnerLoading(true);
|
||
setOwnerError("");
|
||
setOwnerNote("");
|
||
try {
|
||
const res = await fetch("/api/eterra/search-owner", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
siruta,
|
||
ownerName: ownerSearch.trim(),
|
||
...(workspacePk ? { workspacePk } : {}),
|
||
}),
|
||
});
|
||
const data = (await res.json()) as {
|
||
results?: OwnerSearchResult[];
|
||
total?: number;
|
||
dbSearched?: boolean;
|
||
eterraSearched?: boolean;
|
||
eterraNote?: string;
|
||
error?: string;
|
||
};
|
||
if (data.error) {
|
||
setOwnerResults([]);
|
||
setOwnerError(data.error);
|
||
} else {
|
||
setOwnerResults(data.results ?? []);
|
||
const notes: string[] = [];
|
||
if (data.dbSearched) notes.push("DB local");
|
||
if (data.eterraSearched) notes.push("eTerra API");
|
||
if (data.eterraNote) notes.push(data.eterraNote);
|
||
setOwnerNote(
|
||
notes.length > 0
|
||
? `Surse: ${notes.join(" + ")}${data.total ? ` · ${data.total} rezultate` : ""}`
|
||
: "",
|
||
);
|
||
}
|
||
} catch {
|
||
setOwnerError("Eroare de rețea.");
|
||
}
|
||
setOwnerLoading(false);
|
||
}, [siruta, ownerSearch, workspacePk]);
|
||
|
||
const handleOwnerKeyDown = useCallback(
|
||
(e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void handleOwnerSearch();
|
||
}
|
||
},
|
||
[handleOwnerSearch],
|
||
);
|
||
|
||
/** Convert an OwnerSearchResult → ParcelDetail so it can be added to the list */
|
||
const ownerResultToParcelDetail = useCallback(
|
||
(r: OwnerSearchResult): ParcelDetail => ({
|
||
nrCad: r.nrCad,
|
||
nrCF: r.nrCF,
|
||
nrCFVechi: "",
|
||
nrTopo: "",
|
||
intravilan: r.intravilan,
|
||
categorieFolosinta: r.categorieFolosinta,
|
||
adresa: r.adresa,
|
||
proprietari: r.proprietari || r.proprietariVechi,
|
||
proprietariActuali: r.proprietari,
|
||
proprietariVechi: r.proprietariVechi,
|
||
suprafata: typeof r.suprafata === "number" ? r.suprafata : null,
|
||
solicitant: "",
|
||
immovablePk: r.immovablePk,
|
||
}),
|
||
[],
|
||
);
|
||
|
||
// Add result(s) to list for CSV export
|
||
const addToList = useCallback((item: ParcelDetail) => {
|
||
setSearchList((prev) => {
|
||
if (
|
||
prev.some(
|
||
(p) => p.nrCad === item.nrCad && p.immovablePk === item.immovablePk,
|
||
)
|
||
)
|
||
return prev;
|
||
return [...prev, item];
|
||
});
|
||
}, []);
|
||
|
||
const removeFromList = useCallback((nrCad: string) => {
|
||
setSearchList((prev) => prev.filter((p) => p.nrCad !== nrCad));
|
||
}, []);
|
||
|
||
// CSV export — all fields quoted to handle commas in values (e.g. nrTopo)
|
||
const csvEscape = useCallback((val: string | number | null | undefined) => {
|
||
const s = val != null ? String(val) : "";
|
||
return `"${s.replace(/"/g, '""')}"`;
|
||
}, []);
|
||
|
||
const downloadCSV = useCallback(() => {
|
||
const items = searchList.length > 0 ? searchList : searchResults;
|
||
if (items.length === 0) return;
|
||
const headers = [
|
||
"NR_CAD",
|
||
"NR_CF",
|
||
"NR_CF_VECHI",
|
||
"NR_TOPO",
|
||
"SUPRAFATA",
|
||
"INTRAVILAN",
|
||
"CATEGORIE_FOLOSINTA",
|
||
"ADRESA",
|
||
"PROPRIETARI_ACTUALI",
|
||
"PROPRIETARI_VECHI",
|
||
"SOLICITANT",
|
||
];
|
||
const rows = items.map((p) => [
|
||
csvEscape(p.nrCad),
|
||
csvEscape(p.nrCF),
|
||
csvEscape(p.nrCFVechi),
|
||
csvEscape(p.nrTopo),
|
||
csvEscape(p.suprafata),
|
||
csvEscape(p.intravilan),
|
||
csvEscape(p.categorieFolosinta),
|
||
csvEscape(p.adresa),
|
||
csvEscape(p.proprietariActuali ?? p.proprietari),
|
||
csvEscape(p.proprietariVechi),
|
||
csvEscape(p.solicitant),
|
||
]);
|
||
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
|
||
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `parcele_${siruta}_${Date.now()}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}, [searchList, searchResults, siruta, csvEscape]);
|
||
|
||
// Resolve selected UAT entry for ePay order context (needed by CF order handlers below)
|
||
const selectedUat = useMemo(
|
||
() => uatData.find((u) => u.siruta === siruta),
|
||
[uatData, siruta],
|
||
);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* List CF extract ordering + ZIP download */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
/** Order CF extracts for list items: skip valid, re-order expired, order new */
|
||
const handleListCfOrder = useCallback(async () => {
|
||
if (!siruta || searchList.length === 0 || listCfOrdering) return;
|
||
|
||
// Categorize parcels
|
||
const toOrder: typeof searchList = [];
|
||
const toReorder: typeof searchList = [];
|
||
const alreadyValid: typeof searchList = [];
|
||
|
||
for (const p of searchList) {
|
||
const status = cfStatusMap[p.nrCad];
|
||
if (status === "valid") {
|
||
alreadyValid.push(p);
|
||
} else if (status === "expired") {
|
||
toReorder.push(p);
|
||
} else {
|
||
// "none" or "processing" or unknown
|
||
if (status !== "processing") {
|
||
toOrder.push(p);
|
||
}
|
||
}
|
||
}
|
||
|
||
const newCount = toOrder.length;
|
||
const updateCount = toReorder.length;
|
||
const existingCount = alreadyValid.length;
|
||
|
||
if (newCount === 0 && updateCount === 0) {
|
||
setListCfOrderResult(`Toate cele ${existingCount} extrase sunt valide.`);
|
||
return;
|
||
}
|
||
|
||
// Confirm
|
||
const msg = [
|
||
newCount > 0 ? `${newCount} extrase noi` : null,
|
||
updateCount > 0 ? `${updateCount} actualizari` : null,
|
||
existingCount > 0 ? `${existingCount} existente (skip)` : null,
|
||
].filter(Boolean).join(", ");
|
||
|
||
if (!window.confirm(`Comanda extrase CF:\n${msg}\n\nContinui?`)) return;
|
||
|
||
setListCfOrdering(true);
|
||
setListCfOrderResult("");
|
||
|
||
try {
|
||
const allToProcess = [...toOrder, ...toReorder];
|
||
const parcels = allToProcess.map((p) => ({
|
||
nrCadastral: p.nrCad,
|
||
siruta,
|
||
judetIndex: 0,
|
||
judetName: selectedUat?.county ?? "",
|
||
uatId: 0,
|
||
uatName: selectedUat?.name ?? "",
|
||
}));
|
||
|
||
const res = await fetch("/api/ancpi/order", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ parcels }),
|
||
});
|
||
|
||
const data = (await res.json()) as { orders?: unknown[]; error?: string };
|
||
if (!res.ok || data.error) {
|
||
setListCfOrderResult(`Eroare: ${data.error ?? "Eroare la comanda"}`);
|
||
} else {
|
||
const count = data.orders?.length ?? allToProcess.length;
|
||
setListCfOrderResult(`${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`);
|
||
|
||
// Start polling for completion and refresh statuses periodically
|
||
const pollInterval = setInterval(() => {
|
||
void refreshCfStatuses();
|
||
}, 10_000);
|
||
|
||
// Stop after 5 minutes
|
||
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
|
||
}
|
||
} catch {
|
||
setListCfOrderResult("Eroare retea.");
|
||
} finally {
|
||
setListCfOrdering(false);
|
||
}
|
||
}, [siruta, searchList, listCfOrdering, cfStatusMap, selectedUat, refreshCfStatuses]);
|
||
|
||
/** Download all valid CF extracts from list as ZIP */
|
||
const handleListCfDownloadZip = useCallback(async () => {
|
||
if (searchList.length === 0 || listCfDownloading) return;
|
||
|
||
// Collect valid extract IDs in list order
|
||
const ids: string[] = [];
|
||
for (const p of searchList) {
|
||
const status = cfStatusMap[p.nrCad];
|
||
const extractId = cfLatestIds[p.nrCad];
|
||
if (status === "valid" && extractId) {
|
||
ids.push(extractId);
|
||
}
|
||
}
|
||
|
||
if (ids.length === 0) {
|
||
setListCfOrderResult("Niciun extras CF valid in lista.");
|
||
return;
|
||
}
|
||
|
||
setListCfDownloading(true);
|
||
try {
|
||
const res = await fetch(`/api/ancpi/download-zip?ids=${ids.join(",")}`);
|
||
if (!res.ok) throw new Error("Eroare descarcare ZIP");
|
||
|
||
const blob = await res.blob();
|
||
const cd = res.headers.get("Content-Disposition") ?? "";
|
||
const match = /filename="?([^"]+)"?/.exec(cd);
|
||
const filename = match?.[1] ? decodeURIComponent(match[1]) : "Extrase_CF_lista.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 {
|
||
setListCfOrderResult("Eroare la descarcarea ZIP.");
|
||
} finally {
|
||
setListCfDownloading(false);
|
||
}
|
||
}, [searchList, cfStatusMap, cfLatestIds, listCfDownloading]);
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Derived data */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
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 sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
||
|
||
const progressPct =
|
||
exportProgress?.total && exportProgress.total > 0
|
||
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||
: 0;
|
||
|
||
// DB status: which layers have data for the current UAT
|
||
const dbLayersSummary = useMemo(() => {
|
||
if (!sirutaValid || syncingSiruta !== siruta) return [];
|
||
return LAYER_CATALOG.filter((l) => (syncLocalCounts[l.id] ?? 0) > 0).map(
|
||
(l) => {
|
||
const count = syncLocalCounts[l.id] ?? 0;
|
||
const lastRun = syncRuns.find(
|
||
(r) => r.layerId === l.id && r.status === "done",
|
||
);
|
||
const lastSynced = lastRun?.completedAt
|
||
? new Date(lastRun.completedAt)
|
||
: null;
|
||
const ageMs = lastSynced ? Date.now() - lastSynced.getTime() : null;
|
||
const isFresh = ageMs !== null ? ageMs < 168 * 60 * 60 * 1000 : false;
|
||
return { ...l, count, lastSynced, isFresh };
|
||
},
|
||
);
|
||
}, [sirutaValid, syncingSiruta, siruta, syncLocalCounts, syncRuns]);
|
||
|
||
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
|
||
|
||
const relativeTime = (date: Date | null) => {
|
||
if (!date) return "niciodată";
|
||
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`;
|
||
};
|
||
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
/* Render */
|
||
/* ════════════════════════════════════════════════════════════ */
|
||
|
||
return (
|
||
<Tabs defaultValue="search" className="space-y-4">
|
||
{/* ═══════════════════════ Persistent header ═══════════════ */}
|
||
<div className="space-y-3">
|
||
{/* UAT + Connection row */}
|
||
<div className="flex items-start gap-3">
|
||
{/* UAT autocomplete — always visible */}
|
||
<div className="relative flex-1 min-w-0" ref={uatRef}>
|
||
<div className="relative">
|
||
<MapPin className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||
<Input
|
||
id="uat-search"
|
||
placeholder="Selectează UAT — scrie nume sau cod SIRUTA…"
|
||
value={uatQuery}
|
||
onChange={(e) => {
|
||
setUatQuery(e.target.value);
|
||
setShowUatResults(true);
|
||
// Auto-connect on first keystroke
|
||
if (e.target.value.trim().length >= 1) {
|
||
void triggerAutoConnect();
|
||
}
|
||
}}
|
||
onFocus={() => setShowUatResults(true)}
|
||
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
|
||
className="pl-9 font-medium"
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
{/* Selected indicator chip */}
|
||
{sirutaValid && (
|
||
<div className="absolute right-2 top-1.5">
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] font-mono bg-background"
|
||
>
|
||
SIRUTA {siruta}
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
|
||
{/* Dropdown */}
|
||
{showUatResults && uatResults.length > 0 && (
|
||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
|
||
{uatResults.map((item) => (
|
||
<button
|
||
key={item.siruta}
|
||
type="button"
|
||
className="flex w-full items-center justify-between px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
const label = item.county
|
||
? `${item.name} (${item.siruta}), jud. ${item.county}`
|
||
: `${item.name} (${item.siruta})`;
|
||
setUatQuery(label);
|
||
setSiruta(item.siruta);
|
||
setWorkspacePk(item.workspacePk ?? null);
|
||
setShowUatResults(false);
|
||
setSearchResults([]);
|
||
}}
|
||
>
|
||
<span className="flex items-center gap-1.5 min-w-0 flex-wrap">
|
||
<span className="font-medium">{item.name}</span>
|
||
<span className="text-muted-foreground">
|
||
({item.siruta})
|
||
</span>
|
||
{item.county && (
|
||
<span className="text-muted-foreground">
|
||
–{" "}
|
||
<span className="font-medium text-foreground/70">
|
||
jud. {item.county}
|
||
</span>
|
||
</span>
|
||
)}
|
||
{(item.localFeatures ?? 0) > 0 && (
|
||
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
|
||
{(item.localFeatures ?? 0).toLocaleString("ro")} local
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Connection pills */}
|
||
<div className="flex items-center gap-2">
|
||
<EpayConnect
|
||
triggerConnect={sirutaValid}
|
||
onStatusChange={setEpayStatus}
|
||
/>
|
||
<ConnectionPill
|
||
session={session}
|
||
connecting={connecting}
|
||
connectionError={connectionError}
|
||
onDisconnect={handleDisconnect}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab bar */}
|
||
<TabsList>
|
||
<TabsTrigger value="search" className="gap-1.5">
|
||
<Search className="h-4 w-4" />
|
||
Căutare Parcele
|
||
</TabsTrigger>
|
||
<TabsTrigger value="layers" className="gap-1.5">
|
||
<Layers className="h-4 w-4" />
|
||
Catalog Layere
|
||
</TabsTrigger>
|
||
<TabsTrigger value="export" className="gap-1.5">
|
||
<Download className="h-4 w-4" />
|
||
Export
|
||
</TabsTrigger>
|
||
<TabsTrigger value="database" className="gap-1.5">
|
||
<Database className="h-4 w-4" />
|
||
Baza de Date
|
||
</TabsTrigger>
|
||
<TabsTrigger value="extracts" className="gap-1.5">
|
||
<FileText className="h-4 w-4" />
|
||
Extrase CF
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
{/* Tab 1: Parcel search */}
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
<TabsContent value="search" className="space-y-4">
|
||
{!sirutaValid ? (
|
||
<Card>
|
||
<CardContent className="py-12 text-center text-muted-foreground">
|
||
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||
<p>Selectează un UAT mai sus pentru a căuta parcele.</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
{/* Search input — mode toggle + input */}
|
||
<Card>
|
||
<CardContent className="pt-4 space-y-3">
|
||
{/* Mode toggle */}
|
||
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
|
||
<button
|
||
onClick={() => setSearchMode("cadastral")}
|
||
className={cn(
|
||
"px-3 py-1 text-xs rounded font-medium transition-colors",
|
||
searchMode === "cadastral"
|
||
? "bg-background shadow text-foreground"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
<Search className="inline h-3 w-3 mr-1 -mt-0.5" />
|
||
Nr. Cadastral
|
||
</button>
|
||
<button
|
||
onClick={() => setSearchMode("owner")}
|
||
className={cn(
|
||
"px-3 py-1 text-xs rounded font-medium transition-colors",
|
||
searchMode === "owner"
|
||
? "bg-background shadow text-foreground"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
<User className="inline h-3 w-3 mr-1 -mt-0.5" />
|
||
Proprietar
|
||
</button>
|
||
</div>
|
||
|
||
{/* Cadastral search input */}
|
||
{searchMode === "cadastral" && (
|
||
<div className="flex gap-3 items-end">
|
||
<div className="space-y-1 flex-1">
|
||
<Label className="text-xs">
|
||
Numere cadastrale (separate prin virgulă sau Enter)
|
||
</Label>
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="ex: 62580 sau 62580, 62581, 62582"
|
||
className="pl-9"
|
||
value={featuresSearch}
|
||
onChange={(e) => setFeaturesSearch(e.target.value)}
|
||
onKeyDown={handleSearchKeyDown}
|
||
disabled={!session.connected}
|
||
/>
|
||
</div>
|
||
{!session.connected && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Necesită conexiune eTerra. Folosește modul Proprietar
|
||
pentru a căuta offline în DB.
|
||
</p>
|
||
)}
|
||
</div>
|
||
<Button
|
||
onClick={() => void handleSearch()}
|
||
disabled={
|
||
loadingFeatures ||
|
||
!featuresSearch.trim() ||
|
||
!session.connected
|
||
}
|
||
>
|
||
{loadingFeatures ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Search className="mr-2 h-4 w-4" />
|
||
)}
|
||
Caută
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Owner search input */}
|
||
{searchMode === "owner" && (
|
||
<div className="flex gap-3 items-end">
|
||
<div className="space-y-1 flex-1">
|
||
<Label className="text-xs">
|
||
Nume proprietar (caută în DB local + eTerra)
|
||
</Label>
|
||
<div className="relative">
|
||
<User className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="ex: Popescu Ion"
|
||
className="pl-9"
|
||
value={ownerSearch}
|
||
onChange={(e) => setOwnerSearch(e.target.value)}
|
||
onKeyDown={handleOwnerKeyDown}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={() => void handleOwnerSearch()}
|
||
disabled={ownerLoading || ownerSearch.trim().length < 2}
|
||
>
|
||
{ownerLoading ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Search className="mr-2 h-4 w-4" />
|
||
)}
|
||
Caută
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{searchMode === "cadastral" && searchError && (
|
||
<p className="text-xs text-destructive">{searchError}</p>
|
||
)}
|
||
{searchMode === "owner" && ownerError && (
|
||
<p className="text-xs text-destructive">{ownerError}</p>
|
||
)}
|
||
{searchMode === "owner" && ownerNote && (
|
||
<p className="text-xs text-muted-foreground">{ownerNote}</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ─── Cadastral search results ────────────── */}
|
||
{searchMode === "cadastral" && (
|
||
<>
|
||
{/* Results */}
|
||
{loadingFeatures && searchResults.length === 0 && (
|
||
<Card>
|
||
<CardContent className="py-12 text-center text-muted-foreground">
|
||
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
||
<p>Se caută în eTerra...</p>
|
||
<p className="text-xs mt-1 opacity-60">
|
||
Prima căutare pe un UAT nou poate dura ~10-30s (se
|
||
încarcă lista de județe).
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{searchResults.length > 0 && (
|
||
<>
|
||
{/* Action bar */}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">
|
||
{searchResults.length} rezultat
|
||
{searchResults.length > 1 ? "e" : ""}
|
||
{searchList.length > 0 && (
|
||
<span className="ml-2">
|
||
· <strong>{searchList.length}</strong> în listă
|
||
</span>
|
||
)}
|
||
</span>
|
||
<div className="flex gap-2">
|
||
{searchResults.length > 0 && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
for (const r of searchResults) addToList(r);
|
||
}}
|
||
>
|
||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||
Adaugă toate în listă
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="default"
|
||
onClick={downloadCSV}
|
||
disabled={
|
||
searchResults.length === 0 &&
|
||
searchList.length === 0
|
||
}
|
||
>
|
||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||
Descarcă CSV
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detail cards */}
|
||
<div className="space-y-3">
|
||
{searchResults.map((p, idx) => (
|
||
<Card
|
||
key={`${p.nrCad}-${p.immovablePk}-${idx}`}
|
||
className={cn(
|
||
"transition-colors",
|
||
!p.immovablePk && "opacity-60",
|
||
)}
|
||
>
|
||
<CardContent className="pt-4">
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div>
|
||
<h3 className="text-lg font-semibold tabular-nums">
|
||
Nr. Cad. {p.nrCad}
|
||
</h3>
|
||
{!p.immovablePk && (
|
||
<p className="text-xs text-destructive">
|
||
Parcela nu a fost găsită în eTerra.
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 w-7 p-0"
|
||
title="Adaugă în listă"
|
||
onClick={() => addToList(p)}
|
||
disabled={!p.immovablePk}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 w-7 p-0"
|
||
title="Copiaza detalii"
|
||
onClick={() => {
|
||
const text = [
|
||
`Nr. Cad: ${p.nrCad}`,
|
||
`Nr. CF: ${p.nrCF || "\u2014"}`,
|
||
p.nrCFVechi
|
||
? `CF vechi: ${p.nrCFVechi}`
|
||
: null,
|
||
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
|
||
p.suprafata != null
|
||
? `Suprafata: ${p.suprafata.toLocaleString("ro-RO")} mp`
|
||
: null,
|
||
`Intravilan: ${p.intravilan || "\u2014"}`,
|
||
p.categorieFolosinta
|
||
? `Categorie: ${p.categorieFolosinta}`
|
||
: null,
|
||
p.adresa ? `Adresa: ${p.adresa}` : null,
|
||
p.proprietariActuali
|
||
? `Proprietari actuali: ${p.proprietariActuali}`
|
||
: null,
|
||
p.proprietariVechi
|
||
? `Proprietari vechi: ${p.proprietariVechi}`
|
||
: null,
|
||
!p.proprietariActuali &&
|
||
!p.proprietariVechi &&
|
||
p.proprietari
|
||
? `Proprietari: ${p.proprietari}`
|
||
: null,
|
||
p.solicitant
|
||
? `Solicitant: ${p.solicitant}`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
void navigator.clipboard.writeText(text);
|
||
}}
|
||
>
|
||
<ClipboardCopy className="h-3.5 w-3.5" />
|
||
</Button>
|
||
{/* CF Extract status + actions */}
|
||
{p.immovablePk && sirutaValid && (() => {
|
||
const cfStatus = cfStatusMap[p.nrCad];
|
||
const extractId = cfLatestIds[p.nrCad];
|
||
const cfExpiry = cfExpiryDates[p.nrCad];
|
||
if (cfStatus === "valid") {
|
||
return (
|
||
<TooltipProvider>
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400 cursor-default"
|
||
>
|
||
Extras CF
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
{cfExpiry ? `Valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
{extractId && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 w-7 p-0 text-emerald-600"
|
||
asChild
|
||
>
|
||
<a
|
||
href={`/api/ancpi/download?id=${extractId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
</a>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Descarca extras CF</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
if (cfStatus === "expired") {
|
||
return (
|
||
<TooltipProvider>
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400 cursor-default"
|
||
>
|
||
Expirat
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
{cfExpiry ? `Expirat pe ${formatShortDate(cfExpiry)}` : "Extras CF expirat"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<EpayOrderButton
|
||
nrCadastral={p.nrCad}
|
||
siruta={siruta}
|
||
judetName={selectedUat?.county ?? ""}
|
||
uatName={selectedUat?.name ?? ""}
|
||
label="Actualizeaza"
|
||
tooltipText="Comanda extras CF nou (1 credit)"
|
||
/>
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
if (cfStatus === "processing") {
|
||
return (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse cursor-default"
|
||
>
|
||
Se proceseaza...
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Comanda in curs de procesare</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
// "none" or unknown
|
||
return (
|
||
<EpayOrderButton
|
||
nrCadastral={p.nrCad}
|
||
siruta={siruta}
|
||
judetName={selectedUat?.county ?? ""}
|
||
uatName={selectedUat?.name ?? ""}
|
||
tooltipText="Comanda extras CF (1 credit)"
|
||
/>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{p.immovablePk && (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Nr. CF
|
||
</span>
|
||
<span className="font-medium">
|
||
{p.nrCF || "—"}
|
||
</span>
|
||
</div>
|
||
{p.nrCFVechi && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
CF vechi
|
||
</span>
|
||
<span>{p.nrCFVechi}</span>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Nr. Topo
|
||
</span>
|
||
<span>{p.nrTopo || "—"}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Suprafață
|
||
</span>
|
||
<span className="tabular-nums">
|
||
{p.suprafata != null
|
||
? formatArea(p.suprafata)
|
||
: "—"}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Intravilan
|
||
</span>
|
||
<Badge
|
||
variant={
|
||
p.intravilan === "Da"
|
||
? "default"
|
||
: p.intravilan === "Nu"
|
||
? "secondary"
|
||
: "outline"
|
||
}
|
||
className="text-[11px]"
|
||
>
|
||
{p.intravilan || "—"}
|
||
</Badge>
|
||
</div>
|
||
{p.categorieFolosinta && (
|
||
<div className="col-span-2">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Categorii folosință
|
||
</span>
|
||
<span className="text-xs">
|
||
{p.categorieFolosinta}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{p.adresa && (
|
||
<div className="col-span-2">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Adresă
|
||
</span>
|
||
<span>{p.adresa}</span>
|
||
</div>
|
||
)}
|
||
{(p.proprietariActuali ||
|
||
p.proprietariVechi) && (
|
||
<div className="col-span-2 lg:col-span-4">
|
||
{p.proprietariActuali && (
|
||
<div className="mb-1">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Proprietari actuali
|
||
</span>
|
||
<span className="font-medium text-sm">
|
||
{p.proprietariActuali}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{p.proprietariVechi && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Proprietari anteriori
|
||
</span>
|
||
<span className="text-[11px] text-muted-foreground/80">
|
||
{p.proprietariVechi}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{!p.proprietariActuali &&
|
||
!p.proprietariVechi &&
|
||
p.proprietari && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Proprietari
|
||
</span>
|
||
<span>{p.proprietari}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{p.solicitant && (
|
||
<div className="col-span-2">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Solicitant
|
||
</span>
|
||
<span>{p.solicitant}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Empty state when no search has been done */}
|
||
{searchMode === "cadastral" &&
|
||
searchResults.length === 0 &&
|
||
!loadingFeatures &&
|
||
!searchError && (
|
||
<Card>
|
||
<CardContent className="py-12 text-center text-muted-foreground">
|
||
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||
<p>Introdu un număr cadastral și apasă Caută.</p>
|
||
<p className="text-xs mt-1 opacity-60">
|
||
Poți căuta mai multe parcele simultan, separate prin
|
||
virgulă.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ─── Owner search results ────────────────── */}
|
||
{searchMode === "owner" && (
|
||
<>
|
||
{ownerLoading && ownerResults.length === 0 && (
|
||
<Card>
|
||
<CardContent className="py-12 text-center text-muted-foreground">
|
||
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
||
<p>Se caută proprietar...</p>
|
||
<p className="text-xs mt-1 opacity-60">
|
||
Caută mai întâi în DB local (date îmbogățite), apoi pe
|
||
eTerra.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{ownerResults.length > 0 && (
|
||
<>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">
|
||
{ownerResults.length} rezultat
|
||
{ownerResults.length > 1 ? "e" : ""} pentru "
|
||
{ownerSearch}"
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
for (const r of ownerResults)
|
||
addToList(ownerResultToParcelDetail(r));
|
||
}}
|
||
>
|
||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||
Adaugă toate în listă
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="default"
|
||
onClick={downloadCSV}
|
||
disabled={
|
||
ownerResults.length === 0 && searchList.length === 0
|
||
}
|
||
>
|
||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||
Descarcă CSV
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{ownerResults.map((r, idx) => (
|
||
<Card key={`owner-${r.nrCad}-${idx}`}>
|
||
<CardContent className="pt-4">
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div>
|
||
<h3 className="text-lg font-semibold tabular-nums">
|
||
Nr. Cad. {r.nrCad}
|
||
</h3>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] mt-1"
|
||
>
|
||
{r.source === "db"
|
||
? "din baza de date"
|
||
: "eTerra online"}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 w-7 p-0"
|
||
title="Adaugă în listă"
|
||
onClick={() =>
|
||
addToList(ownerResultToParcelDetail(r))
|
||
}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 w-7 p-0"
|
||
title="Copiaza detalii"
|
||
onClick={() => {
|
||
const text = [
|
||
`Nr. Cad: ${r.nrCad}`,
|
||
r.nrCF ? `Nr. CF: ${r.nrCF}` : null,
|
||
r.proprietari
|
||
? `Proprietari: ${r.proprietari}`
|
||
: null,
|
||
r.proprietariVechi
|
||
? `Proprietari vechi: ${r.proprietariVechi}`
|
||
: null,
|
||
r.adresa ? `Adresa: ${r.adresa}` : null,
|
||
r.suprafata
|
||
? `Suprafata: ${r.suprafata} mp`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
void navigator.clipboard.writeText(text);
|
||
}}
|
||
>
|
||
<ClipboardCopy className="h-3.5 w-3.5" />
|
||
</Button>
|
||
{/* CF Extract status + actions */}
|
||
{r.immovablePk && sirutaValid && (() => {
|
||
const cfStatus = cfStatusMap[r.nrCad];
|
||
const extractId = cfLatestIds[r.nrCad];
|
||
const cfExpiry = cfExpiryDates[r.nrCad];
|
||
if (cfStatus === "valid") {
|
||
return (
|
||
<TooltipProvider>
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400 cursor-default"
|
||
>
|
||
Extras CF
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
{cfExpiry ? `Valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
{extractId && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 w-7 p-0 text-emerald-600"
|
||
asChild
|
||
>
|
||
<a
|
||
href={`/api/ancpi/download?id=${extractId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
</a>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Descarca extras CF</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
if (cfStatus === "expired") {
|
||
return (
|
||
<TooltipProvider>
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400 cursor-default"
|
||
>
|
||
Expirat
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
{cfExpiry ? `Expirat pe ${formatShortDate(cfExpiry)}` : "Extras CF expirat"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<EpayOrderButton
|
||
nrCadastral={r.nrCad}
|
||
siruta={siruta}
|
||
judetName={selectedUat?.county ?? ""}
|
||
uatName={selectedUat?.name ?? ""}
|
||
label="Actualizeaza"
|
||
tooltipText="Comanda extras CF nou (1 credit)"
|
||
/>
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
if (cfStatus === "processing") {
|
||
return (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse cursor-default"
|
||
>
|
||
Se proceseaza...
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Comanda in curs de procesare</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
return (
|
||
<EpayOrderButton
|
||
nrCadastral={r.nrCad}
|
||
siruta={siruta}
|
||
judetName={selectedUat?.county ?? ""}
|
||
uatName={selectedUat?.name ?? ""}
|
||
tooltipText="Comanda extras CF (1 credit)"
|
||
/>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||
{r.nrCF && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Nr. CF
|
||
</span>
|
||
<span className="font-medium">{r.nrCF}</span>
|
||
</div>
|
||
)}
|
||
{r.suprafata && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Suprafață
|
||
</span>
|
||
<span className="tabular-nums">
|
||
{typeof r.suprafata === "number"
|
||
? formatArea(r.suprafata)
|
||
: `${r.suprafata} mp`}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{r.intravilan && (
|
||
<div>
|
||
<span className="text-xs text-muted-foreground block">
|
||
Intravilan
|
||
</span>
|
||
<Badge
|
||
variant={
|
||
r.intravilan === "Da"
|
||
? "default"
|
||
: r.intravilan === "Nu"
|
||
? "secondary"
|
||
: "outline"
|
||
}
|
||
className="text-[11px]"
|
||
>
|
||
{r.intravilan}
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
{r.categorieFolosinta && (
|
||
<div className="col-span-2">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Categorii folosință
|
||
</span>
|
||
<span className="text-xs">
|
||
{r.categorieFolosinta}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{r.adresa && (
|
||
<div className="col-span-2">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Adresă
|
||
</span>
|
||
<span>{r.adresa}</span>
|
||
</div>
|
||
)}
|
||
{r.proprietari && (
|
||
<div className="col-span-2 lg:col-span-4">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Proprietari actuali
|
||
</span>
|
||
<span className="font-medium text-sm">
|
||
{r.proprietari}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{r.proprietariVechi && (
|
||
<div className="col-span-2 lg:col-span-4">
|
||
<span className="text-xs text-muted-foreground block">
|
||
Proprietari anteriori
|
||
</span>
|
||
<span className="text-[11px] text-muted-foreground/80">
|
||
{r.proprietariVechi}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{ownerResults.length === 0 && !ownerLoading && !ownerError && (
|
||
<Card>
|
||
<CardContent className="py-12 text-center text-muted-foreground">
|
||
<User className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||
<p>Introdu numele proprietarului și apasă Caută.</p>
|
||
<p className="text-xs mt-1 opacity-60">
|
||
Caută în datele îmbogățite (DB local) și pe eTerra.
|
||
<br />
|
||
Pentru rezultate complete, lansează "Sync fundal —
|
||
Magic" în tab-ul Export.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Saved list */}
|
||
{searchList.length > 0 && (
|
||
<Card>
|
||
<CardContent className="pt-4">
|
||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||
<h3 className="text-sm font-medium">
|
||
Lista mea ({searchList.length} parcele)
|
||
</h3>
|
||
<div className="flex gap-2 flex-wrap">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
setSearchList([]);
|
||
setListCfOrderResult("");
|
||
}}
|
||
>
|
||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||
Goleste
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={downloadCSV}>
|
||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||
CSV din lista
|
||
</Button>
|
||
{/* Download all valid CF extracts as ZIP */}
|
||
{searchList.some((p) => cfStatusMap[p.nrCad] === "valid") && (() => {
|
||
const validCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "valid").length;
|
||
return (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
||
disabled={listCfDownloading}
|
||
onClick={() => void handleListCfDownloadZip()}
|
||
>
|
||
{listCfDownloading ? (
|
||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Archive className="mr-1 h-3.5 w-3.5" />
|
||
)}
|
||
Descarca Extrase CF
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{`Descarca ZIP cu ${validCount} extrase valide din lista`}</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
);
|
||
})()}
|
||
{/* Order CF extracts for list */}
|
||
{epayStatus.connected && (() => {
|
||
const newCount = searchList.filter((p) => {
|
||
const s = cfStatusMap[p.nrCad];
|
||
return s !== "valid" && s !== "expired" && s !== "processing";
|
||
}).length;
|
||
const updateCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "expired").length;
|
||
const totalCredits = newCount + updateCount;
|
||
const validCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "valid").length;
|
||
return (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
disabled={listCfOrdering}
|
||
onClick={() => void handleListCfOrder()}
|
||
>
|
||
{listCfOrdering ? (
|
||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<FileText className="mr-1 h-3.5 w-3.5" />
|
||
)}
|
||
Scoate Extrase CF
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
{`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Order result message */}
|
||
{listCfOrderResult && (
|
||
<p className={cn(
|
||
"text-xs mb-2",
|
||
listCfOrderResult.startsWith("Eroare")
|
||
? "text-destructive"
|
||
: "text-emerald-600 dark:text-emerald-400",
|
||
)}>
|
||
{listCfOrderResult}
|
||
</p>
|
||
)}
|
||
|
||
<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 text-center font-medium w-8 text-muted-foreground">
|
||
#
|
||
</th>
|
||
<th className="px-3 py-2 text-left font-medium">
|
||
Nr. Cad
|
||
</th>
|
||
<th className="px-3 py-2 text-left font-medium">
|
||
Nr. CF
|
||
</th>
|
||
<th className="px-3 py-2 text-right font-medium hidden sm:table-cell">
|
||
Suprafata
|
||
</th>
|
||
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">
|
||
Proprietari
|
||
</th>
|
||
<th className="px-3 py-2 text-center font-medium">
|
||
Extras CF
|
||
</th>
|
||
<th className="px-3 py-2 w-8"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{searchList.map((p, idx) => {
|
||
const cfStatus = cfStatusMap[p.nrCad];
|
||
const cfExpiry = cfExpiryDates[p.nrCad];
|
||
return (
|
||
<tr
|
||
key={`list-${p.nrCad}-${p.immovablePk}`}
|
||
className="border-b hover:bg-muted/30 transition-colors"
|
||
>
|
||
<td className="px-2 py-2 text-center text-xs text-muted-foreground tabular-nums">
|
||
{idx + 1}
|
||
</td>
|
||
<td className="px-3 py-2 font-mono text-xs font-medium">
|
||
{p.nrCad}
|
||
</td>
|
||
<td className="px-3 py-2 text-xs">
|
||
{p.nrCF || "\u2014"}
|
||
</td>
|
||
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
|
||
{p.suprafata != null
|
||
? formatArea(p.suprafata)
|
||
: "\u2014"}
|
||
</td>
|
||
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
|
||
{p.proprietari || "\u2014"}
|
||
</td>
|
||
<td className="px-3 py-2 text-center">
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
{cfStatus === "valid" ? (
|
||
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800 cursor-default">
|
||
Valid
|
||
</span>
|
||
) : cfStatus === "expired" ? (
|
||
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800 cursor-default">
|
||
Expirat
|
||
</span>
|
||
) : cfStatus === "processing" ? (
|
||
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800 animate-pulse cursor-default">
|
||
Procesare
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground border-muted-foreground/20 cursor-default">
|
||
Lipsa
|
||
</span>
|
||
)}
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
{cfStatus === "valid"
|
||
? (cfExpiry ? `Extras CF valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid")
|
||
: cfStatus === "expired"
|
||
? (cfExpiry ? `Extras CF expirat pe ${formatShortDate(cfExpiry)}. Va fi actualizat automat la 'Scoate Extrase CF'.` : "Extras CF expirat. Va fi actualizat automat la 'Scoate Extrase CF'.")
|
||
: cfStatus === "processing"
|
||
? "Comanda in curs de procesare"
|
||
: "Nu exista extras CF. Apasa 'Scoate Extrase CF' pentru a comanda."}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-6 w-6 p-0"
|
||
onClick={() => removeFromList(p.nrCad)}
|
||
>
|
||
<XCircle className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
{/* Tab 2: Layer catalog */}
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
<TabsContent value="layers" className="space-y-4">
|
||
{!sirutaValid || !session.connected ? (
|
||
<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ă-te la eTerra și selectează un UAT."
|
||
: "Selectează un UAT pentru a vedea catalogul de layere."}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<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ăr features pentru SIRUTA ${siruta}`
|
||
: "Apasă pentru a număra 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ără…" : "Numără"}
|
||
</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ă în 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ă GPKG (din cache dacă e proaspăt)"
|
||
: "Sincronizează + descarcă 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">
|
||
—{" "}
|
||
{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">
|
||
QGIS / PostGIS
|
||
</span>
|
||
</div>
|
||
<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 configurează…" : "Setup PostGIS"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<CardContent className="py-3 space-y-2">
|
||
{postgisResult ? (
|
||
postgisResult.success ? (
|
||
<div className="space-y-2">
|
||
<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">
|
||
{postgisResult.message}
|
||
</span>
|
||
</div>
|
||
{postgisResult.details && (
|
||
<div className="rounded bg-muted/50 p-3 text-xs space-y-1 font-mono">
|
||
<p>
|
||
Backfill:{" "}
|
||
{String(
|
||
(
|
||
postgisResult.details as {
|
||
backfilledFeatures?: number;
|
||
}
|
||
).backfilledFeatures ?? 0,
|
||
)}{" "}
|
||
features convertite
|
||
</p>
|
||
<p>
|
||
Total cu geometrie nativă:{" "}
|
||
{String(
|
||
(
|
||
postgisResult.details as {
|
||
totalFeaturesWithGeom?: number;
|
||
}
|
||
).totalFeaturesWithGeom ?? 0,
|
||
)}
|
||
</p>
|
||
<p className="text-muted-foreground mt-1">
|
||
QGIS → PostgreSQL → 10.10.10.166:5432 /
|
||
architools_db
|
||
</p>
|
||
<p className="text-muted-foreground">
|
||
View-uri: gis_terenuri, gis_cladiri,
|
||
gis_documentatii, gis_administrativ
|
||
</p>
|
||
<p className="text-muted-foreground">SRID: 3844</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
|
||
</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Instalează PostGIS pe serverul PostgreSQL:
|
||
</p>
|
||
<code className="text-xs block mt-1 bg-muted rounded px-2 py-1">
|
||
apt install postgresql-16-postgis-3
|
||
</code>
|
||
</div>
|
||
</div>
|
||
)
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">
|
||
Creează coloana nativă PostGIS, trigger auto-conversie,
|
||
index spațial GiST și view-uri QGIS-compatibile. Necesită
|
||
PostGIS instalat pe server.
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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
|
||
? ` — ${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>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
{/* Tab 3: Export */}
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
<TabsContent value="export" className="space-y-4">
|
||
{/* DB freshness status */}
|
||
{sirutaValid && dbLayersSummary.length > 0 && (
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-3 px-4">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<Database className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
<span className="text-sm text-muted-foreground">
|
||
<span className="font-medium text-foreground">
|
||
{dbTotalFeatures.toLocaleString("ro-RO")}
|
||
</span>{" "}
|
||
entități în DB din{" "}
|
||
<span className="font-medium text-foreground">
|
||
{dbLayersSummary.length}
|
||
</span>{" "}
|
||
layere
|
||
</span>
|
||
{(() => {
|
||
const freshCount = dbLayersSummary.filter(
|
||
(l) => l.isFresh,
|
||
).length;
|
||
const staleCount = dbLayersSummary.length - freshCount;
|
||
const oldestSync = dbLayersSummary.reduce(
|
||
(oldest, l) => {
|
||
if (!l.lastSynced) return oldest;
|
||
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
|
||
return oldest;
|
||
},
|
||
null as Date | null,
|
||
);
|
||
return (
|
||
<>
|
||
{staleCount === 0 ? (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800"
|
||
>
|
||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||
Proaspete
|
||
</Badge>
|
||
) : (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800"
|
||
>
|
||
<Clock className="h-3 w-3 mr-1" />
|
||
{staleCount} vechi
|
||
</Badge>
|
||
)}
|
||
{oldestSync && (
|
||
<span className="text-xs text-muted-foreground">
|
||
Ultima sincronizare: {relativeTime(oldestSync)}
|
||
</span>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Hero buttons */}
|
||
{sirutaValid && session.connected ? (
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<Button
|
||
size="lg"
|
||
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||
disabled={exporting}
|
||
onClick={() => void handleExportBundle("base")}
|
||
>
|
||
{exporting && exportProgress?.phase !== "Detalii parcele" ? (
|
||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||
) : (
|
||
<FileDown className="mr-2 h-5 w-5" />
|
||
)}
|
||
<div className="text-left">
|
||
<div className="font-semibold">
|
||
Descarcă Terenuri și Clădiri
|
||
</div>
|
||
<div className="text-xs opacity-70 font-normal">
|
||
Sync + GPKG (din cache dacă e proaspăt)
|
||
</div>
|
||
</div>
|
||
</Button>
|
||
|
||
<Button
|
||
size="lg"
|
||
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
||
disabled={exporting}
|
||
onClick={() => void handleExportBundle("magic")}
|
||
>
|
||
{exporting && exportProgress?.phase === "Detalii parcele" ? (
|
||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-5 w-5" />
|
||
)}
|
||
<div className="text-left">
|
||
<div className="font-semibold">Magic</div>
|
||
<div className="text-xs opacity-70 font-normal">
|
||
Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV
|
||
</div>
|
||
</div>
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Card>
|
||
<CardContent className="py-12 text-center text-muted-foreground">
|
||
{!session.connected ? (
|
||
<>
|
||
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||
<p>Selectează un UAT pentru a activa exportul.</p>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* No-geometry option — shown after auto-scan completes */}
|
||
{sirutaValid &&
|
||
session.connected &&
|
||
(() => {
|
||
const scanDone = noGeomScan !== null && noGeomScanSiruta === siruta;
|
||
const estimatedNoGeom = scanDone
|
||
? Math.max(
|
||
0,
|
||
noGeomScan.totalImmovables - noGeomScan.remoteGisCount,
|
||
)
|
||
: 0;
|
||
const hasNoGeomParcels = scanDone && estimatedNoGeom > 0;
|
||
const scanning = noGeomScanning;
|
||
|
||
// Still scanning
|
||
if (scanning)
|
||
return (
|
||
<Card className="border-amber-200/50 dark:border-amber-800/50">
|
||
<CardContent className="py-3 px-4">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
|
||
Se scanează lista de imobile din eTerra… (max 2 min)
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground mt-1 ml-6">
|
||
Poți folosi butoanele de mai jos fără să aștepți scanarea.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
|
||
// Scan timed out
|
||
if (scanDone && noGeomScan.scannedAt === "timeout")
|
||
return (
|
||
<Card className="border-amber-200/50 dark:border-amber-800/50">
|
||
<CardContent className="py-3 px-4">
|
||
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400">
|
||
<Clock className="h-4 w-4 shrink-0" />
|
||
Scanarea a depășit 2 minute — serverul eTerra e lent.
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground mt-1 ml-6">
|
||
Poți lansa sincronizarea fundal fără rezultate de scanare.
|
||
Include no-geom nu va fi disponibil.
|
||
</p>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 text-xs mt-1 ml-6"
|
||
onClick={() => void handleNoGeomScan()}
|
||
>
|
||
<RefreshCw className="h-3 w-3 mr-1" />
|
||
Reîncearcă scanarea
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
|
||
// Helper: local DB status line
|
||
const staleEnrichment =
|
||
scanDone &&
|
||
noGeomScan.localDbEnriched > 0 &&
|
||
noGeomScan.localDbEnrichedComplete < noGeomScan.localDbEnriched;
|
||
const staleCount = scanDone
|
||
? noGeomScan.localDbEnriched - noGeomScan.localDbEnrichedComplete
|
||
: 0;
|
||
|
||
const localDbLine = scanDone && noGeomScan.localDbTotal > 0 && (
|
||
<div className="space-y-0.5 mt-1">
|
||
<div className="flex items-center gap-1.5 flex-wrap text-[11px] text-muted-foreground">
|
||
<Database className="h-3 w-3 shrink-0" />
|
||
<span>
|
||
Baza de date locală:{" "}
|
||
<span className="font-medium text-foreground">
|
||
{noGeomScan.localDbWithGeom.toLocaleString("ro-RO")}
|
||
</span>{" "}
|
||
cu geometrie
|
||
{noGeomScan.localDbNoGeom > 0 && (
|
||
<>
|
||
{" + "}
|
||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||
{noGeomScan.localDbNoGeom.toLocaleString("ro-RO")}
|
||
</span>{" "}
|
||
fără geometrie
|
||
</>
|
||
)}
|
||
{noGeomScan.localDbEnriched > 0 && (
|
||
<>
|
||
{" · "}
|
||
<span className="font-medium text-teal-600 dark:text-teal-400">
|
||
{noGeomScan.localDbEnriched.toLocaleString("ro-RO")}
|
||
</span>{" "}
|
||
îmbogățite
|
||
{staleEnrichment && (
|
||
<span className="text-orange-600 dark:text-orange-400">
|
||
{" "}
|
||
({staleCount.toLocaleString("ro-RO")} incomplete)
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
{noGeomScan.localSyncFresh && (
|
||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||
(proaspăt)
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{staleEnrichment && (
|
||
<div className="flex items-center gap-1.5 text-[11px] text-orange-600 dark:text-orange-400 ml-[18px]">
|
||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||
<span>
|
||
{staleCount.toLocaleString("ro-RO")} parcele au îmbogățire
|
||
veche (lipsă PROPRIETARI_VECHI). Vor fi re-îmbogățite la
|
||
următorul export Magic.
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
// Helper: workflow preview (what Magic will do)
|
||
const workflowPreview = scanDone && (
|
||
<div className="mt-2 ml-7 space-y-0.5">
|
||
<p className="text-[11px] font-medium text-muted-foreground">
|
||
La apăsarea Magic, pașii vor fi:
|
||
</p>
|
||
<ol className="text-[11px] text-muted-foreground list-decimal ml-4 space-y-px">
|
||
<li>
|
||
{"Sync GIS — "}
|
||
<span
|
||
className={cn(
|
||
"font-medium",
|
||
noGeomScan.localSyncFresh &&
|
||
noGeomScan.localDbWithGeom > 0
|
||
? "text-emerald-600 dark:text-emerald-400"
|
||
: "text-foreground",
|
||
)}
|
||
>
|
||
{noGeomScan.localSyncFresh &&
|
||
noGeomScan.localDbWithGeom > 0
|
||
? "skip (date proaspete în DB)"
|
||
: `descarcă ${noGeomScan.remoteGisCount.toLocaleString("ro-RO")} terenuri` +
|
||
(noGeomScan.remoteCladiriCount > 0
|
||
? ` + ${noGeomScan.remoteCladiriCount.toLocaleString("ro-RO")} clădiri`
|
||
: "")}
|
||
</span>
|
||
</li>
|
||
{includeNoGeom && (
|
||
<li>
|
||
Import parcele fără geometrie —{" "}
|
||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||
{(() => {
|
||
const usefulNoGeom =
|
||
noGeomScan.qualityBreakdown.useful;
|
||
const newNoGeom = Math.max(
|
||
0,
|
||
usefulNoGeom - noGeomScan.localDbNoGeom,
|
||
);
|
||
const filtered = noGeomScan.qualityBreakdown.empty;
|
||
return newNoGeom > 0
|
||
? `~${newNoGeom.toLocaleString("ro-RO")} noi de importat` +
|
||
(filtered > 0
|
||
? ` (${filtered.toLocaleString("ro-RO")} filtrate)`
|
||
: "")
|
||
: "deja importate";
|
||
})()}
|
||
</span>
|
||
</li>
|
||
)}
|
||
<li>
|
||
Îmbogățire CF, proprietari, adrese —{" "}
|
||
<span className="font-medium text-teal-600 dark:text-teal-400">
|
||
{(() => {
|
||
// What will be in DB after sync + optional no-geom import:
|
||
// If DB is empty: sync will add remoteGisCount geo features
|
||
// If DB is fresh: keep localDbTotal
|
||
const geoAfterSync =
|
||
noGeomScan.localSyncFresh &&
|
||
noGeomScan.localDbWithGeom > 0
|
||
? noGeomScan.localDbWithGeom
|
||
: noGeomScan.remoteGisCount;
|
||
const noGeomAfterImport = includeNoGeom
|
||
? Math.max(
|
||
noGeomScan.localDbNoGeom,
|
||
noGeomScan.qualityBreakdown.useful,
|
||
)
|
||
: noGeomScan.localDbNoGeom;
|
||
const totalAfter = geoAfterSync + noGeomAfterImport;
|
||
const remaining =
|
||
totalAfter - noGeomScan.localDbEnrichedComplete;
|
||
return remaining > 0
|
||
? `~${remaining.toLocaleString("ro-RO")} de procesat (~${Math.ceil((remaining * 0.25) / 60)} min)`
|
||
: "deja îmbogățite";
|
||
})()}
|
||
</span>
|
||
</li>
|
||
<li>Generare GPKG + CSV</li>
|
||
<li>Comprimare ZIP + descărcare</li>
|
||
</ol>
|
||
</div>
|
||
);
|
||
|
||
// No-geometry parcels found
|
||
if (hasNoGeomParcels)
|
||
return (
|
||
<Card
|
||
className={cn(
|
||
"transition-colors",
|
||
includeNoGeom
|
||
? "border-amber-400 bg-amber-50/50 dark:border-amber-700 dark:bg-amber-950/20"
|
||
: "border-amber-200 dark:border-amber-800/50",
|
||
)}
|
||
>
|
||
<CardContent className="py-3 px-4 space-y-2">
|
||
<div className="flex items-center gap-3">
|
||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm">
|
||
Layer GIS:{" "}
|
||
<span className="font-semibold">
|
||
{noGeomScan.remoteGisCount.toLocaleString("ro-RO")}
|
||
</span>{" "}
|
||
terenuri
|
||
{noGeomScan.remoteCladiriCount > 0 && (
|
||
<>
|
||
{" + "}
|
||
<span className="font-semibold">
|
||
{noGeomScan.remoteCladiriCount.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>{" "}
|
||
clădiri
|
||
</>
|
||
)}
|
||
{" · "}
|
||
Lista imobile:{" "}
|
||
<span className="font-semibold">
|
||
{noGeomScan.totalImmovables.toLocaleString("ro-RO")}
|
||
</span>
|
||
{" (estimat "}
|
||
<span className="font-semibold text-amber-600 dark:text-amber-400">
|
||
~
|
||
{Math.max(
|
||
0,
|
||
noGeomScan.totalImmovables -
|
||
noGeomScan.remoteGisCount,
|
||
).toLocaleString("ro-RO")}
|
||
</span>
|
||
{" fără geometrie)"}
|
||
</p>
|
||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||
Cele fără geometrie există în baza de date eTerra dar
|
||
nu au contur desenat în layerul GIS.
|
||
</p>
|
||
{localDbLine}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 text-xs shrink-0"
|
||
disabled={noGeomScanning || exporting}
|
||
onClick={() => void handleNoGeomScan()}
|
||
title="Re-scanare"
|
||
>
|
||
<RefreshCw className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
<label className="flex items-center gap-2 cursor-pointer select-none ml-7">
|
||
<input
|
||
type="checkbox"
|
||
checked={includeNoGeom}
|
||
onChange={(e) => setIncludeNoGeom(e.target.checked)}
|
||
disabled={exporting}
|
||
className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
|
||
/>
|
||
<span className="text-sm font-medium">
|
||
Include și parcelele fără geometrie la export
|
||
</span>
|
||
</label>
|
||
{/* Quality breakdown of no-geom items */}
|
||
{scanDone && noGeomScan.noGeomCount > 0 && (
|
||
<div className="ml-7 p-2 rounded-md bg-muted/40 space-y-1">
|
||
<p className="text-[11px] font-medium text-muted-foreground">
|
||
Calitate date (din{" "}
|
||
{noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără
|
||
geometrie):
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px] text-muted-foreground">
|
||
<span>
|
||
Cu nr. cadastral eTerra:{" "}
|
||
<span className="font-medium text-foreground">
|
||
{noGeomScan.qualityBreakdown.withCadRef.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
Cu nr. CF/LB:{" "}
|
||
<span className="font-medium text-foreground">
|
||
{noGeomScan.qualityBreakdown.withPaperLb.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
Cu nr. cad. pe hârtie:{" "}
|
||
<span className="font-medium text-foreground">
|
||
{noGeomScan.qualityBreakdown.withPaperCad.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
Cu suprafață:{" "}
|
||
<span className="font-medium text-foreground">
|
||
{noGeomScan.qualityBreakdown.withArea.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
Active (status=1):{" "}
|
||
<span className="font-medium text-foreground">
|
||
{noGeomScan.qualityBreakdown.withActiveStatus.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
Cu carte funciară:{" "}
|
||
<span className="font-medium text-foreground">
|
||
{noGeomScan.qualityBreakdown.withLandbook.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-3 text-[11px] pt-0.5 border-t border-muted-foreground/10">
|
||
<span>
|
||
Utilizabile:{" "}
|
||
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
|
||
{noGeomScan.qualityBreakdown.useful.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
{noGeomScan.qualityBreakdown.empty > 0 && (
|
||
<span>
|
||
Filtrate (fără CF/inactive/fără date):{" "}
|
||
<span className="font-semibold text-rose-600 dark:text-rose-400">
|
||
{noGeomScan.qualityBreakdown.empty.toLocaleString(
|
||
"ro-RO",
|
||
)}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{includeNoGeom && (
|
||
<p className="text-[11px] text-muted-foreground ml-7">
|
||
{noGeomScan.qualityBreakdown.empty > 0
|
||
? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (imobile electronice cu CF). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (fără carte funciară, inactive sau fără date).`
|
||
: "Vor fi importate în DB și incluse în CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "}
|
||
În GPKG de bază apar doar cele cu geometrie.
|
||
</p>
|
||
)}
|
||
{workflowPreview}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
|
||
// Scan done, all parcels have geometry (or totalImmovables=0 ⇒ workspace issue)
|
||
if (scanDone && !hasNoGeomParcels)
|
||
return (
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-2.5 px-4">
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
{noGeomScan.totalImmovables > 0 ? (
|
||
<>
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||
Toate cele{" "}
|
||
{noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "}
|
||
imobile din eTerra au geometrie — nimic de importat
|
||
suplimentar.
|
||
{noGeomScan.localDbTotal > 0 && (
|
||
<span className="ml-1">
|
||
({noGeomScan.localDbTotal.toLocaleString("ro-RO")}{" "}
|
||
în DB local
|
||
{noGeomScan.localDbEnriched > 0 &&
|
||
`, ${noGeomScan.localDbEnriched.toLocaleString("ro-RO")} îmbogățite`}
|
||
{noGeomScan.localDbEnriched > 0 &&
|
||
noGeomScan.localDbEnrichedComplete <
|
||
noGeomScan.localDbEnriched && (
|
||
<span className="text-orange-600 dark:text-orange-400">
|
||
{` (${(noGeomScan.localDbEnriched - noGeomScan.localDbEnrichedComplete).toLocaleString("ro-RO")} incomplete)`}
|
||
</span>
|
||
)}
|
||
{noGeomScan.localSyncFresh && ", proaspăt"})
|
||
</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<AlertTriangle className="h-3.5 w-3.5 text-muted-foreground" />
|
||
Nu s-au găsit imobile în lista eTerra pentru acest
|
||
UAT. Verifică sesiunea eTerra.
|
||
</>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
|
||
return null;
|
||
})()}
|
||
|
||
{/* ── Background sync + Download from DB ──────────────── */}
|
||
{sirutaValid && (
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-3 px-4 space-y-3">
|
||
{/* Row 1: Section label */}
|
||
<div className="flex items-center gap-2">
|
||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-medium">
|
||
Procesare fundal & descărcare din DB
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground">
|
||
— pornește sincronizarea, închide pagina, descarcă mai târziu
|
||
</span>
|
||
</div>
|
||
|
||
{/* Include no-geom toggle (works independently of scan) */}
|
||
{session.connected && (
|
||
<label className="flex items-center gap-2 cursor-pointer select-none ml-6">
|
||
<input
|
||
type="checkbox"
|
||
checked={includeNoGeom}
|
||
onChange={(e) => setIncludeNoGeom(e.target.checked)}
|
||
disabled={
|
||
exporting ||
|
||
(!!bgJobId && bgProgress?.status === "running")
|
||
}
|
||
className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
|
||
/>
|
||
<span className="text-xs">
|
||
Include și parcelele fără geometrie
|
||
</span>
|
||
{noGeomScanning && (
|
||
<span className="text-[10px] text-muted-foreground">
|
||
(scanare în curs…)
|
||
</span>
|
||
)}
|
||
</label>
|
||
)}
|
||
|
||
{/* Row 2: Background sync buttons */}
|
||
{session.connected && (
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-auto py-2.5 justify-start"
|
||
disabled={
|
||
exporting ||
|
||
(!!bgJobId && bgProgress?.status === "running")
|
||
}
|
||
onClick={() => void handleSyncBackground("base")}
|
||
>
|
||
{bgJobId &&
|
||
bgProgress?.status === "running" &&
|
||
!bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<ArrowDownToLine className="mr-2 h-4 w-4" />
|
||
)}
|
||
<div className="text-left">
|
||
<div className="text-xs font-semibold">
|
||
Sync fundal — Bază
|
||
</div>
|
||
<div className="text-[10px] opacity-60 font-normal">
|
||
Terenuri + clădiri → salvează în DB
|
||
</div>
|
||
</div>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
||
disabled={
|
||
exporting ||
|
||
(!!bgJobId && bgProgress?.status === "running")
|
||
}
|
||
onClick={() => void handleSyncBackground("magic")}
|
||
>
|
||
{bgJobId &&
|
||
bgProgress?.status === "running" &&
|
||
bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
||
)}
|
||
<div className="text-left">
|
||
<div className="text-xs font-semibold">
|
||
Sync fundal — Magic
|
||
</div>
|
||
<div className="text-[10px] opacity-60 font-normal">
|
||
Sync + îmbogățire → salvează în DB
|
||
</div>
|
||
</div>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Row 3: Download from DB buttons */}
|
||
{dbTotalFeatures > 0 && (
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-auto py-2.5 justify-start"
|
||
disabled={downloadingFromDb}
|
||
onClick={() => void handleDownloadFromDb("base")}
|
||
>
|
||
{downloadingFromDb ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Database className="mr-2 h-4 w-4" />
|
||
)}
|
||
<div className="text-left">
|
||
<div className="text-xs font-semibold">
|
||
Descarcă din DB — Bază
|
||
</div>
|
||
<div className="text-[10px] opacity-60 font-normal">
|
||
GPKG terenuri + clădiri (instant, fără eTerra)
|
||
</div>
|
||
</div>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
||
disabled={downloadingFromDb}
|
||
onClick={() => void handleDownloadFromDb("magic")}
|
||
>
|
||
{downloadingFromDb ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
||
)}
|
||
<div className="text-left">
|
||
<div className="text-xs font-semibold">
|
||
Descarcă din DB — Magic
|
||
</div>
|
||
<div className="text-[10px] opacity-60 font-normal">
|
||
GPKG + CSV + raport calitate (instant)
|
||
</div>
|
||
</div>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{!session.connected && dbTotalFeatures === 0 && (
|
||
<p className="text-xs text-muted-foreground ml-6">
|
||
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
||
sau sincronizează mai întâi date în baza locală.
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Background sync progress */}
|
||
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
||
<Card
|
||
className={cn(
|
||
"border-2 transition-colors",
|
||
bgProgress.status === "running" &&
|
||
"border-indigo-300 dark:border-indigo-700",
|
||
bgProgress.status === "error" &&
|
||
"border-rose-300 dark:border-rose-700",
|
||
bgProgress.status === "done" &&
|
||
"border-emerald-400 dark:border-emerald-600",
|
||
)}
|
||
>
|
||
<CardContent className="pt-4 space-y-3">
|
||
{/* Label */}
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<HardDrive className="h-3.5 w-3.5 text-indigo-500" />
|
||
<span className="font-semibold text-indigo-700 dark:text-indigo-400">
|
||
Sincronizare fundal
|
||
</span>
|
||
{bgProgress.status === "running" && (
|
||
<span className="text-muted-foreground">
|
||
(poți închide pagina)
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Phase trail */}
|
||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||
{bgPhaseTrail.map((p, i) => (
|
||
<span key={i} className="flex items-center gap-1">
|
||
{i > 0 && <span className="opacity-40">→</span>}
|
||
<span
|
||
className={cn(
|
||
i === bgPhaseTrail.length - 1
|
||
? "font-semibold text-foreground"
|
||
: "opacity-60",
|
||
)}
|
||
>
|
||
{p}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* Progress info */}
|
||
<div className="flex items-center gap-3">
|
||
{bgProgress.status === "running" && (
|
||
<Loader2 className="h-5 w-5 text-indigo-600 animate-spin shrink-0" />
|
||
)}
|
||
{bgProgress.status === "done" && (
|
||
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
||
)}
|
||
{bgProgress.status === "error" && (
|
||
<XCircle className="h-5 w-5 text-rose-500 shrink-0" />
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-medium">{bgProgress.phase}</p>
|
||
{bgProgress.note && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{bgProgress.note}
|
||
</p>
|
||
)}
|
||
{bgProgress.message && (
|
||
<p
|
||
className={cn(
|
||
"text-xs mt-0.5",
|
||
bgProgress.status === "error"
|
||
? "text-rose-500"
|
||
: "text-muted-foreground",
|
||
)}
|
||
>
|
||
{bgProgress.message}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<span className="text-sm font-mono font-semibold tabular-nums shrink-0">
|
||
{bgProgress.total && bgProgress.total > 0
|
||
? Math.round(
|
||
(bgProgress.downloaded / bgProgress.total) * 100,
|
||
)
|
||
: 0}
|
||
%
|
||
</span>
|
||
</div>
|
||
|
||
{/* Bar */}
|
||
<div className="h-2.5 w-full rounded-full bg-muted">
|
||
<div
|
||
className={cn(
|
||
"h-2.5 rounded-full transition-all duration-300",
|
||
bgProgress.status === "running" && "bg-indigo-500",
|
||
bgProgress.status === "done" && "bg-emerald-500",
|
||
bgProgress.status === "error" && "bg-rose-500",
|
||
)}
|
||
style={{
|
||
width: `${Math.max(2, bgProgress.total && bgProgress.total > 0 ? Math.round((bgProgress.downloaded / bgProgress.total) * 100) : 0)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Done — show download from DB button */}
|
||
{bgProgress.status === "done" && (
|
||
<div className="flex gap-2 pt-1">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-xs border-teal-200 dark:border-teal-800"
|
||
disabled={downloadingFromDb}
|
||
onClick={() => void handleDownloadFromDb("magic")}
|
||
>
|
||
{downloadingFromDb ? (
|
||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||
) : (
|
||
<Database className="mr-1.5 h-3 w-3" />
|
||
)}
|
||
Descarcă din DB (Magic)
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-xs"
|
||
onClick={() => {
|
||
setBgJobId(null);
|
||
setBgProgress(null);
|
||
setBgPhaseTrail([]);
|
||
}}
|
||
>
|
||
Închide
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Progress bar */}
|
||
{exportProgress &&
|
||
exportProgress.status !== "unknown" &&
|
||
exportJobId && (
|
||
<Card
|
||
className={cn(
|
||
"border-2 transition-colors",
|
||
exportProgress.status === "running" &&
|
||
"border-emerald-300 dark:border-emerald-700",
|
||
exportProgress.status === "error" &&
|
||
"border-rose-300 dark:border-rose-700",
|
||
exportProgress.status === "done" &&
|
||
"border-emerald-400 dark:border-emerald-600",
|
||
)}
|
||
>
|
||
<CardContent className="pt-4 space-y-3">
|
||
{/* Phase trail */}
|
||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||
{phaseTrail.map((p, i) => (
|
||
<span key={i} className="flex items-center gap-1">
|
||
{i > 0 && <span className="opacity-40">→</span>}
|
||
<span
|
||
className={cn(
|
||
i === phaseTrail.length - 1
|
||
? "font-semibold text-foreground"
|
||
: "opacity-60",
|
||
)}
|
||
>
|
||
{p}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* Progress info */}
|
||
<div className="flex items-center gap-3">
|
||
{exportProgress.status === "running" && (
|
||
<Loader2 className="h-5 w-5 text-emerald-600 animate-spin shrink-0" />
|
||
)}
|
||
{exportProgress.status === "done" && (
|
||
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
||
)}
|
||
{exportProgress.status === "error" && (
|
||
<XCircle className="h-5 w-5 text-rose-500 shrink-0" />
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-medium">
|
||
{exportProgress.phase}
|
||
{exportProgress.phaseCurrent != null &&
|
||
exportProgress.phaseTotal
|
||
? ` — ${exportProgress.phaseCurrent} / ${exportProgress.phaseTotal}`
|
||
: ""}
|
||
</p>
|
||
{exportProgress.note && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{exportProgress.note}
|
||
</p>
|
||
)}
|
||
{exportProgress.message && (
|
||
<p
|
||
className={cn(
|
||
"text-xs mt-0.5",
|
||
exportProgress.status === "error"
|
||
? "text-rose-500"
|
||
: "text-muted-foreground",
|
||
)}
|
||
>
|
||
{exportProgress.message}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<span className="text-sm font-mono font-semibold tabular-nums shrink-0">
|
||
{progressPct}%
|
||
</span>
|
||
</div>
|
||
|
||
{/* Bar */}
|
||
<div className="h-2.5 w-full rounded-full bg-muted">
|
||
<div
|
||
className={cn(
|
||
"h-2.5 rounded-full transition-all duration-300",
|
||
exportProgress.status === "running" && "bg-emerald-500",
|
||
exportProgress.status === "done" && "bg-emerald-500",
|
||
exportProgress.status === "error" && "bg-rose-500",
|
||
)}
|
||
style={{ width: `${Math.max(2, progressPct)}%` }}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
{/* Tab 4: Baza de Date */}
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
<TabsContent value="database" className="space-y-3">
|
||
{dbSummaryLoading && !dbSummary ? (
|
||
<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 încarcă datele din baza de date…</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : !dbSummary || dbSummary.totalFeatures === 0 ? (
|
||
<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ă în baza de date</p>
|
||
<p className="text-xs mt-1">
|
||
Folosește tab-ul Export pentru a sincroniza date din eTerra.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
{/* 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ăți
|
||
</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={() => void fetchDbSummary()}
|
||
>
|
||
{dbSummaryLoading ? (
|
||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="h-3 w-3 mr-1" />
|
||
)}
|
||
Reîncarcă
|
||
</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) : "—"}
|
||
</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ără 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ăți${isEnriched ? `, ${layer.enrichedCount} îmbogățite` : ""}${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>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
{/* Tab 5: Extrase CF */}
|
||
{/* ═══════════════════════════════════════════════════════ */}
|
||
<TabsContent value="extracts" className="space-y-4">
|
||
<EpayTab />
|
||
</TabsContent>
|
||
</Tabs>
|
||
);
|
||
}
|