From 2f114d47de058e075b1bc46561fcc0b24737588b Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 24 Mar 2026 23:37:00 +0200 Subject: [PATCH] feat(rgi): sortable/filterable table, county selector, smart filenames, soft blocked msg Page improvements: - County dropdown with all 41 Romanian counties (default Cluj) - orgUnitId auto-computed (countyId * 1000 + 2) - Sortable columns: click header to sort asc/desc with arrow indicators - Search input: filters across all visible columns (diacritics-insensitive) - Soft blocked message: amber toast "Documentul nu este inca disponibil" auto-hides after 5s (no more redirect errors) Download improvements: - Meaningful filenames: {docType}_{appNo}.pdf (e.g. Harti_planuri_66903.pdf) - Romanian diacritics stripped from filenames - Returns { blocked: true } JSON instead of redirect when unavailable Bug fix: replaced incorrect useState() side-effect with proper useEffect() Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(modules)/rgi-test/page.tsx | 417 +++++++++++++++---- src/app/api/eterra/rgi/download-doc/route.ts | 79 +++- 2 files changed, 396 insertions(+), 100 deletions(-) diff --git a/src/app/(modules)/rgi-test/page.tsx b/src/app/(modules)/rgi-test/page.tsx index a215077..f742851 100644 --- a/src/app/(modules)/rgi-test/page.tsx +++ b/src/app/(modules)/rgi-test/page.tsx @@ -1,11 +1,18 @@ "use client"; -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Badge } from "@/shared/components/ui/badge"; import { Card, CardContent } from "@/shared/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; import { Loader2, ChevronDown, @@ -18,6 +25,9 @@ import { AlertTriangle, Settings2, Shield, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { cn } from "@/shared/lib/utils"; @@ -67,6 +77,57 @@ type IssuedDoc = { [key: string]: unknown; }; +type SortDir = "asc" | "desc"; +type SortState = { key: string; dir: SortDir } | null; + +/* ------------------------------------------------------------------ */ +/* County list */ +/* ------------------------------------------------------------------ */ + +const COUNTIES = [ + { id: 10, name: "Alba" }, + { id: 29, name: "Arad" }, + { id: 38, name: "Arges" }, + { id: 47, name: "Bacau" }, + { id: 56, name: "Bihor" }, + { id: 65, name: "Bistrita-Nasaud" }, + { id: 74, name: "Botosani" }, + { id: 83, name: "Brasov" }, + { id: 92, name: "Braila" }, + { id: 108, name: "Buzau" }, + { id: 117, name: "Caras-Severin" }, + { id: 127, name: "Cluj" }, + { id: 136, name: "Constanta" }, + { id: 145, name: "Covasna" }, + { id: 154, name: "Dambovita" }, + { id: 163, name: "Dolj" }, + { id: 172, name: "Galati" }, + { id: 181, name: "Giurgiu" }, + { id: 190, name: "Gorj" }, + { id: 199, name: "Harghita" }, + { id: 208, name: "Hunedoara" }, + { id: 217, name: "Ialomita" }, + { id: 226, name: "Iasi" }, + { id: 235, name: "Ilfov" }, + { id: 244, name: "Maramures" }, + { id: 253, name: "Mehedinti" }, + { id: 262, name: "Mures" }, + { id: 271, name: "Neamt" }, + { id: 280, name: "Olt" }, + { id: 289, name: "Prahova" }, + { id: 298, name: "Satu Mare" }, + { id: 307, name: "Salaj" }, + { id: 316, name: "Sibiu" }, + { id: 325, name: "Suceava" }, + { id: 334, name: "Teleorman" }, + { id: 343, name: "Timis" }, + { id: 352, name: "Tulcea" }, + { id: 361, name: "Vaslui" }, + { id: 370, name: "Valcea" }, + { id: 379, name: "Vrancea" }, + { id: 401, name: "Bucuresti" }, +] as const; + /* ------------------------------------------------------------------ */ /* Column definitions */ /* ------------------------------------------------------------------ */ @@ -203,6 +264,20 @@ const ALL_COLUMNS: ColumnDef[] = [ }, ]; +/* ------------------------------------------------------------------ */ +/* Diacritics-insensitive search helper */ +/* ------------------------------------------------------------------ */ + +function removeDiacritics(str: string): string { + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); +} + +function matchesSearch(text: string, query: string): boolean { + return removeDiacritics(text.toLowerCase()).includes( + removeDiacritics(query.toLowerCase()), + ); +} + /* ------------------------------------------------------------------ */ /* Issued Documents panel */ /* ------------------------------------------------------------------ */ @@ -210,18 +285,20 @@ const ALL_COLUMNS: ColumnDef[] = [ function IssuedDocsPanel({ applicationPk, workspaceId, - dueDate, + appNo, }: { applicationPk: number; workspaceId: number; - dueDate: number; + appNo: number; }) { const [docs, setDocs] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [blockedDocPk, setBlockedDocPk] = useState(null); + const blockedTimerRef = useRef | null>(null); - // Auto-load - useState(() => { + useEffect(() => { + let cancelled = false; void (async () => { try { const res = await fetch( @@ -231,13 +308,73 @@ function IssuedDocsPanel({ const items: IssuedDoc[] = Array.isArray(data) ? data : data?.content ?? data?.data ?? data?.list ?? []; - setDocs(items); + if (!cancelled) setDocs(items); } catch { - setError("Eroare la incarcarea documentelor"); + if (!cancelled) setError("Eroare la incarcarea documentelor"); } - setLoading(false); + if (!cancelled) setLoading(false); })(); - }); + return () => { + cancelled = true; + }; + }, [applicationPk, workspaceId]); + + // Cleanup blocked timer on unmount + useEffect(() => { + return () => { + if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); + }; + }, []); + + const handleDownload = useCallback( + async (doc: IssuedDoc, e: React.MouseEvent) => { + e.stopPropagation(); + const url = + `/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || workspaceId}` + + `&applicationId=${doc.applicationId || applicationPk}` + + `&documentPk=${doc.documentPk}` + + `&documentTypeId=${doc.documentTypeId}` + + `&docType=${encodeURIComponent(doc.docType || doc.documentTypeCode || "Document")}` + + `&appNo=${appNo}`; + + try { + const res = await fetch(url); + const contentType = res.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const json = await res.json(); + if (json.blocked || json.error) { + setBlockedDocPk(doc.documentPk); + if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); + blockedTimerRef.current = setTimeout(() => setBlockedDocPk(null), 5000); + return; + } + } + + // It's a file — trigger download + const blob = await res.blob(); + const disposition = res.headers.get("content-disposition") || ""; + let filename = `document_${doc.documentPk}.pdf`; + const match = disposition.match(/filename="?([^";\n]+)"?/); + if (match) { + const decoded = match[1]; + if (decoded) filename = decodeURIComponent(decoded); + } + 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); + } catch { + setBlockedDocPk(doc.documentPk); + if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); + blockedTimerRef.current = setTimeout(() => setBlockedDocPk(null), 5000); + } + }, + [workspaceId, applicationPk, appNo], + ); if (loading) { return ( @@ -267,45 +404,48 @@ function IssuedDocsPanel({ {docs.length > 1 ? "e" : ""}:

{docs.map((doc, i) => ( -
-
- -
-

- {doc.docType || doc.documentTypeCode || "Document"} -

-
- {fmtTs(doc.startDate || doc.lastUpdatedDtm)} - - .{(doc.fileExtension || "PDF").toLowerCase()} - - {doc.digitallySigned === 1 && ( - - - semnat +
+
+
+ +
+

+ {doc.docType || doc.documentTypeCode || "Document"} +

+
+ {fmtTs(doc.startDate || doc.lastUpdatedDtm)} + + .{(doc.fileExtension || "PDF").toLowerCase()} - )} - {doc.identifierDetails && ( - - {doc.identifierDetails} - - )} + {doc.digitallySigned === 1 && ( + + + semnat + + )} + {doc.identifierDetails && ( + + {doc.identifierDetails} + + )} +
-
- + +
+ {blockedDocPk === doc.documentPk && ( +
+ Documentul nu este inca disponibil pentru descarcare din eTerra +
+ )}
))}
@@ -317,18 +457,22 @@ function IssuedDocsPanel({ /* ------------------------------------------------------------------ */ export default function RgiTestPage() { - const [workspaceId, setWorkspaceId] = useState("127"); - const [orgUnitId, setOrgUnitId] = useState("127002"); + const [countyId, setCountyId] = useState(127); + const orgUnitId = countyId * 1000 + 2; const [year, setYear] = useState("2026"); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [applications, setApplications] = useState([]); const [totalCount, setTotalCount] = useState(0); - // filterSolved removed — replaced by filterMode const [expandedPk, setExpandedPk] = useState(null); const [showColumnPicker, setShowColumnPicker] = useState(false); + const [filterMode, setFilterMode] = useState<"all" | "solved" | "confirmed">( + "solved", + ); + const [searchQuery, setSearchQuery] = useState(""); + const [sortState, setSortState] = useState(null); - // Column visibility — saved per session + // Column visibility const [visibleCols, setVisibleCols] = useState>( () => new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key)), ); @@ -347,6 +491,18 @@ export default function RgiTestPage() { [visibleCols], ); + const handleSort = useCallback( + (key: string) => { + setSortState((prev) => { + if (prev && prev.key === key) { + return prev.dir === "asc" ? { key, dir: "desc" } : null; + } + return { key, dir: "asc" }; + }); + }, + [], + ); + const loadApplications = useCallback(async () => { setLoading(true); setError(""); @@ -357,8 +513,8 @@ export default function RgiTestPage() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - workspaceId: parseInt(workspaceId, 10), - orgUnitId: parseInt(orgUnitId, 10), + workspaceId: countyId, + orgUnitId, year, page: 0, nrElements: 200, @@ -382,19 +538,66 @@ export default function RgiTestPage() { setError("Eroare de retea. Verifica conexiunea la eTerra."); } setLoading(false); - }, [workspaceId, orgUnitId, year]); + }, [countyId, orgUnitId, year]); - const [filterMode, setFilterMode] = useState<"all" | "solved" | "confirmed">("solved"); + // Client-side filter + search + sort pipeline + const processed = useMemo(() => { + // Step 1: Filter by mode + let result = applications; + if (filterMode === "solved") { + result = result.filter((a) => a.hasSolution === 1); + } else if (filterMode === "confirmed") { + result = result.filter((a) => a.stateCode === "CONFIRMED"); + } - // Client-side filter - const filtered = useMemo(() => { - if (filterMode === "all") return applications; - return applications.filter((a) => { - if (filterMode === "solved") return a.hasSolution === 1; - if (filterMode === "confirmed") return a.stateCode === "CONFIRMED"; - return true; - }); - }, [applications, filterMode]); + // Step 2: Search across visible columns + if (searchQuery.trim()) { + const q = searchQuery.trim(); + result = result.filter((app) => + columns.some((col) => matchesSearch(col.render(app), q)), + ); + } + + // Step 3: Sort + if (sortState) { + const col = ALL_COLUMNS.find((c) => c.key === sortState.key); + if (col) { + const dir = sortState.dir === "asc" ? 1 : -1; + result = [...result].sort((a, b) => { + const va = col.render(a); + const vb = col.render(b); + // Try numeric comparison first + const na = parseFloat(va); + const nb = parseFloat(vb); + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * dir; + // Date comparison (dd.mm.yyyy format) + if (va.includes(".") && vb.includes(".")) { + const pa = va.split("."); + const pb = vb.split("."); + if (pa.length === 3 && pb.length === 3) { + const da = new Date(`${pa[2]}-${pa[1]}-${pa[0]}`).getTime(); + const db = new Date(`${pb[2]}-${pb[1]}-${pb[0]}`).getTime(); + if (!isNaN(da) && !isNaN(db)) return (da - db) * dir; + } + } + // String comparison + return va.localeCompare(vb, "ro") * dir; + }); + } + } + + return result; + }, [applications, filterMode, searchQuery, columns, sortState]); + + const SortIcon = ({ colKey }: { colKey: string }) => { + if (!sortState || sortState.key !== colKey) { + return ; + } + if (sortState.dir === "asc") { + return ; + } + return ; + }; return (
@@ -411,20 +614,22 @@ export default function RgiTestPage() {
- - setWorkspaceId(e.target.value)} - className="w-24" - /> -
-
- - setOrgUnitId(e.target.value)} - className="w-28" - /> + +
@@ -473,14 +678,24 @@ export default function RgiTestPage() {
)} - {/* Filter toggle */} + {/* Filter toggle + search */}
- {([ - { id: "solved" as const, label: "Solutionate", desc: "lucrari cu solutie" }, - { id: "confirmed" as const, label: "Confirmate", desc: "solutie confirmata" }, - { id: "all" as const, label: "Toate", desc: "" }, - ]).map((opt) => ( + {( + [ + { + id: "solved" as const, + label: "Solutionate", + desc: "lucrari cu solutie", + }, + { + id: "confirmed" as const, + label: "Confirmate", + desc: "solutie confirmata", + }, + { id: "all" as const, label: "Toate", desc: "" }, + ] as const + ).map((opt) => ( ))}
+ {applications.length > 0 && ( - - {filtered.length} din {applications.length} lucrari - {totalCount > applications.length && ` (${totalCount} total)`} - +
+
+ + setSearchQuery(e.target.value)} + placeholder="Cauta in rezultate..." + className="pl-8 h-8 text-xs" + /> +
+ + {processed.length} din {applications.length} lucrari + {totalCount > applications.length && + ` (${totalCount} total)`} + +
)}
@@ -527,7 +755,7 @@ export default function RgiTestPage() { )} {/* Results table */} - {!loading && filtered.length > 0 && ( + {!loading && processed.length > 0 && (
@@ -538,16 +766,20 @@ export default function RgiTestPage() { {columns.map((col) => ( handleSort(col.key)} > - {col.label} + + {col.label} + + ))} - {filtered.map((app) => { + {processed.map((app) => { const pk = app.applicationPk; const isExpanded = expandedPk === pk; const solved = app.hasSolution === 1; @@ -563,7 +795,10 @@ export default function RgiTestPage() { setExpandedPk(isExpanded ? null : pk) } > - + {solved ? ( ) : ( @@ -619,7 +854,7 @@ export default function RgiTestPage() { @@ -635,13 +870,13 @@ export default function RgiTestPage() { )} {/* Empty states */} - {!loading && applications.length > 0 && filtered.length === 0 && ( + {!loading && applications.length > 0 && processed.length === 0 && (

Nicio lucrare gasita pentru filtrul selectat.

- Schimba filtrul pentru a vedea alte lucrari. + Schimba filtrul sau termenul de cautare.

diff --git a/src/app/api/eterra/rgi/download-doc/route.ts b/src/app/api/eterra/rgi/download-doc/route.ts index 11baff5..2e6cdfd 100644 --- a/src/app/api/eterra/rgi/download-doc/route.ts +++ b/src/app/api/eterra/rgi/download-doc/route.ts @@ -5,12 +5,48 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * GET /api/eterra/rgi/download-doc?workspaceId=127&applicationId=X&documentPk=Y&documentTypeId=Z + * Strip Romanian diacritics and replace non-alphanumeric chars with underscores. + */ +function sanitizeFilename(raw: string): string { + return raw + .replace(/[ăâ]/g, "a") + .replace(/[ĂÂ]/g, "A") + .replace(/[îÎ]/g, "i") + .replace(/[țȚ]/g, "t") + .replace(/[șȘ]/g, "s") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); +} + +/** + * Extract file extension from content-type or server filename. + */ +function getExtension(contentType: string, serverFilename: string): string { + // Try extension from server filename first + const dotIdx = serverFilename.lastIndexOf("."); + if (dotIdx > 0) { + return serverFilename.slice(dotIdx + 1).toLowerCase(); + } + // Fallback to content-type mapping + const map: Record = { + "application/pdf": "pdf", + "image/png": "png", + "image/jpeg": "jpg", + "application/zip": "zip", + "application/xml": "xml", + "text/xml": "xml", + }; + return map[contentType] ?? "pdf"; +} + +/** + * GET /api/eterra/rgi/download-doc?workspaceId=127&applicationId=X&documentPk=Y&documentTypeId=Z&docType=...&appNo=...&initialAppNo=... * * Downloads an issued document from eTerra RGI. * Tries server-side download first. If that fails (some documents are - * restricted to the current actor), returns a direct eTerra URL that - * works in the user's browser session. + * restricted to the current actor), returns a JSON blocked response + * so the frontend can show a soft message. */ export async function GET(req: NextRequest) { try { @@ -18,6 +54,9 @@ export async function GET(req: NextRequest) { const applicationId = req.nextUrl.searchParams.get("applicationId"); const documentPk = req.nextUrl.searchParams.get("documentPk"); const documentTypeId = req.nextUrl.searchParams.get("documentTypeId"); + const docType = req.nextUrl.searchParams.get("docType"); + const appNo = req.nextUrl.searchParams.get("appNo"); + const initialAppNo = req.nextUrl.searchParams.get("initialAppNo"); if (!workspaceId || !applicationId || !documentPk) { return NextResponse.json( @@ -70,10 +109,27 @@ export async function GET(req: NextRequest) { // Try download (even if fileVisibility failed — context might be enough) try { - const { data, contentType, filename } = await client.rgiDownload( + const { data, contentType, filename: serverFilename } = await client.rgiDownload( `rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`, ); if (data.length > 0) { + // Build meaningful filename from query params, fallback to server filename + const ext = getExtension(contentType, serverFilename); + let filename: string; + if (docType && appNo) { + filename = `${sanitizeFilename(docType)}_${sanitizeFilename(appNo)}.${ext}`; + } else if (docType) { + filename = `${sanitizeFilename(docType)}.${ext}`; + } else if (appNo) { + filename = `document_${sanitizeFilename(appNo)}.${ext}`; + } else { + // Use server filename, but still sanitize it + const serverBase = serverFilename.replace(/\.[^.]+$/, ""); + filename = serverBase && serverBase !== "document" + ? `${sanitizeFilename(serverBase)}.${ext}` + : serverFilename; + } + return new NextResponse(new Uint8Array(data), { status: 200, headers: { @@ -84,13 +140,18 @@ export async function GET(req: NextRequest) { }); } } catch { - // Fall through to redirect + // Fall through to blocked response } - // Server-side download not available — redirect to eTerra direct URL - // User's browser session (if logged into eTerra) can download it - const eterraUrl = `https://eterra.ancpi.ro/eterra/api/rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`; - return NextResponse.redirect(eterraUrl, 302); + // Server-side download not available — return soft blocked response + // so the frontend can show a user-friendly message + return NextResponse.json( + { + blocked: true, + message: "Documentul nu este inca disponibil pentru descarcare din eTerra.", + }, + { status: 200 }, + ); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; return NextResponse.json({ error: message }, { status: 500 });