fix(parcel-sync): quote all CSV fields + layer feature counts + drumul de azi

- CSV export: all fields properly quoted to prevent column misalignment
  when values contain commas (e.g. nrTopo with multiple topo numbers)
- Layer catalog: 'Numara toate' button fetches feature count per layer
  via /api/eterra/layers/summary (now supports session auth)
- Feature counts displayed as badges on each layer and category total
- 'Drumul de azi' section: persists today's layer counts in localStorage
  grouped by SIRUTA with timestamps
This commit is contained in:
AI Assistant
2026-03-06 23:19:58 +02:00
parent 0b049274b1
commit f73e639e4f
3 changed files with 233 additions and 26 deletions
+7 -8
View File
@@ -5,6 +5,7 @@ import {
} from "@/modules/parcel-sync/services/eterra-client";
import { LAYER_CATALOG } from "@/modules/parcel-sync/services/eterra-layers";
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -18,19 +19,17 @@ type Body = {
/**
* POST — Count features per layer on the remote eTerra server.
* Supports session-based auth (falls back to env vars).
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
const session = getSessionCredentials();
const username = String(
body.username || session?.username || process.env.ETERRA_USERNAME || "",
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
const password = String(
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
).trim();
const siruta = String(body.siruta ?? "").trim();
+5 -2
View File
@@ -131,7 +131,8 @@ function formatAddress(item?: any) {
if (houseNo) parts.push(`Nr. ${houseNo}`);
// Building details
if (address.buildingSectionNo) parts.push(`Bl. ${address.buildingSectionNo}`);
if (address.buildingSectionNo)
parts.push(`Bl. ${address.buildingSectionNo}`);
if (address.buildingEntryNo) parts.push(`Sc. ${address.buildingEntryNo}`);
if (address.buildingFloorNo) parts.push(`Et. ${address.buildingFloorNo}`);
if (address.buildingUnitNo) parts.push(`Ap. ${address.buildingUnitNo}`);
@@ -166,7 +167,9 @@ function formatAddress(item?: any) {
// If we still have nothing, try addressDescription from first entry
if (formatted.length === 0) {
const desc = addresses[0]?.address?.addressDescription ?? addresses[0]?.addressDescription;
const desc =
addresses[0]?.address?.addressDescription ??
addresses[0]?.addressDescription;
if (desc) {
const s = String(desc).trim();
if (s.length > 2 && !s.includes("[object")) return s;
@@ -299,6 +299,14 @@ export function ParcelSyncModule() {
Record<string, boolean>
>({});
const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null);
const [layerCounts, setLayerCounts] = useState<
Record<string, { count: number; error?: string }>
>({});
const [countingLayers, setCountingLayers] = useState(false);
const [layerCountSiruta, setLayerCountSiruta] = useState(""); // siruta for which counts were fetched
const [layerHistory, setLayerHistory] = useState<
{ layerId: string; label: string; count: number; time: string; siruta: string }[]
>([]);
/* ── Parcel search tab ──────────────────────────────────────── */
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
@@ -554,6 +562,84 @@ export function ParcelSyncModule() {
[siruta, exporting, startPolling],
);
/* ════════════════════════════════════════════════════════════ */
/* Layer feature counts */
/* ════════════════════════════════════════════════════════════ */
// Load history from localStorage on mount
useEffect(() => {
try {
const raw = localStorage.getItem("parcel-sync:layer-history");
if (raw) {
const parsed = JSON.parse(raw) as typeof layerHistory;
// Only keep today's entries
const today = new Date().toISOString().slice(0, 10);
const todayEntries = parsed.filter(
(e) => e.time.slice(0, 10) === today,
);
setLayerHistory(todayEntries);
}
} catch {
// ignore
}
}, []);
const fetchLayerCounts = useCallback(async () => {
if (!siruta || countingLayers) return;
setCountingLayers(true);
try {
const res = await fetch("/api/eterra/layers/summary", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siruta }),
});
const data = (await res.json()) as {
counts?: Record<string, { count: number; error?: string }>;
error?: string;
};
if (data.counts) {
setLayerCounts(data.counts);
setLayerCountSiruta(siruta);
// Save non-zero counts to history
const now = new Date().toISOString();
const today = now.slice(0, 10);
const newEntries: typeof layerHistory = [];
for (const [layerId, info] of Object.entries(data.counts)) {
if (info.count > 0) {
const layer = LAYER_CATALOG.find((l) => l.id === layerId);
newEntries.push({
layerId,
label: layer?.label ?? layerId,
count: info.count,
time: now,
siruta,
});
}
}
setLayerHistory((prev) => {
// Keep today's entries only, add new batch
const kept = prev.filter(
(e) => e.time.slice(0, 10) === today && e.siruta !== siruta,
);
const merged = [...kept, ...newEntries];
try {
localStorage.setItem(
"parcel-sync:layer-history",
JSON.stringify(merged),
);
} catch {
// quota
}
return merged;
});
}
} catch {
// silent
}
setCountingLayers(false);
}, [siruta, countingLayers]);
/* ════════════════════════════════════════════════════════════ */
/* Export individual layer */
/* ════════════════════════════════════════════════════════════ */
@@ -692,7 +778,12 @@ export function ParcelSyncModule() {
setSearchList((prev) => prev.filter((p) => p.nrCad !== nrCad));
}, []);
// CSV export
// CSV export — all fields quoted to handle commas in values (e.g. nrTopo)
const csvEscape = useCallback((val: string | number | null | undefined) => {
const s = val != null ? String(val) : "";
return `"${s.replace(/"/g, '""')}"`;
}, []);
const downloadCSV = useCallback(() => {
const items = searchList.length > 0 ? searchList : searchResults;
if (items.length === 0) return;
@@ -710,17 +801,17 @@ export function ParcelSyncModule() {
"SOLICITANT",
];
const rows = items.map((p) => [
p.nrCad,
p.nrCF,
p.nrCFVechi,
p.nrTopo,
p.suprafata != null ? String(p.suprafata) : "",
p.intravilan,
`"${(p.categorieFolosinta ?? "").replace(/"/g, '""')}"`,
`"${(p.adresa ?? "").replace(/"/g, '""')}"`,
`"${(p.proprietariActuali ?? p.proprietari ?? "").replace(/"/g, '""')}"`,
`"${(p.proprietariVechi ?? "").replace(/"/g, '""')}"`,
`"${(p.solicitant ?? "").replace(/"/g, '""')}"`,
csvEscape(p.nrCad),
csvEscape(p.nrCF),
csvEscape(p.nrCFVechi),
csvEscape(p.nrTopo),
csvEscape(p.suprafata),
csvEscape(p.intravilan),
csvEscape(p.categorieFolosinta),
csvEscape(p.adresa),
csvEscape(p.proprietariActuali ?? p.proprietari),
csvEscape(p.proprietariVechi),
csvEscape(p.solicitant),
]);
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
@@ -730,7 +821,7 @@ export function ParcelSyncModule() {
a.download = `parcele_${siruta}_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [searchList, searchResults, siruta]);
}, [searchList, searchResults, siruta, csvEscape]);
/* ════════════════════════════════════════════════════════════ */
/* Derived data */
@@ -1288,12 +1379,43 @@ export function ParcelSyncModule() {
</Card>
) : (
<div className="space-y-3">
{/* Count all button */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{layerCountSiruta === siruta && Object.keys(layerCounts).length > 0
? `Număr features pentru SIRUTA ${siruta}`
: "Apasă pentru a număra features-urile din fiecare layer."}
</p>
<Button
size="sm"
variant="outline"
disabled={countingLayers}
onClick={() => void fetchLayerCounts()}
>
{countingLayers ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<Search className="h-3.5 w-3.5 mr-1.5" />
)}
{countingLayers ? "Se numără…" : "Numără toate"}
</Button>
</div>
{(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map(
(cat) => {
const layers = layersByCategory[cat];
if (!layers?.length) return null;
const isExpanded = expandedCategories[cat] ?? false;
// Sum counts for category badge
const catTotal =
layerCountSiruta === siruta
? layers.reduce(
(sum, l) => sum + (layerCounts[l.id]?.count ?? 0),
0,
)
: null;
return (
<Card key={cat}>
<button
@@ -1316,6 +1438,11 @@ export function ParcelSyncModule() {
>
{layers.length}
</Badge>
{catTotal != null && catTotal > 0 && (
<Badge className="font-mono text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-0">
{catTotal.toLocaleString("ro-RO")} feat.
</Badge>
)}
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
@@ -1328,6 +1455,10 @@ export function ParcelSyncModule() {
<CardContent className="pt-0 pb-3 space-y-1.5">
{layers.map((layer) => {
const isDownloading = downloadingLayer === layer.id;
const lc =
layerCountSiruta === siruta
? layerCounts[layer.id]
: undefined;
return (
<div
key={layer.id}
@@ -1339,9 +1470,29 @@ export function ParcelSyncModule() {
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{layer.label}
</p>
{lc != null && !lc.error && (
<Badge
variant="secondary"
className={cn(
"font-mono text-[10px] shrink-0",
lc.count === 0
? "opacity-40"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
)}
>
{lc.count.toLocaleString("ro-RO")}
</Badge>
)}
{lc?.error && (
<span className="text-[10px] text-rose-500">
eroare
</span>
)}
</div>
<p className="text-[11px] text-muted-foreground font-mono">
{layer.id}
</p>
@@ -1370,6 +1521,60 @@ export function ParcelSyncModule() {
);
},
)}
{/* Drumul de azi — today's layer count history */}
{layerHistory.length > 0 && (
<Card>
<div className="px-4 py-3 border-b">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-500" />
<span className="text-sm font-semibold">Drumul de azi</span>
<Badge variant="outline" className="font-normal text-[11px]">
{layerHistory.length}
</Badge>
</div>
</div>
<CardContent className="pt-3 pb-3">
<div className="space-y-2 max-h-64 overflow-y-auto">
{/* Group by siruta */}
{(() => {
const grouped = new Map<string, typeof layerHistory>();
for (const e of layerHistory) {
if (!grouped.has(e.siruta)) grouped.set(e.siruta, []);
grouped.get(e.siruta)!.push(e);
}
return Array.from(grouped.entries()).map(
([sir, entries]) => (
<div key={sir} className="space-y-1">
<p className="text-[11px] font-semibold text-muted-foreground">
SIRUTA {sir}{" "}
<span className="font-normal opacity-70">
{new Date(entries[0]!.time).toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit" })}
</span>
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1">
{entries
.sort((a, b) => b.count - a.count)
.map((e) => (
<div
key={e.layerId}
className="flex items-center justify-between gap-1 rounded border px-2 py-1 text-[11px]"
>
<span className="truncate">{e.label}</span>
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">
{e.count.toLocaleString("ro-RO")}
</span>
</div>
))}
</div>
</div>
),
);
})()}
</div>
</CardContent>
</Card>
)}
</div>
)}