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,
|
||||
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<ParcelDetail[]>([]);
|
||||
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||||
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<string | null>(null);
|
||||
|
||||
/* ── No-geometry import option ──────────────────────────────── */
|
||||
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
||||
@@ -1791,13 +1797,18 @@ export function ParcelSyncModule() {
|
||||
</div>
|
||||
{!session.connected && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void handleSearch()}
|
||||
disabled={loadingFeatures || !featuresSearch.trim() || !session.connected}
|
||||
disabled={
|
||||
loadingFeatures ||
|
||||
!featuresSearch.trim() ||
|
||||
!session.connected
|
||||
}
|
||||
>
|
||||
{loadingFeatures ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -1858,270 +1869,275 @@ export function ParcelSyncModule() {
|
||||
<>
|
||||
{/* Results */}
|
||||
{loadingFeatures && searchResults.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
||||
<p>Se caută în eTerra...</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Prima căutare pe un UAT nou poate dura ~10-30s (se încarcă
|
||||
lista de județe).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
||||
<p>Se caută în eTerra...</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Prima căutare pe un UAT nou poate dura ~10-30s (se
|
||||
încarcă lista de județe).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{searchResults.length} rezultat
|
||||
{searchResults.length > 1 ? "e" : ""}
|
||||
{searchList.length > 0 && (
|
||||
<span className="ml-2">
|
||||
· <strong>{searchList.length}</strong> în listă
|
||||
{searchResults.length > 0 && (
|
||||
<>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{searchResults.length} rezultat
|
||||
{searchResults.length > 1 ? "e" : ""}
|
||||
{searchList.length > 0 && (
|
||||
<span className="ml-2">
|
||||
· <strong>{searchList.length}</strong> în listă
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{searchResults.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
for (const r of searchResults) addToList(r);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Adaugă toate în listă
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={downloadCSV}
|
||||
disabled={
|
||||
searchResults.length === 0 && searchList.length === 0
|
||||
}
|
||||
>
|
||||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||
Descarcă CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{searchResults.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
for (const r of searchResults) addToList(r);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Adaugă toate în listă
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={downloadCSV}
|
||||
disabled={
|
||||
searchResults.length === 0 &&
|
||||
searchList.length === 0
|
||||
}
|
||||
>
|
||||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||
Descarcă CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail cards */}
|
||||
<div className="space-y-3">
|
||||
{searchResults.map((p, idx) => (
|
||||
<Card
|
||||
key={`${p.nrCad}-${p.immovablePk}-${idx}`}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!p.immovablePk && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tabular-nums">
|
||||
Nr. Cad. {p.nrCad}
|
||||
</h3>
|
||||
{!p.immovablePk && (
|
||||
<p className="text-xs text-destructive">
|
||||
Parcela nu a fost găsită în eTerra.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
title="Adaugă în listă"
|
||||
onClick={() => addToList(p)}
|
||||
disabled={!p.immovablePk}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
title="Copiază detalii"
|
||||
onClick={() => {
|
||||
const text = [
|
||||
`Nr. Cad: ${p.nrCad}`,
|
||||
`Nr. CF: ${p.nrCF || "—"}`,
|
||||
p.nrCFVechi
|
||||
? `CF vechi: ${p.nrCFVechi}`
|
||||
: null,
|
||||
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
|
||||
p.suprafata != null
|
||||
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
|
||||
: null,
|
||||
`Intravilan: ${p.intravilan || "—"}`,
|
||||
p.categorieFolosinta
|
||||
? `Categorie: ${p.categorieFolosinta}`
|
||||
: null,
|
||||
p.adresa ? `Adresă: ${p.adresa}` : null,
|
||||
p.proprietariActuali
|
||||
? `Proprietari actuali: ${p.proprietariActuali}`
|
||||
: null,
|
||||
p.proprietariVechi
|
||||
? `Proprietari vechi: ${p.proprietariVechi}`
|
||||
: null,
|
||||
!p.proprietariActuali &&
|
||||
!p.proprietariVechi &&
|
||||
p.proprietari
|
||||
? `Proprietari: ${p.proprietari}`
|
||||
: null,
|
||||
p.solicitant
|
||||
? `Solicitant: ${p.solicitant}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.immovablePk && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Nr. CF
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{p.nrCF || "—"}
|
||||
</span>
|
||||
</div>
|
||||
{p.nrCFVechi && (
|
||||
{/* Detail cards */}
|
||||
<div className="space-y-3">
|
||||
{searchResults.map((p, idx) => (
|
||||
<Card
|
||||
key={`${p.nrCad}-${p.immovablePk}-${idx}`}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!p.immovablePk && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
CF vechi
|
||||
</span>
|
||||
<span>{p.nrCFVechi}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Nr. Topo
|
||||
</span>
|
||||
<span>{p.nrTopo || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Suprafață
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{p.suprafata != null
|
||||
? formatArea(p.suprafata)
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Intravilan
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
p.intravilan === "Da"
|
||||
? "default"
|
||||
: p.intravilan === "Nu"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{p.intravilan || "—"}
|
||||
</Badge>
|
||||
</div>
|
||||
{p.categorieFolosinta && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Categorii folosință
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{p.categorieFolosinta}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{p.adresa && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Adresă
|
||||
</span>
|
||||
<span>{p.adresa}</span>
|
||||
</div>
|
||||
)}
|
||||
{(p.proprietariActuali || p.proprietariVechi) && (
|
||||
<div className="col-span-2 lg:col-span-4">
|
||||
{p.proprietariActuali && (
|
||||
<div className="mb-1">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Proprietari actuali
|
||||
</span>
|
||||
<span className="font-medium text-sm">
|
||||
{p.proprietariActuali}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold tabular-nums">
|
||||
Nr. Cad. {p.nrCad}
|
||||
</h3>
|
||||
{!p.immovablePk && (
|
||||
<p className="text-xs text-destructive">
|
||||
Parcela nu a fost găsită în eTerra.
|
||||
</p>
|
||||
)}
|
||||
{p.proprietariVechi && (
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
title="Adaugă în listă"
|
||||
onClick={() => addToList(p)}
|
||||
disabled={!p.immovablePk}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
title="Copiază detalii"
|
||||
onClick={() => {
|
||||
const text = [
|
||||
`Nr. Cad: ${p.nrCad}`,
|
||||
`Nr. CF: ${p.nrCF || "—"}`,
|
||||
p.nrCFVechi
|
||||
? `CF vechi: ${p.nrCFVechi}`
|
||||
: null,
|
||||
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
|
||||
p.suprafata != null
|
||||
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
|
||||
: null,
|
||||
`Intravilan: ${p.intravilan || "—"}`,
|
||||
p.categorieFolosinta
|
||||
? `Categorie: ${p.categorieFolosinta}`
|
||||
: null,
|
||||
p.adresa ? `Adresă: ${p.adresa}` : null,
|
||||
p.proprietariActuali
|
||||
? `Proprietari actuali: ${p.proprietariActuali}`
|
||||
: null,
|
||||
p.proprietariVechi
|
||||
? `Proprietari vechi: ${p.proprietariVechi}`
|
||||
: null,
|
||||
!p.proprietariActuali &&
|
||||
!p.proprietariVechi &&
|
||||
p.proprietari
|
||||
? `Proprietari: ${p.proprietari}`
|
||||
: null,
|
||||
p.solicitant
|
||||
? `Solicitant: ${p.solicitant}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.immovablePk && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Nr. CF
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{p.nrCF || "—"}
|
||||
</span>
|
||||
</div>
|
||||
{p.nrCFVechi && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Proprietari anteriori
|
||||
CF vechi
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/80">
|
||||
{p.proprietariVechi}
|
||||
<span>{p.nrCFVechi}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Nr. Topo
|
||||
</span>
|
||||
<span>{p.nrTopo || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Suprafață
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{p.suprafata != null
|
||||
? formatArea(p.suprafata)
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Intravilan
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
p.intravilan === "Da"
|
||||
? "default"
|
||||
: p.intravilan === "Nu"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{p.intravilan || "—"}
|
||||
</Badge>
|
||||
</div>
|
||||
{p.categorieFolosinta && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Categorii folosință
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{p.categorieFolosinta}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!p.proprietariActuali &&
|
||||
!p.proprietariVechi &&
|
||||
p.proprietari && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Proprietari
|
||||
</span>
|
||||
<span>{p.proprietari}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.adresa && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Adresă
|
||||
</span>
|
||||
<span>{p.adresa}</span>
|
||||
</div>
|
||||
)}
|
||||
{(p.proprietariActuali ||
|
||||
p.proprietariVechi) && (
|
||||
<div className="col-span-2 lg:col-span-4">
|
||||
{p.proprietariActuali && (
|
||||
<div className="mb-1">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Proprietari actuali
|
||||
</span>
|
||||
<span className="font-medium text-sm">
|
||||
{p.proprietariActuali}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{p.proprietariVechi && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Proprietari anteriori
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/80">
|
||||
{p.proprietariVechi}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!p.proprietariActuali &&
|
||||
!p.proprietariVechi &&
|
||||
p.proprietari && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Proprietari
|
||||
</span>
|
||||
<span>{p.proprietari}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{p.solicitant && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Solicitant
|
||||
</span>
|
||||
<span>{p.solicitant}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{p.solicitant && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
Solicitant
|
||||
</span>
|
||||
<span>{p.solicitant}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state when no search has been done */}
|
||||
{searchMode === "cadastral" &&
|
||||
searchResults.length === 0 &&
|
||||
!loadingFeatures &&
|
||||
!searchError && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Introdu un număr cadastral și apasă Caută.</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Poți căuta mai multe parcele simultan, separate prin
|
||||
virgulă.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state when no search has been done */}
|
||||
{searchMode === "cadastral" && searchResults.length === 0 && !loadingFeatures && !searchError && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Introdu un număr cadastral și apasă Caută.</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Poți căuta mai multe parcele simultan, separate prin
|
||||
virgulă.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2166,8 +2182,7 @@ export function ParcelSyncModule() {
|
||||
variant="default"
|
||||
onClick={downloadCSV}
|
||||
disabled={
|
||||
ownerResults.length === 0 &&
|
||||
searchList.length === 0
|
||||
ownerResults.length === 0 && searchList.length === 0
|
||||
}
|
||||
>
|
||||
<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">
|
||||
Caută în datele îmbogățite (DB local) și pe eTerra.
|
||||
<br />
|
||||
Pentru rezultate complete, lansează "Sync fundal — Magic" în tab-ul Export.
|
||||
Pentru rezultate complete, lansează "Sync fundal —
|
||||
Magic" în tab-ul Export.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -3885,120 +3901,149 @@ export function ParcelSyncModule() {
|
||||
const isCurrentUat = sirutaValid && uat.siruta === siruta;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={uat.siruta}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
isCurrentUat && "ring-1 ring-emerald-400/50",
|
||||
)}
|
||||
>
|
||||
<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})
|
||||
<div key={uat.siruta} className="space-y-3">
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
isCurrentUat && "ring-1 ring-emerald-400/50",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<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"
|
||||
{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"
|
||||
>
|
||||
<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")}
|
||||
selectat
|
||||
</Badge>
|
||||
)}
|
||||
<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) : "—"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{noGeomTotal > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
Fără geom:
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{noGeomTotal.toLocaleString("ro-RO")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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;
|
||||
{/* 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={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")}` : ""}`}
|
||||
key={cat}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{label}
|
||||
<span className="text-muted-foreground">
|
||||
{label}:
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{layer.count.toLocaleString("ro-RO")}
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
{noGeomTotal > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
Fără geom:
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{noGeomTotal.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>
|
||||
|
||||
{/* 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