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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user