diff --git a/src/app/api/eterra/uat-dashboard/route.ts b/src/app/api/eterra/uat-dashboard/route.ts new file mode 100644 index 0000000..de170d4 --- /dev/null +++ b/src/app/api/eterra/uat-dashboard/route.ts @@ -0,0 +1,418 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type UatDashboardData = { + siruta: string; + uatName: string; + county: string; + + /* ── Counts ────────────────────────────── */ + totalTerenuri: number; + totalCladiri: number; + totalNoGeom: number; + totalEnriched: number; + enrichmentPct: number; // 0-100 + + /* ── Area ──────────────────────────────── */ + totalAreaHa: number; // total area in hectares + avgAreaMp: number; + medianAreaMp: number; + areaDistribution: { bucket: string; count: number; pct: number }[]; + + /* ── Intravilan ────────────────────────── */ + intravilanCount: number; + extravilanCount: number; + mixtCount: number; + intravilanPct: number; + + /* ── Buildings ─────────────────────────── */ + withBuildingCount: number; + buildingLegalCount: number; + withBuildingPct: number; + + /* ── Ownership ─────────────────────────── */ + uniqueOwnerCount: number; + topOwners: { name: string; count: number }[]; + parcelsWithoutCF: number; + parcelsWithoutCFPct: number; + + /* ── Land use (categorie folosinta) ────── */ + landUseDistribution: { + category: string; + count: number; + areaMp: number; + pct: number; + }[]; + + /* ── Sync ──────────────────────────────── */ + lastSyncDate: string | null; + syncRunCount: number; + + /* ── Fun facts ─────────────────────────── */ + funFacts: string[]; +}; + +/* ------------------------------------------------------------------ */ +/* GET /api/eterra/uat-dashboard?siruta=XXXX */ +/* ------------------------------------------------------------------ */ + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const siruta = url.searchParams.get("siruta")?.trim(); + + if (!siruta || !/^\d+$/.test(siruta)) { + return NextResponse.json( + { error: "SIRUTA obligatoriu" }, + { status: 400 }, + ); + } + + // ── UAT info ── + const uat = await prisma.gisUat.findUnique({ where: { siruta } }); + const uatName = uat?.name ?? siruta; + const county = uat?.county ?? ""; + + // ── Basic counts ── + const [terenuriCount, cladiriCount, noGeomCount, enrichedCount] = + await Promise.all([ + prisma.gisFeature.count({ + where: { siruta, layerId: "TERENURI_ACTIVE" }, + }), + prisma.gisFeature.count({ + where: { siruta, layerId: "CLADIRI_ACTIVE" }, + }), + prisma.gisFeature.count({ + where: { siruta, geometrySource: "NO_GEOMETRY" }, + }), + prisma.gisFeature.count({ + where: { siruta, enrichedAt: { not: null } }, + }), + ]); + + const totalTerenuri = terenuriCount; + if (totalTerenuri === 0) { + return NextResponse.json({ + error: "Nicio parcelă pentru acest UAT.", + }); + } + const enrichmentPct = Math.round((enrichedCount / totalTerenuri) * 100); + + // ── Area stats (from enrichment or areaValue) ── + const areaStats = await prisma.$queryRaw< + [{ total_area: number | null; avg_area: number | null; cnt: number }] + >` + SELECT + COALESCE(SUM("areaValue"), 0)::float AS total_area, + COALESCE(AVG("areaValue"), 0)::float AS avg_area, + COUNT(*)::int AS cnt + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND "areaValue" IS NOT NULL + AND "areaValue" > 0 + `; + const totalAreaMp = areaStats[0]?.total_area ?? 0; + const totalAreaHa = Math.round((totalAreaMp / 10000) * 100) / 100; + const avgAreaMp = Math.round(areaStats[0]?.avg_area ?? 0); + + // Median area + const medianResult = await prisma.$queryRaw<[{ median: number | null }]>` + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY "areaValue")::float AS median + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND "areaValue" IS NOT NULL + AND "areaValue" > 0 + `; + const medianAreaMp = Math.round(medianResult[0]?.median ?? 0); + + // Area distribution buckets + const areaBuckets = await prisma.$queryRaw< + Array<{ bucket: string; count: number }> + >` + SELECT + CASE + WHEN "areaValue" <= 100 THEN '0-100' + WHEN "areaValue" <= 500 THEN '100-500' + WHEN "areaValue" <= 1000 THEN '500-1.000' + WHEN "areaValue" <= 2500 THEN '1.000-2.500' + WHEN "areaValue" <= 5000 THEN '2.500-5.000' + WHEN "areaValue" <= 10000 THEN '5.000-10.000' + ELSE '10.000+' + END AS bucket, + COUNT(*)::int AS count + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND "areaValue" IS NOT NULL + AND "areaValue" > 0 + GROUP BY bucket + ORDER BY MIN("areaValue") + `; + const totalWithArea = areaBuckets.reduce((s, b) => s + b.count, 0) || 1; + const areaDistribution = areaBuckets.map((b) => ({ + bucket: b.bucket, + count: b.count, + pct: Math.round((b.count / totalWithArea) * 100), + })); + + // ── Intravilan/Extravilan (from enrichment) ── + const intravilanStats = await prisma.$queryRaw< + Array<{ val: string; count: number }> + >` + SELECT + COALESCE(enrichment->>'INTRAVILAN', '-') AS val, + COUNT(*)::int AS count + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND enrichment IS NOT NULL + GROUP BY val + `; + let intravilanCount = 0; + let extravilanCount = 0; + let mixtCount = 0; + for (const row of intravilanStats) { + if (row.val === "Da") intravilanCount = row.count; + else if (row.val === "Nu") extravilanCount = row.count; + else if (row.val === "Mixt") mixtCount = row.count; + } + const totalIntraBase = intravilanCount + extravilanCount + mixtCount || 1; + const intravilanPct = Math.round((intravilanCount / totalIntraBase) * 100); + + // ── Buildings (from enrichment) ── + const buildingStats = await prisma.$queryRaw< + [{ with_building: number; legal_building: number }] + >` + SELECT + COUNT(*) FILTER (WHERE (enrichment->>'HAS_BUILDING')::int = 1)::int AS with_building, + COUNT(*) FILTER (WHERE (enrichment->>'BUILD_LEGAL')::int = 1)::int AS legal_building + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND enrichment IS NOT NULL + `; + const withBuildingCount = buildingStats[0]?.with_building ?? 0; + const buildingLegalCount = buildingStats[0]?.legal_building ?? 0; + const withBuildingPct = + enrichedCount > 0 + ? Math.round((withBuildingCount / enrichedCount) * 100) + : 0; + + // ── Land use categories (from enrichment) ── + // enrichment CATEGORIE_FOLOSINTA looks like "Arabil:2500.00; Pășune:1200.00" + const landUseRaw = await prisma.$queryRaw>` + SELECT enrichment->>'CATEGORIE_FOLOSINTA' AS cat + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND enrichment IS NOT NULL + AND enrichment->>'CATEGORIE_FOLOSINTA' IS NOT NULL + AND enrichment->>'CATEGORIE_FOLOSINTA' != '' + AND enrichment->>'CATEGORIE_FOLOSINTA' != '-' + `; + const landUseMap = new Map(); + for (const row of landUseRaw) { + // Parse "Arabil:2500.00; Pășune:1200.00" format + const parts = (row.cat ?? "").split(";").map((s) => s.trim()); + for (const part of parts) { + const [name, areaStr] = part.split(":"); + if (!name) continue; + const key = name.trim(); + const area = parseFloat(areaStr ?? "0") || 0; + const existing = landUseMap.get(key) ?? { count: 0, area: 0 }; + existing.count += 1; + existing.area += area; + landUseMap.set(key, existing); + } + } + const totalLandUseCount = + Array.from(landUseMap.values()).reduce((s, v) => s + v.count, 0) || 1; + const landUseDistribution = Array.from(landUseMap.entries()) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 15) + .map(([category, data]) => ({ + category, + count: data.count, + areaMp: Math.round(data.area), + pct: Math.round((data.count / totalLandUseCount) * 100), + })); + + // ── Ownership stats (from enrichment) ── + const ownerRaw = await prisma.$queryRaw>` + SELECT enrichment->>'PROPRIETARI' AS owners + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND enrichment IS NOT NULL + AND enrichment->>'PROPRIETARI' IS NOT NULL + AND enrichment->>'PROPRIETARI' != '' + AND enrichment->>'PROPRIETARI' != '-' + `; + const ownerCountMap = new Map(); + for (const row of ownerRaw) { + const names = (row.owners ?? "") + .split(";") + .map((n) => n.trim()) + .filter(Boolean); + for (const name of names) { + ownerCountMap.set(name, (ownerCountMap.get(name) ?? 0) + 1); + } + } + const uniqueOwnerCount = ownerCountMap.size; + const topOwners = Array.from(ownerCountMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name, count]) => ({ name, count })); + + // Parcels without CF + const noCFResult = await prisma.$queryRaw<[{ count: number }]>` + SELECT COUNT(*)::int AS count + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND enrichment IS NOT NULL + AND ( + enrichment->>'NR_CF' IS NULL + OR enrichment->>'NR_CF' = '' + OR enrichment->>'NR_CF' = '-' + ) + `; + const parcelsWithoutCF = noCFResult[0]?.count ?? 0; + const parcelsWithoutCFPct = + enrichedCount > 0 + ? Math.round((parcelsWithoutCF / enrichedCount) * 100) + : 0; + + // ── Sync runs ── + const syncRuns = await prisma.gisSyncRun.findMany({ + where: { siruta }, + orderBy: { startedAt: "desc" }, + take: 1, + select: { startedAt: true }, + }); + const syncRunCount = await prisma.gisSyncRun.count({ + where: { siruta }, + }); + const lastSyncDate = syncRuns[0]?.startedAt?.toISOString() ?? null; + + // ── Fun facts ── + const funFacts: string[] = []; + + // Largest parcel + const largestParcel = await prisma.$queryRaw< + [{ cad: string | null; area: number | null }] + >` + SELECT "cadastralRef" AS cad, "areaValue" AS area + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND "areaValue" IS NOT NULL + ORDER BY "areaValue" DESC + LIMIT 1 + `; + if (largestParcel[0]?.area) { + const aHa = (largestParcel[0].area / 10000).toFixed(2); + funFacts.push( + `Cea mai mare parcelă: ${largestParcel[0].cad ?? "?"} — ${aHa} ha (${Math.round(largestParcel[0].area).toLocaleString("ro-RO")} mp)`, + ); + } + + // Smallest parcel + const smallestParcel = await prisma.$queryRaw< + [{ cad: string | null; area: number | null }] + >` + SELECT "cadastralRef" AS cad, "areaValue" AS area + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND "areaValue" IS NOT NULL + AND "areaValue" > 0 + ORDER BY "areaValue" ASC + LIMIT 1 + `; + if (smallestParcel[0]?.area) { + funFacts.push( + `Cea mai mică parcelă: ${smallestParcel[0].cad ?? "?"} — ${Math.round(smallestParcel[0].area).toLocaleString("ro-RO")} mp`, + ); + } + + // Owner with most parcels + const topOwner = topOwners[0]; + if (topOwner) { + funFacts.push( + `Cel mai mare proprietar: ${topOwner.name} — ${topOwner.count} parcele`, + ); + } + + // Intravilan ratio insight + if (intravilanPct > 70) { + funFacts.push(`UAT predominant intravilan (${intravilanPct}%)`); + } else if (intravilanPct < 30 && enrichedCount > 0) { + funFacts.push( + `UAT predominant extravilan (doar ${intravilanPct}% intravilan)`, + ); + } + + // Building density + if (withBuildingPct > 50) { + funFacts.push( + `Densitate ridicată de clădiri: ${withBuildingPct}% din parcele au construcții`, + ); + } + + // Coverage gap + if (noGeomCount > 0) { + const pct = Math.round( + (noGeomCount / (totalTerenuri + noGeomCount)) * 100, + ); + funFacts.push( + `${pct}% din imobile nu au geometrie GIS (${noGeomCount.toLocaleString("ro-RO")} fără geom)`, + ); + } + + const result: UatDashboardData = { + siruta, + uatName, + county, + totalTerenuri, + totalCladiri: cladiriCount, + totalNoGeom: noGeomCount, + totalEnriched: enrichedCount, + enrichmentPct, + totalAreaHa, + avgAreaMp, + medianAreaMp, + areaDistribution, + intravilanCount, + extravilanCount, + mixtCount, + intravilanPct, + withBuildingCount, + buildingLegalCount, + withBuildingPct, + uniqueOwnerCount, + topOwners, + parcelsWithoutCF, + parcelsWithoutCFPct, + landUseDistribution, + lastSyncDate, + syncRunCount, + funFacts, + }; + + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + console.error("[uat-dashboard]", message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 564eee1..7673266 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -25,6 +25,7 @@ import { Clock, ArrowDownToLine, AlertTriangle, + BarChart3, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; @@ -55,6 +56,7 @@ import { import type { ParcelDetail } from "@/app/api/eterra/search/route"; import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route"; import { User } from "lucide-react"; +import { UatDashboard } from "./uat-dashboard"; /* ------------------------------------------------------------------ */ /* Types */ @@ -379,7 +381,9 @@ export function ParcelSyncModule() { } | null>(null); /* ── Parcel search tab ──────────────────────────────────────── */ - const [searchMode, setSearchMode] = useState<"cadastral" | "owner">("cadastral"); + const [searchMode, setSearchMode] = useState<"cadastral" | "owner">( + "cadastral", + ); const [searchResults, setSearchResults] = useState([]); const [searchList, setSearchList] = useState([]); const [featuresSearch, setFeaturesSearch] = useState(""); @@ -391,6 +395,8 @@ export function ParcelSyncModule() { const [ownerLoading, setOwnerLoading] = useState(false); const [ownerError, setOwnerError] = useState(""); const [ownerNote, setOwnerNote] = useState(""); + /* dashboard */ + const [dashboardSiruta, setDashboardSiruta] = useState(null); /* ── No-geometry import option ──────────────────────────────── */ const [includeNoGeom, setIncludeNoGeom] = useState(false); @@ -1791,13 +1797,18 @@ export function ParcelSyncModule() { {!session.connected && (

- Necesită conexiune eTerra. Folosește modul Proprietar pentru a căuta offline în DB. + Necesită conexiune eTerra. Folosește modul Proprietar + pentru a căuta offline în DB.

)} - )} - - - +
+ {searchResults.length > 0 && ( + + )} + +
+ - {/* Detail cards */} -
- {searchResults.map((p, idx) => ( - - -
-
-

- Nr. Cad. {p.nrCad} -

- {!p.immovablePk && ( -

- Parcela nu a fost găsită în eTerra. -

- )} -
-
- - -
-
- - {p.immovablePk && ( -
-
- - Nr. CF - - - {p.nrCF || "—"} - -
- {p.nrCFVechi && ( + {/* Detail cards */} +
+ {searchResults.map((p, idx) => ( + + +
- - CF vechi - - {p.nrCFVechi} -
- )} -
- - Nr. Topo - - {p.nrTopo || "—"} -
-
- - Suprafață - - - {p.suprafata != null - ? formatArea(p.suprafata) - : "—"} - -
-
- - Intravilan - - - {p.intravilan || "—"} - -
- {p.categorieFolosinta && ( -
- - Categorii folosință - - - {p.categorieFolosinta} - -
- )} - {p.adresa && ( -
- - Adresă - - {p.adresa} -
- )} - {(p.proprietariActuali || p.proprietariVechi) && ( -
- {p.proprietariActuali && ( -
- - Proprietari actuali - - - {p.proprietariActuali} - -
+

+ Nr. Cad. {p.nrCad} +

+ {!p.immovablePk && ( +

+ Parcela nu a fost găsită în eTerra. +

)} - {p.proprietariVechi && ( +
+
+ + +
+
+ + {p.immovablePk && ( +
+
+ + Nr. CF + + + {p.nrCF || "—"} + +
+ {p.nrCFVechi && (
- Proprietari anteriori + CF vechi - - {p.proprietariVechi} + {p.nrCFVechi} +
+ )} +
+ + Nr. Topo + + {p.nrTopo || "—"} +
+
+ + Suprafață + + + {p.suprafata != null + ? formatArea(p.suprafata) + : "—"} + +
+
+ + Intravilan + + + {p.intravilan || "—"} + +
+ {p.categorieFolosinta && ( +
+ + Categorii folosință + + + {p.categorieFolosinta}
)} - {!p.proprietariActuali && - !p.proprietariVechi && - p.proprietari && ( -
- - Proprietari - - {p.proprietari} -
- )} + {p.adresa && ( +
+ + Adresă + + {p.adresa} +
+ )} + {(p.proprietariActuali || + p.proprietariVechi) && ( +
+ {p.proprietariActuali && ( +
+ + Proprietari actuali + + + {p.proprietariActuali} + +
+ )} + {p.proprietariVechi && ( +
+ + Proprietari anteriori + + + {p.proprietariVechi} + +
+ )} + {!p.proprietariActuali && + !p.proprietariVechi && + p.proprietari && ( +
+ + Proprietari + + {p.proprietari} +
+ )} +
+ )} + {p.solicitant && ( +
+ + Solicitant + + {p.solicitant} +
+ )}
)} - {p.solicitant && ( -
- - Solicitant - - {p.solicitant} -
- )} -
- )} + + + ))} +
+ + )} + + {/* Empty state when no search has been done */} + {searchMode === "cadastral" && + searchResults.length === 0 && + !loadingFeatures && + !searchError && ( + + + +

Introdu un număr cadastral și apasă Caută.

+

+ Poți căuta mai multe parcele simultan, separate prin + virgulă. +

- ))} -
- - )} - - {/* Empty state when no search has been done */} - {searchMode === "cadastral" && searchResults.length === 0 && !loadingFeatures && !searchError && ( - - - -

Introdu un număr cadastral și apasă Caută.

-

- Poți căuta mai multe parcele simultan, separate prin - virgulă. -

-
-
- )} + )} )} @@ -2166,8 +2182,7 @@ export function ParcelSyncModule() { variant="default" onClick={downloadCSV} disabled={ - ownerResults.length === 0 && - searchList.length === 0 + ownerResults.length === 0 && searchList.length === 0 } > @@ -2330,7 +2345,8 @@ export function ParcelSyncModule() {

Caută în datele îmbogățite (DB local) și pe eTerra.
- Pentru rezultate complete, lansează "Sync fundal — Magic" în tab-ul Export. + Pentru rezultate complete, lansează "Sync fundal — + Magic" în tab-ul Export.

@@ -3885,120 +3901,149 @@ export function ParcelSyncModule() { const isCurrentUat = sirutaValid && uat.siruta === siruta; return ( - - - {/* UAT header row */} -
- - {uat.uatName} - - {uat.county && ( - - ({uat.county}) +
+ + + {/* UAT header row */} +
+ + {uat.uatName} - )} - - #{uat.siruta} - - {isCurrentUat && ( - - selectat - - )} - - {oldestSync ? relativeTime(oldestSync) : "—"} - -
- - {/* Category counts in a single compact row */} -
- {( - Object.entries(LAYER_CATEGORY_LABELS) as [ - LayerCategory, - string, - ][] - ).map(([cat, label]) => { - const count = catCounts[cat] ?? 0; - if (count === 0) return null; - return ( - + ({uat.county}) + + )} + + #{uat.siruta} + + {isCurrentUat && ( + - - {label}: - - - {count.toLocaleString("ro-RO")} - - - ); - })} - {enrichedTotal > 0 && ( - - - Magic: - - {enrichedTotal.toLocaleString("ro-RO")} + selectat + + )} + + + + {oldestSync ? relativeTime(oldestSync) : "—"} - )} - {noGeomTotal > 0 && ( - - - Fără geom: - - - {noGeomTotal.toLocaleString("ro-RO")} - - - )} -
+
- {/* Layer detail pills */} -
- {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; + {/* Category counts in a single compact row */} +
+ {( + Object.entries(LAYER_CATEGORY_LABELS) as [ + LayerCategory, + string, + ][] + ).map(([cat, label]) => { + const count = catCounts[cat] ?? 0; + if (count === 0) return null; return ( - - {label} + + {label}: - {layer.count.toLocaleString("ro-RO")} + {count.toLocaleString("ro-RO")} - {isEnriched && ( - - )} ); })} -
- - + {enrichedTotal > 0 && ( + + + + Magic: + + + {enrichedTotal.toLocaleString("ro-RO")} + + + )} + {noGeomTotal > 0 && ( + + + Fără geom: + + + {noGeomTotal.toLocaleString("ro-RO")} + + + )} +
+ + {/* Layer detail pills */} +
+ {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 ( + + + {label} + + + {layer.count.toLocaleString("ro-RO")} + + {isEnriched && ( + + )} + + ); + })} +
+ + + + {/* Dashboard panel (expanded below card) */} + {dashboardSiruta === uat.siruta && ( + setDashboardSiruta(null)} + /> + )} +
); })} diff --git a/src/modules/parcel-sync/components/uat-dashboard.tsx b/src/modules/parcel-sync/components/uat-dashboard.tsx new file mode 100644 index 0000000..1d70f7a --- /dev/null +++ b/src/modules/parcel-sync/components/uat-dashboard.tsx @@ -0,0 +1,555 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + BarChart3, + MapPin, + Users, + Building2, + TreePine, + Ruler, + Sparkles, + FileQuestion, + Lightbulb, + X, + Loader2, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { Button } from "@/shared/components/ui/button"; +import { Badge } from "@/shared/components/ui/badge"; +import { cn } from "@/shared/lib/utils"; +import type { UatDashboardData } from "@/app/api/eterra/uat-dashboard/route"; + +/* ------------------------------------------------------------------ */ +/* Bar component (CSS only) */ +/* ------------------------------------------------------------------ */ + +function Bar({ + pct, + color = "bg-emerald-500", + className, +}: { + pct: number; + color?: string; + className?: string; +}) { + return ( +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* KPI Card */ +/* ------------------------------------------------------------------ */ + +function KpiCard({ + icon: Icon, + label, + value, + sub, + accent = "text-foreground", +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: string | number; + sub?: string; + accent?: string; +}) { + return ( +
+
+ +
+
+

+ {label} +

+

+ {typeof value === "number" ? value.toLocaleString("ro-RO") : value} +

+ {sub && ( +

+ {sub} +

+ )} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Horizontal bar chart */ +/* ------------------------------------------------------------------ */ + +function HBarChart({ + data, + labelKey, + valueKey, + pctKey, + color = "bg-emerald-500", + maxBars = 10, + formatValue, +}: { + data: Record[]; + labelKey: string; + valueKey: string; + pctKey: string; + color?: string; + maxBars?: number; + formatValue?: (v: number) => string; +}) { + const items = data.slice(0, maxBars); + if (items.length === 0) + return

Fără date

; + const maxPct = Math.max(...items.map((d) => Number(d[pctKey] ?? 0)), 1); + + return ( +
+ {items.map((d, i) => { + const pct = Number(d[pctKey] ?? 0); + const val = Number(d[valueKey] ?? 0); + const label = String(d[labelKey] ?? ""); + return ( +
+ + {label} + +
+ + + {formatValue ? formatValue(val) : val.toLocaleString("ro-RO")} + +
+ + {pct}% + +
+ ); + })} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Donut-ish ring (CSS conic-gradient) */ +/* ------------------------------------------------------------------ */ + +function DonutRing({ + segments, + size = 80, +}: { + segments: { pct: number; color: string; label: string }[]; + size?: number; +}) { + let cumulative = 0; + const stops: string[] = []; + for (const seg of segments) { + const start = cumulative; + cumulative += seg.pct; + stops.push(`${seg.color} ${start}% ${cumulative}%`); + } + if (cumulative < 100) { + stops.push(`hsl(var(--muted)) ${cumulative}% 100%`); + } + + return ( +
+
+
+ {segments.map((seg) => ( +
+ + {seg.label} + {seg.pct}% +
+ ))} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Main dashboard component */ +/* ------------------------------------------------------------------ */ + +export function UatDashboard({ + siruta, + uatName, + onClose, +}: { + siruta: string; + uatName: string; + onClose: () => void; +}) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [expanded, setExpanded] = useState>({}); + const [loaded, setLoaded] = useState(false); + + const toggle = (key: string) => + setExpanded((prev) => ({ ...prev, [key]: !prev[key] })); + + const fetchDashboard = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await fetch( + `/api/eterra/uat-dashboard?siruta=${encodeURIComponent(siruta)}`, + ); + const json = await res.json(); + if (json.error) { + setError(json.error); + } else { + setData(json as UatDashboardData); + } + } catch { + setError("Eroare de rețea."); + } + setLoading(false); + setLoaded(true); + }, [siruta]); + + // Auto-fetch on first render + if (!loaded && !loading) { + void fetchDashboard(); + } + + if (loading && !data) { + return ( + + + +

+ Se calculează dashboard-ul pentru {uatName}… +

+
+
+ ); + } + + if (error) { + return ( + + +

{error}

+ +
+
+ ); + } + + if (!data) return null; + + return ( +
+ {/* ── Header ─────────────────────────────────── */} +
+
+ +

+ Dashboard — {data.uatName} +

+ {data.county && ( + + ({data.county}) + + )} + + {data.siruta} + +
+ +
+ + {/* ── KPI Grid ───────────────────────────────── */} +
+ + + + + + 30 + ? "text-red-500" + : "text-muted-foreground" + } + /> +
+ + {/* ── Row 2: Donut + Area distribution ───────── */} +
+ {/* Intravilan/Extravilan donut */} + + +

+ + Intravilan / Extravilan +

+ 0 + ? Math.round( + (data.mixtCount / + (data.intravilanCount + + data.extravilanCount + + data.mixtCount || 1)) * + 100, + ) + : 0), + color: "hsl(30, 70%, 50%)", + label: `Extravilan (${data.extravilanCount.toLocaleString("ro-RO")})`, + }, + ...(data.mixtCount > 0 + ? [ + { + pct: Math.round( + (data.mixtCount / + (data.intravilanCount + + data.extravilanCount + + data.mixtCount || 1)) * + 100, + ), + color: "hsl(200, 50%, 55%)", + label: `Mixt (${data.mixtCount.toLocaleString("ro-RO")})`, + }, + ] + : []), + ]} + /> +
+
+ + {/* Area distribution */} + + +

+ + Distribuție suprafețe (mp) +

+ [] + } + labelKey="bucket" + valueKey="count" + pctKey="pct" + color="bg-indigo-500" + /> +
+
+
+ + {/* ── Row 3: Land use + Top owners ──────────── */} +
+ {/* Land use */} + + + + {(expanded.landuse ?? true) && ( +
+ [] + } + labelKey="category" + valueKey="count" + pctKey="pct" + color="bg-green-500" + /> +
+ )} +
+
+ + {/* Top owners */} + + + + {(expanded.owners ?? true) && ( +
+ {data.topOwners.length === 0 ? ( +

+ Fără date proprietari (necesită îmbogățire Magic) +

+ ) : ( + data.topOwners.map((o, i) => ( +
+ + {i + 1}. + + {o.name} + + {o.count} parcele + +
+ )) + )} +
+ )} +
+
+
+ + {/* ── Fun facts ────────────────────────────── */} + {data.funFacts.length > 0 && ( + + +

+ + Observații +

+
    + {data.funFacts.map((fact, i) => ( +
  • + + {fact} +
  • + ))} +
+
+
+ )} + + {/* ── Footer meta ──────────────────────────── */} +
+ + Ultimul sync:{" "} + {data.lastSyncDate + ? new Date(data.lastSyncDate).toLocaleString("ro-RO") + : "—"} + {data.syncRunCount > 0 && ` · ${data.syncRunCount} sync-uri totale`} + + + {data.totalNoGeom > 0 && + `${data.totalNoGeom.toLocaleString("ro-RO")} fără geometrie · `} + Date la {new Date().toLocaleDateString("ro-RO")} + +
+
+ ); +}