feat(eterra): RGI API routes + test page for issued documents
New eTerra RGI (Registrul General de Intrare) integration: API routes (/api/eterra/rgi/): - POST /applications — list applications with workspace/year filters - GET /details?applicationId=X — application details - GET /issued-docs?applicationId=X&workspaceId=Y — issued documents list - GET /download-doc?wid=X&aid=Y&did=Z — download issued document EterraClient: added rgiPost, rgiGet, rgiDownload methods for RGI API. Test page (/rgi-test): - Filters: workspace, orgUnit, year - Toggle: "Doar solutionate cu termen viitor" - Table with application list, expandable issued docs, download links - Raw JSON debug sections (collapsible) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,612 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card";
|
||||||
|
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 { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import { Loader2, ChevronDown, ChevronUp, Download, Search, FileText, AlertCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types — loosely typed since eTerra response shape varies */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type Application = Record<string, unknown>;
|
||||||
|
type IssuedDoc = Record<string, unknown>;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Safely read a nested string field from a record. */
|
||||||
|
function str(obj: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = obj[k];
|
||||||
|
if (typeof v === "string" && v.length > 0) return v;
|
||||||
|
if (typeof v === "number") return String(v);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a date string (various eTerra formats) into a Date, or null. */
|
||||||
|
function parseDate(raw: unknown): Date | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (typeof raw === "number") return new Date(raw);
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (!isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date for display (DD.MM.YYYY). */
|
||||||
|
function fmtDate(raw: unknown): string {
|
||||||
|
const d = parseDate(raw);
|
||||||
|
if (!d) return "-";
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
return `${dd}.${mm}.${yyyy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a date value is in the future. */
|
||||||
|
function isFuture(raw: unknown): boolean {
|
||||||
|
const d = parseDate(raw);
|
||||||
|
if (!d) return false;
|
||||||
|
return d.getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect if an application looks "solutionat" (solved). */
|
||||||
|
function isSolutionat(app: Application): boolean {
|
||||||
|
const status = String(
|
||||||
|
app.statusName ?? app.status ?? app.statusCode ?? app.applicationStatus ?? ""
|
||||||
|
).toLowerCase();
|
||||||
|
return (
|
||||||
|
status.includes("solu") ||
|
||||||
|
status.includes("rezolv") ||
|
||||||
|
status.includes("finalizat") ||
|
||||||
|
status.includes("closed") ||
|
||||||
|
status.includes("eliberat")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the due date from an application (tries common field names). */
|
||||||
|
function getDueDate(app: Application): unknown {
|
||||||
|
return (
|
||||||
|
app.dueDate ??
|
||||||
|
app.solutionDeadline ??
|
||||||
|
app.termenSolutionare ??
|
||||||
|
app.deadline ??
|
||||||
|
app.termenLegal ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-components */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function StatusBadge({ app }: { app: Application }) {
|
||||||
|
const raw = String(
|
||||||
|
app.statusName ?? app.status ?? app.statusCode ?? app.applicationStatus ?? "Necunoscut"
|
||||||
|
);
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
|
||||||
|
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
||||||
|
if (lower.includes("solu") || lower.includes("finalizat") || lower.includes("eliberat")) {
|
||||||
|
variant = "default";
|
||||||
|
} else if (lower.includes("suspenda") || lower.includes("reject")) {
|
||||||
|
variant = "destructive";
|
||||||
|
} else if (lower.includes("lucr") || lower.includes("analiz")) {
|
||||||
|
variant = "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge variant={variant}>{raw}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssuedDocsPanel({
|
||||||
|
applicationId,
|
||||||
|
workspaceId,
|
||||||
|
}: {
|
||||||
|
applicationId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
}) {
|
||||||
|
const [docs, setDocs] = useState<IssuedDoc[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const loadDocs = useCallback(async () => {
|
||||||
|
if (loaded) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/eterra/rgi/issued-docs?applicationId=${encodeURIComponent(applicationId)}&workspaceId=${workspaceId}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(body as Record<string, unknown>).error
|
||||||
|
? String((body as Record<string, unknown>).error)
|
||||||
|
: `HTTP ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data: unknown = await res.json();
|
||||||
|
|
||||||
|
// eTerra can return { elements: [...] } or just an array
|
||||||
|
let list: IssuedDoc[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
list = data as IssuedDoc[];
|
||||||
|
} else if (data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).elements)) {
|
||||||
|
list = (data as Record<string, unknown>).elements as IssuedDoc[];
|
||||||
|
} else if (data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).content)) {
|
||||||
|
list = (data as Record<string, unknown>).content as IssuedDoc[];
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocs(list);
|
||||||
|
setLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Eroare la incarcarea documentelor");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [applicationId, workspaceId, loaded]);
|
||||||
|
|
||||||
|
// Auto-load on mount
|
||||||
|
if (!loaded && !loading && !error) {
|
||||||
|
loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Se incarca documentele eliberate...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
{error}
|
||||||
|
<Button variant="ghost" size="xs" onClick={() => { setLoaded(false); setError(null); }}>
|
||||||
|
Reincearca
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docs || docs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
Niciun document eliberat gasit.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
Documente eliberate ({docs.length})
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground text-xs">
|
||||||
|
<th className="py-1 pr-3">Nr. document</th>
|
||||||
|
<th className="py-1 pr-3">Tip</th>
|
||||||
|
<th className="py-1 pr-3">Data</th>
|
||||||
|
<th className="py-1 pr-3">Status</th>
|
||||||
|
<th className="py-1">Actiuni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{docs.map((doc, i) => {
|
||||||
|
const docId = str(doc, "issuedDocumentId", "documentId", "id", "pk");
|
||||||
|
const docNr = str(doc, "documentNumber", "number", "nr", "nrDocument") || docId;
|
||||||
|
const docType = str(doc, "documentTypeName", "documentType", "type", "tipDocument");
|
||||||
|
const docDate = doc.documentDate ?? doc.date ?? doc.dataDocument ?? doc.issueDate;
|
||||||
|
const docStatus = str(doc, "statusName", "status", "documentStatus");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={docId || i} className="border-b border-border/50 hover:bg-muted/30">
|
||||||
|
<td className="py-2 pr-3 font-mono text-xs">{docNr || "-"}</td>
|
||||||
|
<td className="py-2 pr-3">{docType || "-"}</td>
|
||||||
|
<td className="py-2 pr-3">{fmtDate(docDate)}</td>
|
||||||
|
<td className="py-2 pr-3">{docStatus || "-"}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{docId ? (
|
||||||
|
<a
|
||||||
|
href={`/api/eterra/rgi/download-doc?workspaceId=${workspaceId}&applicationId=${encodeURIComponent(applicationId)}&docId=${encodeURIComponent(docId)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<Download className="size-3" />
|
||||||
|
Descarca
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw JSON for debugging */}
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||||
|
Raspuns brut JSON
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted/50 p-2 text-xs">
|
||||||
|
{JSON.stringify(docs, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* ApplicationRow */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function ApplicationRow({
|
||||||
|
app,
|
||||||
|
workspaceId,
|
||||||
|
}: {
|
||||||
|
app: Application;
|
||||||
|
workspaceId: number;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const appId = str(app, "applicationId", "id", "pk", "applicationPk");
|
||||||
|
const appNr = str(app, "applicationNumber", "number", "nrCerere", "registrationNumber") || appId;
|
||||||
|
const serviceType = str(app, "serviceTypeName", "serviceType", "tipServiciu", "serviceName");
|
||||||
|
const applicant = str(app, "applicantName", "applicant", "deponent", "ownerName", "solicitant");
|
||||||
|
const submitDate = app.submitDate ?? app.registrationDate ?? app.dataCerere ?? app.dataInregistrare;
|
||||||
|
const dueDate = getDueDate(app);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="border-b hover:bg-muted/30">
|
||||||
|
<td className="py-2.5 px-3 font-mono text-xs">{appNr || "-"}</td>
|
||||||
|
<td className="py-2.5 px-3 text-sm max-w-[200px] truncate" title={serviceType}>
|
||||||
|
{serviceType || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-sm max-w-[180px] truncate" title={applicant}>
|
||||||
|
{applicant || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-sm">{fmtDate(submitDate)}</td>
|
||||||
|
<td className="py-2.5 px-3 text-sm">
|
||||||
|
<span className={cn(isFuture(dueDate) ? "text-green-500" : "text-muted-foreground")}>
|
||||||
|
{fmtDate(dueDate)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<StatusBadge app={app} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<FileText className="size-3" />
|
||||||
|
Detalii
|
||||||
|
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expanded && (
|
||||||
|
<tr className="border-b bg-muted/10">
|
||||||
|
<td colSpan={7} className="p-0">
|
||||||
|
<div className="border-l-2 border-primary/30 ml-4">
|
||||||
|
<IssuedDocsPanel applicationId={appId} workspaceId={workspaceId} />
|
||||||
|
|
||||||
|
{/* Application raw details */}
|
||||||
|
<details className="px-4 pb-3">
|
||||||
|
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||||
|
Date aplicatie (JSON brut)
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted/50 p-2 text-xs">
|
||||||
|
{JSON.stringify(app, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main Page */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function RgiTestPage() {
|
||||||
|
// Filter state
|
||||||
|
const [workspaceId, setWorkspaceId] = useState(127);
|
||||||
|
const [orgUnitId, setOrgUnitId] = useState(127002);
|
||||||
|
const [year, setYear] = useState("2026");
|
||||||
|
const [onlyFutureSolved, setOnlyFutureSolved] = useState(false);
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [applications, setApplications] = useState<Application[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadApplications = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/rgi/applications", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId,
|
||||||
|
orgUnitId,
|
||||||
|
year,
|
||||||
|
page: 0,
|
||||||
|
nrElements: 100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(body as Record<string, unknown>).error
|
||||||
|
? String((body as Record<string, unknown>).error)
|
||||||
|
: `HTTP ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: unknown = await res.json();
|
||||||
|
|
||||||
|
// Extract the list from various possible response shapes
|
||||||
|
let list: Application[] = [];
|
||||||
|
let total: number | null = null;
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
list = data as Application[];
|
||||||
|
total = list.length;
|
||||||
|
} else if (data && typeof data === "object") {
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
if (Array.isArray(obj.elements)) {
|
||||||
|
list = obj.elements as Application[];
|
||||||
|
} else if (Array.isArray(obj.content)) {
|
||||||
|
list = obj.content as Application[];
|
||||||
|
} else if (Array.isArray(obj.data)) {
|
||||||
|
list = obj.data as Application[];
|
||||||
|
}
|
||||||
|
if (typeof obj.totalElements === "number") total = obj.totalElements;
|
||||||
|
else if (typeof obj.total === "number") total = obj.total;
|
||||||
|
else if (typeof obj.totalCount === "number") total = obj.totalCount;
|
||||||
|
else total = list.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplications(list);
|
||||||
|
setTotalCount(total);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Eroare la incarcarea lucrarilor");
|
||||||
|
setApplications([]);
|
||||||
|
setTotalCount(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [workspaceId, orgUnitId, year]);
|
||||||
|
|
||||||
|
// Apply client-side filter
|
||||||
|
const displayedApps = onlyFutureSolved
|
||||||
|
? applications.filter((app) => isSolutionat(app) && isFuture(getDueDate(app)))
|
||||||
|
: applications;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6 p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
Documente Eliberate eTerra
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Pagina de test — lucrari depuse cu documente eliberate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Filtre</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Selecteaza judetul, unitatea administrativ-teritoriala si anul
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="workspaceId">Workspace ID (judet)</Label>
|
||||||
|
<Input
|
||||||
|
id="workspaceId"
|
||||||
|
type="number"
|
||||||
|
value={workspaceId}
|
||||||
|
onChange={(e) => setWorkspaceId(Number(e.target.value))}
|
||||||
|
className="w-32"
|
||||||
|
placeholder="127"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">127 = Cluj</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="orgUnitId">OrgUnit ID (UAT)</Label>
|
||||||
|
<Input
|
||||||
|
id="orgUnitId"
|
||||||
|
type="number"
|
||||||
|
value={orgUnitId}
|
||||||
|
onChange={(e) => setOrgUnitId(Number(e.target.value))}
|
||||||
|
className="w-32"
|
||||||
|
placeholder="127002"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="year">An</Label>
|
||||||
|
<Input
|
||||||
|
id="year"
|
||||||
|
type="text"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(e.target.value)}
|
||||||
|
className="w-24"
|
||||||
|
placeholder="2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={loadApplications} disabled={loading} className="gap-2">
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="size-4" />
|
||||||
|
)}
|
||||||
|
Incarca lucrari
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Future/solved filter toggle */}
|
||||||
|
<div className="flex items-center gap-3 mt-4 pt-4 border-t">
|
||||||
|
<Switch
|
||||||
|
id="futureFilter"
|
||||||
|
checked={onlyFutureSolved}
|
||||||
|
onCheckedChange={setOnlyFutureSolved}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="futureFilter" className="cursor-pointer">
|
||||||
|
Doar solutionate cu termen viitor
|
||||||
|
</Label>
|
||||||
|
{onlyFutureSolved && applications.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{displayedApps.length} / {applications.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertCircle className="size-5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Eroare</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{!loading && applications.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
Lucrari
|
||||||
|
<Badge variant="outline">
|
||||||
|
{displayedApps.length}
|
||||||
|
{totalCount !== null && totalCount !== displayedApps.length
|
||||||
|
? ` / ${totalCount} total`
|
||||||
|
: ""}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
|
<th className="py-2 px-3">Nr. cerere</th>
|
||||||
|
<th className="py-2 px-3">Tip serviciu</th>
|
||||||
|
<th className="py-2 px-3">Deponent</th>
|
||||||
|
<th className="py-2 px-3">Data depunere</th>
|
||||||
|
<th className="py-2 px-3">Termen</th>
|
||||||
|
<th className="py-2 px-3">Status</th>
|
||||||
|
<th className="py-2 px-3">Actiuni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{displayedApps.map((app, i) => {
|
||||||
|
const key = str(app, "applicationId", "id", "pk") || String(i);
|
||||||
|
return (
|
||||||
|
<ApplicationRow
|
||||||
|
key={key}
|
||||||
|
app={app}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayedApps.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
Nicio lucrare nu corespunde filtrelor selectate.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && !error && applications.length === 0 && totalCount === null && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<FileText className="size-10 mx-auto mb-3 opacity-40" />
|
||||||
|
<p>Apasa "Incarca lucrari" pentru a interoga eTerra RGI.</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Asigura-te ca sesiunea eTerra este activa (conecteaza-te din ParcelSync).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state for initial load */}
|
||||||
|
{loading && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||||
|
<Loader2 className="size-8 animate-spin" />
|
||||||
|
<p>Se interogheaza eTerra RGI...</p>
|
||||||
|
<p className="text-xs">Poate dura cateva secunde.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw response debug */}
|
||||||
|
{applications.length > 0 && (
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
Raspuns brut — toate aplicatiile ({applications.length})
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-muted/50 p-4 text-xs">
|
||||||
|
{JSON.stringify(applications.slice(0, 5), null, 2)}
|
||||||
|
{applications.length > 5 && `\n\n... si inca ${applications.length - 5} aplicatii`}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
workspaceId: number;
|
||||||
|
orgUnitId: number;
|
||||||
|
year: string;
|
||||||
|
page?: number;
|
||||||
|
nrElements?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/rgi/applications
|
||||||
|
*
|
||||||
|
* List RGI applications for a given workspace (county) and org unit (UAT).
|
||||||
|
* Proxies eTerra rgi/applicationgrid/list endpoint.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as Body;
|
||||||
|
const { workspaceId, orgUnitId, year } = body;
|
||||||
|
|
||||||
|
if (!workspaceId || !orgUnitId || !year) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "workspaceId, orgUnitId and year are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME ?? "";
|
||||||
|
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credentials missing" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
const page = body.page ?? 0;
|
||||||
|
const nrElements = body.nrElements ?? 25;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
value: workspaceId,
|
||||||
|
type: "NUMBER",
|
||||||
|
key: "workspace.nomenPk",
|
||||||
|
op: "=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: orgUnitId,
|
||||||
|
type: "NUMBER",
|
||||||
|
key: "partyFunctionByOrgUnitId.nomenPk",
|
||||||
|
op: "=",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
applicationFilters: {
|
||||||
|
applicationType: "own",
|
||||||
|
tabCode: "NUMBER_LIST",
|
||||||
|
year,
|
||||||
|
countyId: workspaceId,
|
||||||
|
adminUnitId: orgUnitId,
|
||||||
|
showAll: false,
|
||||||
|
showNewRequests: false,
|
||||||
|
showSuspended: false,
|
||||||
|
showSolutionDeadlineExpired: false,
|
||||||
|
showPendingLimitation: false,
|
||||||
|
showCadastreNumberAllocated: false,
|
||||||
|
showImmovableRegistered: false,
|
||||||
|
showDocumentIssued: false,
|
||||||
|
showRestitutionClosed: false,
|
||||||
|
showRejected: false,
|
||||||
|
showClosed: false,
|
||||||
|
showWithdrawn: false,
|
||||||
|
showSolutionDeadlineExceeded: false,
|
||||||
|
},
|
||||||
|
sorters: [],
|
||||||
|
nrElements,
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await client.rgiPost(
|
||||||
|
"rgi/applicationgrid/list",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/rgi/details?applicationId=...
|
||||||
|
*
|
||||||
|
* Fetch RGI application details by application ID.
|
||||||
|
* Proxies eTerra rgi/appdetail/details endpoint.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
||||||
|
|
||||||
|
if (!applicationId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "applicationId is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME ?? "";
|
||||||
|
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credentials missing" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
const result = await client.rgiPost(
|
||||||
|
`rgi/appdetail/details?applicationid=${encodeURIComponent(applicationId)}`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/rgi/download-doc?workspaceId=...&applicationId=...&docId=...
|
||||||
|
*
|
||||||
|
* Check file visibility and download an issued document from RGI.
|
||||||
|
* Step 1: Calls rgi/appdetail/issueddocs/fileVisibility to check access.
|
||||||
|
* Step 2: Downloads the file via rgi/appdetail/issueddocs/download.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
|
||||||
|
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
||||||
|
const docId = req.nextUrl.searchParams.get("docId");
|
||||||
|
|
||||||
|
if (!workspaceId || !applicationId || !docId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "workspaceId, applicationId and docId are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME ?? "";
|
||||||
|
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credentials missing" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
// Step 1: Check file visibility
|
||||||
|
const visibilityPath = `rgi/appdetail/issueddocs/fileVisibility/${encodeURIComponent(workspaceId)}/${encodeURIComponent(applicationId)}/${encodeURIComponent(docId)}`;
|
||||||
|
const visibility = await client.rgiGet(visibilityPath);
|
||||||
|
|
||||||
|
// Step 2: Attempt to download the file
|
||||||
|
try {
|
||||||
|
const downloadPath = `rgi/appdetail/issueddocs/download/${encodeURIComponent(workspaceId)}/${encodeURIComponent(applicationId)}/${encodeURIComponent(docId)}`;
|
||||||
|
const { data, contentType, filename } =
|
||||||
|
await client.rgiDownload(downloadPath);
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(data), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||||
|
"Content-Length": String(data.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Download failed — return the visibility response instead so the caller
|
||||||
|
// can inspect what eTerra reported (may contain a URL or error details).
|
||||||
|
return NextResponse.json({
|
||||||
|
visibility,
|
||||||
|
downloadFailed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/rgi/issued-docs?applicationId=...&workspaceId=...
|
||||||
|
*
|
||||||
|
* List issued documents for an RGI application.
|
||||||
|
* Proxies eTerra rgi/appdetail/issueddocs/list endpoint.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
||||||
|
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
|
||||||
|
|
||||||
|
if (!applicationId || !workspaceId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "applicationId and workspaceId are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME ?? "";
|
||||||
|
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credentials missing" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
const result = await client.rgiPost(
|
||||||
|
`rgi/appdetail/issueddocs/list?applicationid=${encodeURIComponent(applicationId)}&reSaveDocsInPendingAndTiomeOut=false&workspaceid=${encodeURIComponent(workspaceId)}`,
|
||||||
|
{
|
||||||
|
filters: [],
|
||||||
|
sorters: [],
|
||||||
|
nrElements: 50,
|
||||||
|
page: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -982,6 +982,54 @@ export class EterraClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── RGI (Registrul General de Intrare) API ───────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic RGI POST request with JSON body.
|
||||||
|
* Uses the same JSESSIONID cookie jar as GIS queries.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async rgiPost<T = any>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const url = `${BASE_URL}/api/${path}`;
|
||||||
|
return this.requestRaw(() =>
|
||||||
|
this.client.post(url, body ?? {}, {
|
||||||
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic RGI GET request.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async rgiGet<T = any>(path: string): Promise<T> {
|
||||||
|
const url = `${BASE_URL}/api/${path}`;
|
||||||
|
return this.requestRaw(() =>
|
||||||
|
this.client.get(url, { timeout: this.timeoutMs }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from RGI (returns Buffer).
|
||||||
|
*/
|
||||||
|
async rgiDownload(path: string): Promise<{ data: Buffer; contentType: string; filename: string }> {
|
||||||
|
const url = `${BASE_URL}/api/${path}`;
|
||||||
|
const response = await this.requestWithRetry(() =>
|
||||||
|
this.client.get(url, {
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cd = (response as { headers?: Record<string, string> }).headers?.["content-disposition"] ?? "";
|
||||||
|
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||||
|
return {
|
||||||
|
data: Buffer.from(response.data as ArrayBuffer),
|
||||||
|
contentType: (response as { headers?: Record<string, string> }).headers?.["content-type"] ?? "application/octet-stream",
|
||||||
|
filename: match?.[1] ?? "document.pdf",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async requestWithRetry<T>(request: () => Promise<T>) {
|
private async requestWithRetry<T>(request: () => Promise<T>) {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
Reference in New Issue
Block a user