feat(parcel-sync): per-UAT analytics dashboard in Database tab
- New API route /api/eterra/uat-dashboard with SQL aggregates (area stats, intravilan/extravilan split, land use, top owners, fun facts) - CSS-only dashboard component: KPI cards, donut ring, bar charts - Dashboard button on each UAT card in DB tab, expands panel below
This commit is contained in:
@@ -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<Array<{ cat: string }>>`
|
||||||
|
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<string, { count: number; area: number }>();
|
||||||
|
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<Array<{ owners: string }>>`
|
||||||
|
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<string, number>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||||
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
||||||
import { User } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
|
import { UatDashboard } from "./uat-dashboard";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -379,7 +381,9 @@ export function ParcelSyncModule() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
/* ── Parcel search tab ──────────────────────────────────────── */
|
/* ── Parcel search tab ──────────────────────────────────────── */
|
||||||
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">("cadastral");
|
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">(
|
||||||
|
"cadastral",
|
||||||
|
);
|
||||||
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
||||||
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||||||
const [featuresSearch, setFeaturesSearch] = useState("");
|
const [featuresSearch, setFeaturesSearch] = useState("");
|
||||||
@@ -391,6 +395,8 @@ export function ParcelSyncModule() {
|
|||||||
const [ownerLoading, setOwnerLoading] = useState(false);
|
const [ownerLoading, setOwnerLoading] = useState(false);
|
||||||
const [ownerError, setOwnerError] = useState("");
|
const [ownerError, setOwnerError] = useState("");
|
||||||
const [ownerNote, setOwnerNote] = useState("");
|
const [ownerNote, setOwnerNote] = useState("");
|
||||||
|
/* dashboard */
|
||||||
|
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
|
||||||
|
|
||||||
/* ── No-geometry import option ──────────────────────────────── */
|
/* ── No-geometry import option ──────────────────────────────── */
|
||||||
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
||||||
@@ -1791,13 +1797,18 @@ export function ParcelSyncModule() {
|
|||||||
</div>
|
</div>
|
||||||
{!session.connected && (
|
{!session.connected && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void handleSearch()}
|
onClick={() => void handleSearch()}
|
||||||
disabled={loadingFeatures || !featuresSearch.trim() || !session.connected}
|
disabled={
|
||||||
|
loadingFeatures ||
|
||||||
|
!featuresSearch.trim() ||
|
||||||
|
!session.connected
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{loadingFeatures ? (
|
{loadingFeatures ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -1863,8 +1874,8 @@ export function ParcelSyncModule() {
|
|||||||
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
||||||
<p>Se caută în eTerra...</p>
|
<p>Se caută în eTerra...</p>
|
||||||
<p className="text-xs mt-1 opacity-60">
|
<p className="text-xs mt-1 opacity-60">
|
||||||
Prima căutare pe un UAT nou poate dura ~10-30s (se încarcă
|
Prima căutare pe un UAT nou poate dura ~10-30s (se
|
||||||
lista de județe).
|
încarcă lista de județe).
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1901,7 +1912,8 @@ export function ParcelSyncModule() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
onClick={downloadCSV}
|
onClick={downloadCSV}
|
||||||
disabled={
|
disabled={
|
||||||
searchResults.length === 0 && searchList.length === 0
|
searchResults.length === 0 &&
|
||||||
|
searchList.length === 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||||
@@ -2058,7 +2070,8 @@ export function ParcelSyncModule() {
|
|||||||
<span>{p.adresa}</span>
|
<span>{p.adresa}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(p.proprietariActuali || p.proprietariVechi) && (
|
{(p.proprietariActuali ||
|
||||||
|
p.proprietariVechi) && (
|
||||||
<div className="col-span-2 lg:col-span-4">
|
<div className="col-span-2 lg:col-span-4">
|
||||||
{p.proprietariActuali && (
|
{p.proprietariActuali && (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
@@ -2110,7 +2123,10 @@ export function ParcelSyncModule() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state when no search has been done */}
|
{/* Empty state when no search has been done */}
|
||||||
{searchMode === "cadastral" && searchResults.length === 0 && !loadingFeatures && !searchError && (
|
{searchMode === "cadastral" &&
|
||||||
|
searchResults.length === 0 &&
|
||||||
|
!loadingFeatures &&
|
||||||
|
!searchError && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
@@ -2166,8 +2182,7 @@ export function ParcelSyncModule() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
onClick={downloadCSV}
|
onClick={downloadCSV}
|
||||||
disabled={
|
disabled={
|
||||||
ownerResults.length === 0 &&
|
ownerResults.length === 0 && searchList.length === 0
|
||||||
searchList.length === 0
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||||
@@ -2330,7 +2345,8 @@ export function ParcelSyncModule() {
|
|||||||
<p className="text-xs mt-1 opacity-60">
|
<p className="text-xs mt-1 opacity-60">
|
||||||
Caută în datele îmbogățite (DB local) și pe eTerra.
|
Caută în datele îmbogățite (DB local) și pe eTerra.
|
||||||
<br />
|
<br />
|
||||||
Pentru rezultate complete, lansează "Sync fundal — Magic" în tab-ul Export.
|
Pentru rezultate complete, lansează "Sync fundal —
|
||||||
|
Magic" în tab-ul Export.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -3885,8 +3901,8 @@ export function ParcelSyncModule() {
|
|||||||
const isCurrentUat = sirutaValid && uat.siruta === siruta;
|
const isCurrentUat = sirutaValid && uat.siruta === siruta;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={uat.siruta} className="space-y-3">
|
||||||
<Card
|
<Card
|
||||||
key={uat.siruta}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
isCurrentUat && "ring-1 ring-emerald-400/50",
|
isCurrentUat && "ring-1 ring-emerald-400/50",
|
||||||
@@ -3914,9 +3930,26 @@ export function ParcelSyncModule() {
|
|||||||
selectat
|
selectat
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
<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) : "—"}
|
{oldestSync ? relativeTime(oldestSync) : "—"}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category counts in a single compact row */}
|
{/* Category counts in a single compact row */}
|
||||||
@@ -3946,7 +3979,9 @@ export function ParcelSyncModule() {
|
|||||||
{enrichedTotal > 0 && (
|
{enrichedTotal > 0 && (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3 text-teal-600 dark:text-teal-400" />
|
<Sparkles className="h-3 w-3 text-teal-600 dark:text-teal-400" />
|
||||||
<span className="text-muted-foreground">Magic:</span>
|
<span className="text-muted-foreground">
|
||||||
|
Magic:
|
||||||
|
</span>
|
||||||
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">
|
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">
|
||||||
{enrichedTotal.toLocaleString("ro-RO")}
|
{enrichedTotal.toLocaleString("ro-RO")}
|
||||||
</span>
|
</span>
|
||||||
@@ -3999,6 +4034,16 @@ export function ParcelSyncModule() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Dashboard panel (expanded below card) */}
|
||||||
|
{dashboardSiruta === uat.siruta && (
|
||||||
|
<UatDashboard
|
||||||
|
siruta={uat.siruta}
|
||||||
|
uatName={uat.uatName}
|
||||||
|
onClose={() => setDashboardSiruta(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-sm transition-all duration-500",
|
||||||
|
color,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.max(pct, 1)}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 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 (
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border bg-card">
|
||||||
|
<div className="mt-0.5 p-1.5 rounded-md bg-muted">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-tight">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn("text-xl font-bold tabular-nums leading-tight", accent)}
|
||||||
|
>
|
||||||
|
{typeof value === "number" ? value.toLocaleString("ro-RO") : value}
|
||||||
|
</p>
|
||||||
|
{sub && (
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5 leading-tight">
|
||||||
|
{sub}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Horizontal bar chart */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function HBarChart({
|
||||||
|
data,
|
||||||
|
labelKey,
|
||||||
|
valueKey,
|
||||||
|
pctKey,
|
||||||
|
color = "bg-emerald-500",
|
||||||
|
maxBars = 10,
|
||||||
|
formatValue,
|
||||||
|
}: {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
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 <p className="text-xs text-muted-foreground italic">Fără date</p>;
|
||||||
|
const maxPct = Math.max(...items.map((d) => Number(d[pctKey] ?? 0)), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((d, i) => {
|
||||||
|
const pct = Number(d[pctKey] ?? 0);
|
||||||
|
const val = Number(d[valueKey] ?? 0);
|
||||||
|
const label = String(d[labelKey] ?? "");
|
||||||
|
return (
|
||||||
|
<div key={`${label}-${i}`} className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground w-[110px] truncate text-right flex-shrink-0">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-5 bg-muted/50 rounded-sm overflow-hidden relative">
|
||||||
|
<Bar
|
||||||
|
pct={(pct / maxPct) * 100}
|
||||||
|
color={color}
|
||||||
|
className="absolute inset-y-0 left-0"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-y-0 left-1.5 flex items-center text-[10px] font-medium tabular-nums mix-blend-difference text-white">
|
||||||
|
{formatValue ? formatValue(val) : val.toLocaleString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] tabular-nums text-muted-foreground w-[32px] text-right flex-shrink-0">
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 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 (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="rounded-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
background: `conic-gradient(${stops.join(", ")})`,
|
||||||
|
mask: `radial-gradient(circle at center, transparent 55%, black 56%)`,
|
||||||
|
WebkitMask: `radial-gradient(circle at center, transparent 55%, black 56%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{segments.map((seg) => (
|
||||||
|
<div
|
||||||
|
key={seg.label}
|
||||||
|
className="flex items-center gap-1.5 text-[11px]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||||
|
style={{ backgroundColor: seg.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{seg.label}</span>
|
||||||
|
<span className="font-medium tabular-nums">{seg.pct}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main dashboard component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function UatDashboard({
|
||||||
|
siruta,
|
||||||
|
uatName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
siruta: string;
|
||||||
|
uatName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState<UatDashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
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 (
|
||||||
|
<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 className="text-sm">
|
||||||
|
Se calculează dashboard-ul pentru {uatName}…
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button variant="ghost" size="sm" className="mt-2" onClick={onClose}>
|
||||||
|
Închide
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
{/* ── Header ─────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-indigo-500" />
|
||||||
|
<h2 className="text-base font-semibold">
|
||||||
|
Dashboard — {data.uatName}
|
||||||
|
</h2>
|
||||||
|
{data.county && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({data.county})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
|
{data.siruta}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI Grid ───────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||||
|
<KpiCard
|
||||||
|
icon={MapPin}
|
||||||
|
label="Terenuri active"
|
||||||
|
value={data.totalTerenuri}
|
||||||
|
sub={`+ ${data.totalCladiri.toLocaleString("ro-RO")} clădiri`}
|
||||||
|
accent="text-emerald-600 dark:text-emerald-400"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Ruler}
|
||||||
|
label="Suprafață totală"
|
||||||
|
value={`${data.totalAreaHa.toLocaleString("ro-RO")} ha`}
|
||||||
|
sub={`Medie: ${data.avgAreaMp.toLocaleString("ro-RO")} mp · Med: ${data.medianAreaMp.toLocaleString("ro-RO")} mp`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Building2}
|
||||||
|
label="Cu clădire"
|
||||||
|
value={`${data.withBuildingPct}%`}
|
||||||
|
sub={`${data.withBuildingCount.toLocaleString("ro-RO")} parcele (${data.buildingLegalCount} legale)`}
|
||||||
|
accent="text-amber-600 dark:text-amber-400"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Users}
|
||||||
|
label="Proprietari unici"
|
||||||
|
value={data.uniqueOwnerCount}
|
||||||
|
sub={
|
||||||
|
data.topOwners[0]
|
||||||
|
? `Top: ${data.topOwners[0].name.slice(0, 25)}`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
accent="text-blue-600 dark:text-blue-400"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Sparkles}
|
||||||
|
label="Date îmbogățite"
|
||||||
|
value={`${data.enrichmentPct}%`}
|
||||||
|
sub={`${data.totalEnriched.toLocaleString("ro-RO")} din ${data.totalTerenuri.toLocaleString("ro-RO")}`}
|
||||||
|
accent="text-teal-600 dark:text-teal-400"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={FileQuestion}
|
||||||
|
label="Fără CF"
|
||||||
|
value={`${data.parcelsWithoutCFPct}%`}
|
||||||
|
sub={`${data.parcelsWithoutCF.toLocaleString("ro-RO")} parcele`}
|
||||||
|
accent={
|
||||||
|
data.parcelsWithoutCFPct > 30
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Row 2: Donut + Area distribution ───────── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{/* Intravilan/Extravilan donut */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||||
|
<TreePine className="h-3.5 w-3.5" />
|
||||||
|
Intravilan / Extravilan
|
||||||
|
</h3>
|
||||||
|
<DonutRing
|
||||||
|
segments={[
|
||||||
|
{
|
||||||
|
pct: data.intravilanPct,
|
||||||
|
color: "hsl(142, 76%, 36%)",
|
||||||
|
label: `Intravilan (${data.intravilanCount.toLocaleString("ro-RO")})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pct:
|
||||||
|
100 -
|
||||||
|
data.intravilanPct -
|
||||||
|
(data.mixtCount > 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")})`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Area distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||||
|
<Ruler className="h-3.5 w-3.5" />
|
||||||
|
Distribuție suprafețe (mp)
|
||||||
|
</h3>
|
||||||
|
<HBarChart
|
||||||
|
data={
|
||||||
|
data.areaDistribution as unknown as Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
labelKey="bucket"
|
||||||
|
valueKey="count"
|
||||||
|
pctKey="pct"
|
||||||
|
color="bg-indigo-500"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Row 3: Land use + Top owners ──────────── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{/* Land use */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggle("landuse")}
|
||||||
|
className="w-full flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<TreePine className="h-3.5 w-3.5" />
|
||||||
|
Categorii folosință
|
||||||
|
</h3>
|
||||||
|
{expanded.landuse ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{(expanded.landuse ?? true) && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<HBarChart
|
||||||
|
data={
|
||||||
|
data.landUseDistribution as unknown as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>[]
|
||||||
|
}
|
||||||
|
labelKey="category"
|
||||||
|
valueKey="count"
|
||||||
|
pctKey="pct"
|
||||||
|
color="bg-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top owners */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggle("owners")}
|
||||||
|
className="w-full flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Top 10 proprietari
|
||||||
|
</h3>
|
||||||
|
{expanded.owners ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{(expanded.owners ?? true) && (
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{data.topOwners.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Fără date proprietari (necesită îmbogățire Magic)
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
data.topOwners.map((o, i) => (
|
||||||
|
<div
|
||||||
|
key={`${o.name}-${i}`}
|
||||||
|
className="flex items-center gap-2 text-[11px]"
|
||||||
|
>
|
||||||
|
<span className="w-5 text-right text-muted-foreground font-mono tabular-nums">
|
||||||
|
{i + 1}.
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{o.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] h-4 tabular-nums"
|
||||||
|
>
|
||||||
|
{o.count} parcele
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Fun facts ────────────────────────────── */}
|
||||||
|
{data.funFacts.length > 0 && (
|
||||||
|
<Card className="border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<h3 className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-2 flex items-center gap-1.5">
|
||||||
|
<Lightbulb className="h-3.5 w-3.5" />
|
||||||
|
Observații
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{data.funFacts.map((fact, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="text-xs text-amber-800/80 dark:text-amber-300/80 flex items-start gap-1.5"
|
||||||
|
>
|
||||||
|
<span className="text-amber-500 mt-0.5">•</span>
|
||||||
|
{fact}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Footer meta ──────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-muted-foreground px-1">
|
||||||
|
<span>
|
||||||
|
Ultimul sync:{" "}
|
||||||
|
{data.lastSyncDate
|
||||||
|
? new Date(data.lastSyncDate).toLocaleString("ro-RO")
|
||||||
|
: "—"}
|
||||||
|
{data.syncRunCount > 0 && ` · ${data.syncRunCount} sync-uri totale`}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{data.totalNoGeom > 0 &&
|
||||||
|
`${data.totalNoGeom.toLocaleString("ro-RO")} fără geometrie · `}
|
||||||
|
Date la {new Date().toLocaleDateString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user