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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,18 @@
|
|||||||
"use client";
|
"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 { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -18,6 +25,9 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Settings2,
|
Settings2,
|
||||||
Shield,
|
Shield,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
@@ -67,6 +77,57 @@ type IssuedDoc = {
|
|||||||
[key: string]: unknown;
|
[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 */
|
/* 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 */
|
/* Issued Documents panel */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -210,18 +285,20 @@ const ALL_COLUMNS: ColumnDef[] = [
|
|||||||
function IssuedDocsPanel({
|
function IssuedDocsPanel({
|
||||||
applicationPk,
|
applicationPk,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
dueDate,
|
appNo,
|
||||||
}: {
|
}: {
|
||||||
applicationPk: number;
|
applicationPk: number;
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
dueDate: number;
|
appNo: number;
|
||||||
}) {
|
}) {
|
||||||
const [docs, setDocs] = useState<IssuedDoc[] | null>(null);
|
const [docs, setDocs] = useState<IssuedDoc[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [blockedDocPk, setBlockedDocPk] = useState<number | null>(null);
|
||||||
|
const blockedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Auto-load
|
useEffect(() => {
|
||||||
useState(() => {
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -231,13 +308,73 @@ function IssuedDocsPanel({
|
|||||||
const items: IssuedDoc[] = Array.isArray(data)
|
const items: IssuedDoc[] = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
: data?.content ?? data?.data ?? data?.list ?? [];
|
: data?.content ?? data?.data ?? data?.list ?? [];
|
||||||
setDocs(items);
|
if (!cancelled) setDocs(items);
|
||||||
} catch {
|
} 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -267,10 +404,8 @@ function IssuedDocsPanel({
|
|||||||
{docs.length > 1 ? "e" : ""}:
|
{docs.length > 1 ? "e" : ""}:
|
||||||
</p>
|
</p>
|
||||||
{docs.map((doc, i) => (
|
{docs.map((doc, i) => (
|
||||||
<div
|
<div key={doc.documentPk || i} className="space-y-0">
|
||||||
key={doc.documentPk || i}
|
<div className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2 hover:bg-muted/30 transition-colors">
|
||||||
className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2 hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -296,17 +431,22 @@ function IssuedDocsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" className="gap-1 shrink-0" asChild>
|
<Button
|
||||||
<a
|
size="sm"
|
||||||
href={`/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || workspaceId}&applicationId=${doc.applicationId || applicationPk}&documentPk=${doc.documentPk}&documentTypeId=${doc.documentTypeId}`}
|
variant="outline"
|
||||||
target="_blank"
|
className="gap-1 shrink-0"
|
||||||
rel="noopener noreferrer"
|
onClick={(e) => void handleDownload(doc, e)}
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Descarca
|
Descarca
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{blockedDocPk === doc.documentPk && (
|
||||||
|
<div className="mx-3 mt-1 mb-1 px-3 py-1.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
|
||||||
|
Documentul nu este inca disponibil pentru descarcare din eTerra
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -317,18 +457,22 @@ function IssuedDocsPanel({
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export default function RgiTestPage() {
|
export default function RgiTestPage() {
|
||||||
const [workspaceId, setWorkspaceId] = useState("127");
|
const [countyId, setCountyId] = useState(127);
|
||||||
const [orgUnitId, setOrgUnitId] = useState("127002");
|
const orgUnitId = countyId * 1000 + 2;
|
||||||
const [year, setYear] = useState("2026");
|
const [year, setYear] = useState("2026");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [applications, setApplications] = useState<App[]>([]);
|
const [applications, setApplications] = useState<App[]>([]);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
// filterSolved removed — replaced by filterMode
|
|
||||||
const [expandedPk, setExpandedPk] = useState<number | null>(null);
|
const [expandedPk, setExpandedPk] = useState<number | null>(null);
|
||||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||||
|
const [filterMode, setFilterMode] = useState<"all" | "solved" | "confirmed">(
|
||||||
|
"solved",
|
||||||
|
);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [sortState, setSortState] = useState<SortState>(null);
|
||||||
|
|
||||||
// Column visibility — saved per session
|
// Column visibility
|
||||||
const [visibleCols, setVisibleCols] = useState<Set<string>>(
|
const [visibleCols, setVisibleCols] = useState<Set<string>>(
|
||||||
() => new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key)),
|
() => new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key)),
|
||||||
);
|
);
|
||||||
@@ -347,6 +491,18 @@ export default function RgiTestPage() {
|
|||||||
[visibleCols],
|
[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 () => {
|
const loadApplications = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -357,8 +513,8 @@ export default function RgiTestPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
workspaceId: parseInt(workspaceId, 10),
|
workspaceId: countyId,
|
||||||
orgUnitId: parseInt(orgUnitId, 10),
|
orgUnitId,
|
||||||
year,
|
year,
|
||||||
page: 0,
|
page: 0,
|
||||||
nrElements: 200,
|
nrElements: 200,
|
||||||
@@ -382,19 +538,66 @@ export default function RgiTestPage() {
|
|||||||
setError("Eroare de retea. Verifica conexiunea la eTerra.");
|
setError("Eroare de retea. Verifica conexiunea la eTerra.");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
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
|
// Step 2: Search across visible columns
|
||||||
const filtered = useMemo(() => {
|
if (searchQuery.trim()) {
|
||||||
if (filterMode === "all") return applications;
|
const q = searchQuery.trim();
|
||||||
return applications.filter((a) => {
|
result = result.filter((app) =>
|
||||||
if (filterMode === "solved") return a.hasSolution === 1;
|
columns.some((col) => matchesSearch(col.render(app), q)),
|
||||||
if (filterMode === "confirmed") return a.stateCode === "CONFIRMED";
|
);
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
});
|
});
|
||||||
}, [applications, filterMode]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [applications, filterMode, searchQuery, columns, sortState]);
|
||||||
|
|
||||||
|
const SortIcon = ({ colKey }: { colKey: string }) => {
|
||||||
|
if (!sortState || sortState.key !== colKey) {
|
||||||
|
return <ArrowUpDown className="h-3 w-3 opacity-0 group-hover:opacity-40 transition-opacity" />;
|
||||||
|
}
|
||||||
|
if (sortState.dir === "asc") {
|
||||||
|
return <ArrowUp className="h-3 w-3 text-foreground" />;
|
||||||
|
}
|
||||||
|
return <ArrowDown className="h-3 w-3 text-foreground" />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-[1400px] mx-auto">
|
<div className="space-y-4 max-w-[1400px] mx-auto">
|
||||||
@@ -411,20 +614,22 @@ export default function RgiTestPage() {
|
|||||||
<CardContent className="pt-4 space-y-3">
|
<CardContent className="pt-4 space-y-3">
|
||||||
<div className="flex items-end gap-3 flex-wrap">
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Judet (workspace)</Label>
|
<Label className="text-xs">Judet</Label>
|
||||||
<Input
|
<Select
|
||||||
value={workspaceId}
|
value={String(countyId)}
|
||||||
onChange={(e) => setWorkspaceId(e.target.value)}
|
onValueChange={(v) => setCountyId(parseInt(v, 10))}
|
||||||
className="w-24"
|
>
|
||||||
/>
|
<SelectTrigger className="w-[200px]">
|
||||||
</div>
|
<SelectValue placeholder="Alege judetul" />
|
||||||
<div className="space-y-1">
|
</SelectTrigger>
|
||||||
<Label className="text-xs">OCPI (orgUnit)</Label>
|
<SelectContent>
|
||||||
<Input
|
{COUNTIES.map((c) => (
|
||||||
value={orgUnitId}
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
onChange={(e) => setOrgUnitId(e.target.value)}
|
{c.name}
|
||||||
className="w-28"
|
</SelectItem>
|
||||||
/>
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">An</Label>
|
<Label className="text-xs">An</Label>
|
||||||
@@ -473,14 +678,24 @@ export default function RgiTestPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filter toggle */}
|
{/* Filter toggle + search */}
|
||||||
<div className="flex items-center gap-3 pt-2 border-t flex-wrap">
|
<div className="flex items-center gap-3 pt-2 border-t flex-wrap">
|
||||||
<div className="flex gap-1 p-0.5 bg-muted rounded-md">
|
<div className="flex gap-1 p-0.5 bg-muted rounded-md">
|
||||||
{([
|
{(
|
||||||
{ id: "solved" as const, label: "Solutionate", desc: "lucrari cu solutie" },
|
[
|
||||||
{ id: "confirmed" as const, label: "Confirmate", desc: "solutie confirmata" },
|
{
|
||||||
|
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: "" },
|
{ id: "all" as const, label: "Toate", desc: "" },
|
||||||
]).map((opt) => (
|
] as const
|
||||||
|
).map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
onClick={() => setFilterMode(opt.id)}
|
onClick={() => setFilterMode(opt.id)}
|
||||||
@@ -496,11 +711,24 @@ export default function RgiTestPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{applications.length > 0 && (
|
{applications.length > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
{filtered.length} din {applications.length} lucrari
|
<div className="relative flex-1 max-w-xs">
|
||||||
{totalCount > applications.length && ` (${totalCount} total)`}
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Cauta in rezultate..."
|
||||||
|
className="pl-8 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{processed.length} din {applications.length} lucrari
|
||||||
|
{totalCount > applications.length &&
|
||||||
|
` (${totalCount} total)`}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -527,7 +755,7 @@ export default function RgiTestPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results table */}
|
{/* Results table */}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && processed.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -538,16 +766,20 @@ export default function RgiTestPage() {
|
|||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="px-3 py-2 text-left font-medium text-xs text-muted-foreground whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium text-xs text-muted-foreground whitespace-nowrap cursor-pointer select-none group"
|
||||||
|
onClick={() => handleSort(col.key)}
|
||||||
>
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
{col.label}
|
{col.label}
|
||||||
|
<SortIcon colKey={col.key} />
|
||||||
|
</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-2 py-2 w-8"></th>
|
<th className="px-2 py-2 w-8"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((app) => {
|
{processed.map((app) => {
|
||||||
const pk = app.applicationPk;
|
const pk = app.applicationPk;
|
||||||
const isExpanded = expandedPk === pk;
|
const isExpanded = expandedPk === pk;
|
||||||
const solved = app.hasSolution === 1;
|
const solved = app.hasSolution === 1;
|
||||||
@@ -563,7 +795,10 @@ export default function RgiTestPage() {
|
|||||||
setExpandedPk(isExpanded ? null : pk)
|
setExpandedPk(isExpanded ? null : pk)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td className="px-2 py-2.5 w-8" title={solved ? "Solutionata" : "In lucru"}>
|
<td
|
||||||
|
className="px-2 py-2.5 w-8"
|
||||||
|
title={solved ? "Solutionata" : "In lucru"}
|
||||||
|
>
|
||||||
{solved ? (
|
{solved ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
) : (
|
) : (
|
||||||
@@ -619,7 +854,7 @@ export default function RgiTestPage() {
|
|||||||
<IssuedDocsPanel
|
<IssuedDocsPanel
|
||||||
applicationPk={pk}
|
applicationPk={pk}
|
||||||
workspaceId={app.workspaceId}
|
workspaceId={app.workspaceId}
|
||||||
dueDate={app.dueDate}
|
appNo={app.appNo}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -635,13 +870,13 @@ export default function RgiTestPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty states */}
|
{/* Empty states */}
|
||||||
{!loading && applications.length > 0 && filtered.length === 0 && (
|
{!loading && applications.length > 0 && processed.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-8 text-center text-muted-foreground">
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
<FileText className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
<p>Nicio lucrare gasita pentru filtrul selectat.</p>
|
<p>Nicio lucrare gasita pentru filtrul selectat.</p>
|
||||||
<p className="text-xs mt-1">
|
<p className="text-xs mt-1">
|
||||||
Schimba filtrul pentru a vedea alte lucrari.
|
Schimba filtrul sau termenul de cautare.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,12 +5,48 @@ export const runtime = "nodejs";
|
|||||||
export const dynamic = "force-dynamic";
|
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<string, string> = {
|
||||||
|
"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.
|
* Downloads an issued document from eTerra RGI.
|
||||||
* Tries server-side download first. If that fails (some documents are
|
* Tries server-side download first. If that fails (some documents are
|
||||||
* restricted to the current actor), returns a direct eTerra URL that
|
* restricted to the current actor), returns a JSON blocked response
|
||||||
* works in the user's browser session.
|
* so the frontend can show a soft message.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -18,6 +54,9 @@ export async function GET(req: NextRequest) {
|
|||||||
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
||||||
const documentPk = req.nextUrl.searchParams.get("documentPk");
|
const documentPk = req.nextUrl.searchParams.get("documentPk");
|
||||||
const documentTypeId = req.nextUrl.searchParams.get("documentTypeId");
|
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) {
|
if (!workspaceId || !applicationId || !documentPk) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -70,10 +109,27 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// Try download (even if fileVisibility failed — context might be enough)
|
// Try download (even if fileVisibility failed — context might be enough)
|
||||||
try {
|
try {
|
||||||
const { data, contentType, filename } = await client.rgiDownload(
|
const { data, contentType, filename: serverFilename } = await client.rgiDownload(
|
||||||
`rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`,
|
`rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`,
|
||||||
);
|
);
|
||||||
if (data.length > 0) {
|
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), {
|
return new NextResponse(new Uint8Array(data), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -84,13 +140,18 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to redirect
|
// Fall through to blocked response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-side download not available — redirect to eTerra direct URL
|
// Server-side download not available — return soft blocked response
|
||||||
// User's browser session (if logged into eTerra) can download it
|
// so the frontend can show a user-friendly message
|
||||||
const eterraUrl = `https://eterra.ancpi.ro/eterra/api/rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`;
|
return NextResponse.json(
|
||||||
return NextResponse.redirect(eterraUrl, 302);
|
{
|
||||||
|
blocked: true,
|
||||||
|
message: "Documentul nu este inca disponibil pentru descarcare din eTerra.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
|||||||
Reference in New Issue
Block a user