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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{layer.label}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user