feat: ZIP download, mobile fixes, click centering, tooltip

ZIP download:
- Both portal and RGI test page now create a single ZIP archive
  (Documente_eliberate_{appNo}.zip) instead of sequential downloads
- Uses JSZip (already in project dependencies)

Portal mobile:
- Basemap switcher drops below UAT card on mobile (top-14 sm:top-2)
- Selection toolbar at bottom-3 with z-30 (always visible)
- Click on feature centers map on that parcel (flyTo)

Tooltips:
- Green download icon: "Descarca arhiva ZIP cu documentele cererii X"
- Updated on both portal and RGI test page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-25 09:17:29 +02:00
parent 8acafe958b
commit 12ff629fbf
2 changed files with 58 additions and 37 deletions
+19 -17
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useCallback, useMemo, useEffect, useRef } from "react";
import JSZip from "jszip";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
@@ -354,10 +355,10 @@ function IssuedDocsPanel({
const handleDownloadAll = useCallback(async () => {
if (!docs || docs.length === 0 || downloadingAll) return;
setDownloadingAll(true);
const zip = new JSZip();
let downloaded = 0;
let blocked = 0;
// Count duplicates by docType for naming (e.g. Receptie_tehnica_66903_2.pdf)
const typeCounts: Record<string, number> = {};
for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1;
const typeIdx: Record<string, number> = {};
@@ -382,30 +383,31 @@ function IssuedDocsPanel({
try {
const res = await fetch(url);
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) {
blocked++;
continue;
}
if (ct.includes("application/json")) { blocked++; continue; }
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
zip.file(filename, blob);
downloaded++;
// Small delay between downloads so browser doesn't block them
await new Promise((r) => setTimeout(r, 300));
} catch {
blocked++;
}
}
if (downloaded > 0) {
setDownloadProgress("Se creeaza arhiva ZIP...");
const zipBlob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(zipBlob);
a.download = `Documente_eliberate_${appNo}.zip`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
setDownloadProgress(
blocked > 0
? `${downloaded} descarcat${downloaded !== 1 ? "e" : ""}, ${blocked} indisponibil${blocked !== 1 ? "e" : ""}`
: `${downloaded} document${downloaded !== 1 ? "e" : ""} descarcat${downloaded !== 1 ? "e" : ""}`,
? `${downloaded} in ZIP, ${blocked} indisponibil${blocked !== 1 ? "e" : ""}`
: `ZIP descarcat cu ${downloaded} document${downloaded !== 1 ? "e" : ""}`,
);
setDownloadingAll(false);
setTimeout(() => setDownloadProgress(""), 5000);
@@ -982,7 +984,7 @@ export default function RgiTestPage() {
</button>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-xs">
<p className="font-semibold">Nr. {app.appNo} click descarca toate</p>
<p className="font-semibold">Descarca arhiva ZIP cu documentele cererii {app.appNo}</p>
<p>{app.applicationObject || "-"}</p>
<p>Status: {app.statusName || app.stateCode}</p>
<p>Rezolutie: {app.resolutionName || "-"}</p>
+39 -20
View File
@@ -41,6 +41,7 @@ import {
Satellite,
RefreshCw,
} from "lucide-react";
import JSZip from "jszip";
import { cn } from "@/shared/lib/utils";
import { SelectionToolbar, type SelectionMode } from "@/modules/geoportal/components/selection-toolbar";
// Simple inline feature panel — no enrichment, no CF extract
@@ -373,6 +374,7 @@ function IssuedDocsPanel({
let downloaded = 0;
let blocked = 0;
const zip = new JSZip();
const typeCounts: Record<string, number> = {};
for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1;
const typeIdx: Record<string, number> = {};
@@ -402,20 +404,25 @@ function IssuedDocsPanel({
continue;
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
zip.file(filename, blob);
downloaded++;
await new Promise((r) => setTimeout(r, 300));
} catch {
blocked++;
}
}
if (downloaded > 0) {
setDownloadProgress("Se genereaza arhiva ZIP...");
const zipBlob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(zipBlob);
a.download = `Documente_eliberate_${appNo}.zip`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
setDownloadProgress(
blocked > 0
? `${downloaded} descarcat${downloaded !== 1 ? "e" : ""}, ${blocked} indisponibil${blocked !== 1 ? "e" : ""}`
@@ -625,6 +632,7 @@ function RgiContent() {
return;
}
const zip = new JSZip();
const typeCounts: Record<string, number> = {};
for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1;
const typeIdx: Record<string, number> = {};
@@ -650,18 +658,22 @@ function RgiContent() {
const ct = r.headers.get("content-type") || "";
if (ct.includes("application/json")) continue;
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
await new Promise((resolve) => setTimeout(resolve, 300));
zip.file(filename, blob);
} catch {
// skip
}
}
if (Object.keys(zip.files).length > 0) {
const zipBlob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(zipBlob);
a.download = `Documente_eliberate_${app.appNo}.zip`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
} catch {
// silent
}
@@ -920,7 +932,7 @@ function RgiContent() {
<button
type="button"
className="mt-0.5 shrink-0"
title={`Descarca toate documentele cererii ${app.appNo}`}
title={`Descarca arhiva ZIP cu toate documentele cererii ${app.appNo}`}
onClick={(e) => { e.stopPropagation(); void downloadAllForApp(app); }}
disabled={downloadingAppPk === pk}
>
@@ -1025,7 +1037,7 @@ function RgiContent() {
</button>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-xs">
<p className="font-semibold">Nr. {app.appNo} click descarca toate</p>
<p className="font-semibold">Descarca arhiva ZIP cu toate documentele cererii {app.appNo}</p>
<p>{app.applicationObject || "-"}</p>
<p>Status: {app.statusName || app.stateCode}</p>
<p>Rezolutie: {app.resolutionName || "-"}</p>
@@ -1189,6 +1201,7 @@ type MapLike = {
bounds: [number, number, number, number],
opts?: Record<string, unknown>,
): void;
flyTo(opts: { center: [number, number]; duration?: number; zoom?: number }): void;
isStyleLoaded(): boolean;
};
@@ -1365,6 +1378,11 @@ function HartaContent() {
return;
}
setClickedFeature(feature);
// Center map on clicked feature
const map = asMap(mapHandleRef.current);
if (map && feature.coordinates) {
map.flyTo({ center: feature.coordinates, duration: 500 });
}
}, []);
const handleSelectionModeChange = useCallback((mode: SelectionMode) => {
@@ -1539,7 +1557,8 @@ function HartaContent() {
</div>
{/* Top-right: basemap switcher + simple feature info (offset to avoid zoom controls) */}
<div className="absolute top-2 right-12 z-10 flex flex-col items-end gap-2">
{/* On mobile (< sm), drops below the UAT card via top-14 */}
<div className="absolute top-14 sm:top-2 right-2 sm:right-12 z-10 flex flex-col items-end gap-2">
<PortalBasemapSwitcher value={basemap} onChange={setBasemap} />
{clickedFeature && selectionMode === "off" && (
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg w-56 sm:w-64 overflow-hidden">
@@ -1598,7 +1617,7 @@ function HartaContent() {
</div>
{/* Bottom: selection toolbar — centered, above attribution */}
<div className="absolute bottom-12 sm:bottom-8 left-1/2 -translate-x-1/2 sm:left-3 sm:translate-x-0 z-20">
<div className="absolute bottom-3 sm:bottom-8 left-1/2 -translate-x-1/2 sm:left-3 sm:translate-x-0 z-30">
<SelectionToolbar
selectedFeatures={selectedFeatures}
selectionMode={selectionMode}