ParcelSync: PROPRIETARI_VECHI in enrichment + global DB summary tab (all UATs without login)

This commit is contained in:
AI Assistant
2026-03-07 12:16:34 +02:00
parent abd00aecfb
commit d50b9ea0e2
4 changed files with 382 additions and 188 deletions
+145
View File
@@ -0,0 +1,145 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/db-summary
*
* Returns a summary of ALL data in the GIS database, grouped by UAT.
* No siruta required — shows everything across all UATs.
*
* Response shape:
* {
* uats: [{
* siruta, uatName,
* layers: [{ layerId, count, enrichedCount, lastSynced }],
* totalFeatures, totalEnriched
* }],
* totalFeatures, totalUats
* }
*/
export async function GET() {
try {
// Feature counts per siruta + layerId
const featureCounts = await prisma.gisFeature.groupBy({
by: ["siruta", "layerId"],
_count: { id: true },
});
// Enriched counts per siruta + layerId
const enrichedCounts = await prisma.gisFeature.groupBy({
by: ["siruta", "layerId"],
where: { enrichedAt: { not: null } },
_count: { id: true },
});
const enrichedMap = new Map<string, number>();
for (const e of enrichedCounts) {
enrichedMap.set(`${e.siruta}:${e.layerId}`, e._count.id);
}
// Latest sync run per siruta + layerId
const latestRuns = await prisma.gisSyncRun.findMany({
where: { status: "done" },
orderBy: { completedAt: "desc" },
select: {
siruta: true,
uatName: true,
layerId: true,
completedAt: true,
},
});
const latestRunMap = new Map<
string,
{ completedAt: Date | null; uatName: string | null }
>();
for (const r of latestRuns) {
const key = `${r.siruta}:${r.layerId}`;
if (!latestRunMap.has(key)) {
latestRunMap.set(key, {
completedAt: r.completedAt,
uatName: r.uatName,
});
}
}
// UAT names from GisUat table
const uatNames = await prisma.gisUat.findMany({
select: { siruta: true, name: true, county: true },
});
const uatNameMap = new Map<
string,
{ name: string; county: string | null }
>();
for (const u of uatNames) {
uatNameMap.set(u.siruta, { name: u.name, county: u.county });
}
// Group by siruta
const uatMap = new Map<
string,
{
siruta: string;
uatName: string;
county: string | null;
layers: {
layerId: string;
count: number;
enrichedCount: number;
lastSynced: string | null;
}[];
totalFeatures: number;
totalEnriched: number;
}
>();
for (const fc of featureCounts) {
if (!uatMap.has(fc.siruta)) {
const uatInfo = uatNameMap.get(fc.siruta);
const runInfo = latestRunMap.get(`${fc.siruta}:${fc.layerId}`);
uatMap.set(fc.siruta, {
siruta: fc.siruta,
uatName: uatInfo?.name ?? runInfo?.uatName ?? `UAT ${fc.siruta}`,
county: uatInfo?.county ?? null,
layers: [],
totalFeatures: 0,
totalEnriched: 0,
});
}
const uat = uatMap.get(fc.siruta)!;
const enriched = enrichedMap.get(`${fc.siruta}:${fc.layerId}`) ?? 0;
const runInfo = latestRunMap.get(`${fc.siruta}:${fc.layerId}`);
uat.layers.push({
layerId: fc.layerId,
count: fc._count.id,
enrichedCount: enriched,
lastSynced: runInfo?.completedAt?.toISOString() ?? null,
});
uat.totalFeatures += fc._count.id;
uat.totalEnriched += enriched;
// Update UAT name if we got one from sync runs
if (uat.uatName.startsWith("UAT ") && runInfo?.uatName) {
uat.uatName = runInfo.uatName;
}
}
const uats = Array.from(uatMap.values()).sort(
(a, b) => b.totalFeatures - a.totalFeatures,
);
const totalFeatures = uats.reduce((s, u) => s + u.totalFeatures, 0);
return NextResponse.json({
uats,
totalFeatures,
totalUats: uats.length,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -370,6 +370,7 @@ export async function POST(req: Request) {
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
@@ -390,6 +391,7 @@ export async function POST(req: Request) {
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
@@ -423,6 +425,7 @@ export async function POST(req: Request) {
e.NR_TOPO ?? "",
e.ADRESA ?? "",
e.PROPRIETARI ?? "",
e.PROPRIETARI_VECHI ?? "",
e.SUPRAFATA_2D ?? "",
e.SUPRAFATA_R ?? "",
e.SOLICITANT ?? "",
@@ -47,6 +47,7 @@ import { cn } from "@/shared/lib/utils";
import {
LAYER_CATALOG,
LAYER_CATEGORY_LABELS,
findLayerById,
type LayerCategory,
type LayerCatalogItem,
} from "../services/eterra-layers";
@@ -341,6 +342,28 @@ export function ParcelSyncModule() {
const [exportingLocal, setExportingLocal] = useState(false);
const refreshSyncRef = useRef<(() => void) | null>(null);
/* ── Global DB summary (all UATs) ────────────────────────────── */
type DbUatSummary = {
siruta: string;
uatName: string;
county: string | null;
layers: {
layerId: string;
count: number;
enrichedCount: number;
lastSynced: string | null;
}[];
totalFeatures: number;
totalEnriched: number;
};
type DbSummary = {
uats: DbUatSummary[];
totalFeatures: number;
totalUats: number;
};
const [dbSummary, setDbSummary] = useState<DbSummary | null>(null);
const [dbSummaryLoading, setDbSummaryLoading] = useState(false);
/* ── PostGIS setup ───────────────────────────────────────────── */
const [postgisRunning, setPostgisRunning] = useState(false);
const [postgisResult, setPostgisResult] = useState<{
@@ -407,6 +430,23 @@ export function ParcelSyncModule() {
};
}, [fetchSession]);
/* ── Fetch global DB summary ─────────────────────────────────── */
const fetchDbSummary = useCallback(async () => {
setDbSummaryLoading(true);
try {
const res = await fetch("/api/eterra/db-summary");
const data = (await res.json()) as DbSummary;
if (data.uats) setDbSummary(data);
} catch {
// silent
}
setDbSummaryLoading(false);
}, []);
useEffect(() => {
void fetchDbSummary();
}, [fetchDbSummary]);
/* ════════════════════════════════════════════════════════════ */
/* (Sync effect removed — POST seeds from uat.json, no */
/* eTerra nomenclature needed. Workspace resolved lazily.) */
@@ -2351,15 +2391,15 @@ export function ParcelSyncModule() {
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 4: Baza de Date */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="database" className="space-y-4">
{!sirutaValid ? (
<TabsContent value="database" className="space-y-3">
{dbSummaryLoading && !dbSummary ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Selectează un UAT pentru a vedea datele locale.</p>
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
<p>Se încarcă datele din baza de date</p>
</CardContent>
</Card>
) : dbLayersSummary.length === 0 ? (
) : !dbSummary || dbSummary.totalFeatures === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Database className="h-10 w-10 mx-auto mb-3 opacity-30" />
@@ -2371,187 +2411,155 @@ export function ParcelSyncModule() {
</Card>
) : (
<>
{/* Summary + Sync All */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{dbTotalFeatures.toLocaleString("ro-RO")} entități din{" "}
{dbLayersSummary.length} layere
</p>
<p className="text-xs text-muted-foreground">
{dbLayersSummary.filter((l) => l.isFresh).length} proaspete,{" "}
{dbLayersSummary.filter((l) => !l.isFresh).length} vechi
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={
!!syncingLayer || exportingLocal || syncQueue.length > 0
}
onClick={() =>
void handleExportLocal(dbLayersSummary.map((l) => l.id))
}
>
{exportingLocal ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<ArrowDownToLine className="h-3.5 w-3.5 mr-1.5" />
)}
Export GPKG
</Button>
<Button
size="sm"
disabled={
!!syncingLayer || syncQueue.length > 0 || !session.connected
}
onClick={() =>
void handleSyncMultiple(dbLayersSummary.map((l) => l.id))
}
>
{syncQueue.length > 0 ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
)}
Sync tot
</Button>
</div>
</div>
{/* Sync progress banner */}
{(syncingLayer || syncQueue.length > 0) && syncProgress && (
<Card className="border-emerald-200 dark:border-emerald-800">
<CardContent className="py-2.5 px-4">
<div className="flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin text-emerald-600 shrink-0" />
<span>{syncProgress}</span>
{syncQueue.length > 0 && (
<Badge variant="secondary" className="ml-auto text-xs">
{syncQueue.length} rămase
</Badge>
)}
</div>
</CardContent>
</Card>
)}
{/* Layers grouped by category */}
{(
Object.entries(LAYER_CATEGORY_LABELS) as [LayerCategory, string][]
).map(([cat, catLabel]) => {
const catLayers = dbLayersSummary.filter(
(l) => l.category === cat,
);
if (catLayers.length === 0) return null;
return (
<div key={cat} className="space-y-2">
{/* Category header */}
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{catLabel}
<span className="ml-2 font-normal">
(
{catLayers
.reduce((s, l) => s + l.count, 0)
.toLocaleString("ro-RO")}
)
{/* Header row */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{dbSummary.totalFeatures.toLocaleString("ro-RO")} entități
</span>
</h3>
{catLayers.length > 1 && (
<span className="text-xs text-muted-foreground">
din {dbSummary.totalUats} UAT-uri
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={
!!syncingLayer ||
syncQueue.length > 0 ||
!session.connected
}
onClick={() =>
void handleSyncMultiple(catLayers.map((l) => l.id))
}
disabled={dbSummaryLoading}
onClick={() => void fetchDbSummary()}
>
{dbSummaryLoading ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
Sync categorie
</Button>
)}
Reîncarcă
</Button>
</div>
{/* Layer rows */}
<Card>
<CardContent className="p-0 divide-y">
{catLayers.map((layer) => (
<div
key={layer.id}
className="flex items-center gap-3 px-4 py-2.5"
>
{/* Layer info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{layer.label}
</span>
<Badge
variant="secondary"
className="text-[10px] h-5 shrink-0"
>
{layer.count.toLocaleString("ro-RO")}
</Badge>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{relativeTime(layer.lastSynced)}
</span>
{layer.isFresh ? (
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
) : (
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-amber-500" />
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title="Export GPKG din DB"
disabled={exportingLocal}
onClick={() => void handleExportLocal([layer.id])}
>
<ArrowDownToLine className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title="Re-sincronizare din eTerra"
disabled={
!!syncingLayer ||
syncQueue.length > 0 ||
!session.connected
{/* UAT cards */}
{dbSummary.uats.map((uat) => {
const catCounts: Record<string, number> = {};
let enrichedTotal = 0;
let oldestSync: Date | null = null;
for (const layer of uat.layers) {
const cat =
findLayerById(layer.layerId)?.category ?? "administrativ";
catCounts[cat] = (catCounts[cat] ?? 0) + layer.count;
enrichedTotal += layer.enrichedCount;
if (layer.lastSynced) {
const d = new Date(layer.lastSynced);
if (!oldestSync || d < oldestSync) oldestSync = d;
}
onClick={() => void handleSyncLayer(layer.id)}
>
{syncingLayer === layer.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
}
const isCurrentUat = sirutaValid && uat.siruta === siruta;
return (
<Card
key={uat.siruta}
className={cn(
"transition-colors",
isCurrentUat && "ring-1 ring-emerald-400/50",
)}
</Button>
>
<CardContent className="py-3 px-4 space-y-2">
{/* UAT header row */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">
{uat.uatName}
</span>
{uat.county && (
<span className="text-[10px] text-muted-foreground">
({uat.county})
</span>
)}
<span className="text-[10px] font-mono text-muted-foreground">
#{uat.siruta}
</span>
{isCurrentUat && (
<Badge
variant="outline"
className="text-[9px] h-4 text-emerald-600 border-emerald-300 dark:text-emerald-400 dark:border-emerald-700"
>
selectat
</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{oldestSync ? relativeTime(oldestSync) : "—"}
</span>
</div>
{/* Category counts in a single compact row */}
<div className="flex items-center gap-2 flex-wrap text-xs">
{(
Object.entries(LAYER_CATEGORY_LABELS) as [
LayerCategory,
string,
][]
).map(([cat, label]) => {
const count = catCounts[cat] ?? 0;
if (count === 0) return null;
return (
<span
key={cat}
className="inline-flex items-center gap-1"
>
<span className="text-muted-foreground">
{label}:
</span>
<span className="font-medium tabular-nums">
{count.toLocaleString("ro-RO")}
</span>
</span>
);
})}
{enrichedTotal > 0 && (
<span className="inline-flex items-center gap-1">
<Sparkles className="h-3 w-3 text-teal-600 dark:text-teal-400" />
<span className="text-muted-foreground">Magic:</span>
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">
{enrichedTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
</div>
{/* Layer detail pills */}
<div className="flex gap-1.5 flex-wrap">
{uat.layers
.sort((a, b) => b.count - a.count)
.map((layer) => {
const meta = findLayerById(layer.layerId);
const label =
meta?.label ?? layer.layerId.replace(/_/g, " ");
const isEnriched = layer.enrichedCount > 0;
return (
<span
key={layer.layerId}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px]",
isEnriched
? "border-teal-200 bg-teal-50/50 dark:border-teal-800 dark:bg-teal-950/30"
: "border-muted bg-muted/30",
)}
title={`${label}: ${layer.count} entități${isEnriched ? `, ${layer.enrichedCount} îmbogățite` : ""}${layer.lastSynced ? `, sync: ${new Date(layer.lastSynced).toLocaleDateString("ro-RO")}` : ""}`}
>
<span className="truncate max-w-[120px]">
{label}
</span>
<span className="font-medium tabular-nums">
{layer.count.toLocaleString("ro-RO")}
</span>
{isEnriched && (
<Sparkles className="h-2.5 w-2.5 text-teal-600 dark:text-teal-400" />
)}
</span>
);
})}
</div>
))}
</CardContent>
</Card>
</div>
);
})}
</>
@@ -125,6 +125,7 @@ export type FeatureEnrichment = {
NR_TOPO: string;
ADRESA: string;
PROPRIETARI: string;
PROPRIETARI_VECHI: string;
SUPRAFATA_2D: number | string;
SUPRAFATA_R: number | string;
SOLICITANT: string;
@@ -244,11 +245,14 @@ export async function enrichFeatures(
const immovableListByCad = new Map<string, any>();
const ownersByLandbook = new Map<string, Set<string>>();
const addOwner = (landbook: string, name: string) => {
const cancelledOwnersByLandbook = new Map<string, Set<string>>();
const addOwner = (landbook: string, name: string, radiated = false) => {
if (!landbook || !name) return;
const existing = ownersByLandbook.get(landbook) ?? new Set<string>();
const targetMap = radiated ? cancelledOwnersByLandbook : ownersByLandbook;
const existing = targetMap.get(landbook) ?? new Set<string>();
existing.add(name);
ownersByLandbook.set(landbook, existing);
targetMap.set(landbook, existing);
};
let listPage = 0;
@@ -301,15 +305,32 @@ export async function enrichFeatures(
const idKey = normalizeId(item?.immovablePk);
if (idKey) docByImmovable.set(idKey, item);
});
(docResponse?.partTwoRegs ?? []).forEach((item: any) => {
if (
String(item?.nodeType ?? "").toUpperCase() === "P" &&
item?.landbookIE
) {
const name = String(item?.nodeName ?? "").trim();
if (name) addOwner(String(item.landbookIE), name);
// Build nodeId → entry map for radiated detection
const regs: any[] = docResponse?.partTwoRegs ?? [];
const nodeMap = new Map<number, any>();
for (const reg of regs) {
if (reg?.nodeId != null) nodeMap.set(Number(reg.nodeId), reg);
}
// Check if an entry or any ancestor "I" inscription is radiated
const isRadiated = (entry: any, depth = 0): boolean => {
if (!entry || depth > 10) return false;
if (entry?.nodeStatus === -1) return true;
const pid = entry?.parentId;
if (pid != null) {
const parent = nodeMap.get(Number(pid));
if (parent) return isRadiated(parent, depth + 1);
}
return false;
};
for (const reg of regs) {
if (
String(reg?.nodeType ?? "").toUpperCase() !== "P" ||
!reg?.landbookIE
)
continue;
const name = String(reg?.nodeName ?? "").trim();
if (name) addOwner(String(reg.landbookIE), name, isRadiated(reg));
}
});
}
// ── Enrich each teren feature ──
@@ -401,6 +422,22 @@ export async function enrichFeatures(
Array.from(new Set([...owners, ...ownersByCad])).join("; ") ||
proprietari;
// Cancelled/old owners
const cancelledOwners =
landbookIE && cancelledOwnersByLandbook.get(String(landbookIE))
? Array.from(cancelledOwnersByLandbook.get(String(landbookIE)) ?? [])
: [];
const cancelledByCad =
cadRefRaw && cancelledOwnersByLandbook.get(String(cadRefRaw))
? Array.from(cancelledOwnersByLandbook.get(String(cadRefRaw)) ?? [])
: [];
const activeSet = new Set([...owners, ...ownersByCad]);
const proprietariVechi = Array.from(
new Set([...cancelledOwners, ...cancelledByCad]),
)
.filter((n) => !activeSet.has(n))
.join("; ");
nrCF =
docItem?.landbookIE ||
listItem?.paperLbNo ||
@@ -435,6 +472,7 @@ export async function enrichFeatures(
NR_TOPO: nrTopo,
ADRESA: addressText,
PROPRIETARI: proprietari,
PROPRIETARI_VECHI: proprietariVechi,
SUPRAFATA_2D: areaValue !== null ? Number(areaValue.toFixed(2)) : "",
SUPRAFATA_R: areaValue !== null ? Math.round(areaValue) : "",
SOLICITANT: solicitant,