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:
AI Assistant
2026-03-08 10:18:34 +02:00
parent 6558c690f5
commit 6557cd5374
3 changed files with 1373 additions and 355 deletions
+418
View File
@@ -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 });
}
}