Files
ArchiTools/src/app/(modules)/rgi-test/page.tsx
T
AI Assistant 12ff629fbf 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>
2026-03-25 09:17:29 +02:00

1087 lines
37 KiB
TypeScript

"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";
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,
ChevronUp,
Download,
Search,
FileText,
CheckCircle2,
Clock,
AlertTriangle,
Settings2,
Shield,
ArrowUpDown,
ArrowUp,
ArrowDown,
Archive,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/lib/utils";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type App = {
actorName: string;
adminUnit: number;
appDate: number;
appNo: number;
applicationObject: string;
applicationPk: number;
colorNumber: number;
communicationType: string;
deponent: string;
dueDate: number;
hasSolution: number;
identifiers: string;
initialAppNo: string;
orgUnit: string;
requester: string;
resolutionName: string;
stateCode: string;
statusName: string;
totalFee: number;
uat: string;
workspace: string;
workspaceId: number;
[key: string]: unknown;
};
type IssuedDoc = {
applicationId: number;
docType: string;
documentPk: number;
documentTypeCode: string;
documentTypeId: number;
fileExtension: string;
digitallySigned: number;
startDate: number;
lastUpdatedDtm: number;
initialAppNo: string;
workspaceId: number;
identifierDetails: string | null;
[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 */
/* ------------------------------------------------------------------ */
type ColumnDef = {
key: string;
label: string;
defaultVisible: boolean;
render: (app: App) => string;
className?: string;
};
function fmtTs(ts: number | null | undefined): string {
if (!ts) return "-";
const d = new Date(ts);
if (isNaN(d.getTime())) return "-";
return d.toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
const ALL_COLUMNS: ColumnDef[] = [
{
key: "appNo",
label: "Nr. cerere",
defaultVisible: true,
render: (a) => String(a.appNo ?? "-"),
className: "font-mono font-semibold",
},
{
key: "initialAppNo",
label: "Nr. initial",
defaultVisible: false,
render: (a) => a.initialAppNo || "-",
className: "font-mono text-xs",
},
{
key: "applicationObject",
label: "Obiect",
defaultVisible: false,
render: (a) => a.applicationObject || "-",
},
{
key: "identifiers",
label: "Identificatori (IE/CF)",
defaultVisible: false,
render: (a) => a.identifiers || "-",
className: "text-xs max-w-[300px] truncate",
},
{
key: "deponent",
label: "Deponent",
defaultVisible: false,
render: (a) => a.deponent || "-",
},
{
key: "requester",
label: "Solicitant",
defaultVisible: true,
render: (a) => a.requester || "-",
},
{
key: "appDate",
label: "Data depunere",
defaultVisible: false,
render: (a) => fmtTs(a.appDate),
className: "tabular-nums",
},
{
key: "dueDate",
label: "Termen",
defaultVisible: true,
render: (a) => fmtTs(a.dueDate),
className: "tabular-nums",
},
{
key: "statusName",
label: "Status",
defaultVisible: true,
render: (a) => a.statusName || a.stateCode || "-",
},
{
key: "resolutionName",
label: "Rezolutie",
defaultVisible: true,
render: (a) => a.resolutionName || "-",
},
{
key: "hasSolution",
label: "Solutionat",
defaultVisible: false,
render: (a) => (a.hasSolution === 1 ? "DA" : "NU"),
},
{
key: "totalFee",
label: "Taxa (lei)",
defaultVisible: false,
render: (a) => (a.totalFee != null ? String(a.totalFee) : "-"),
className: "tabular-nums",
},
{
key: "uat",
label: "UAT",
defaultVisible: true,
render: (a) => a.uat || "-",
},
{
key: "orgUnit",
label: "OCPI",
defaultVisible: false,
render: (a) => a.orgUnit || "-",
},
{
key: "communicationType",
label: "Comunicare",
defaultVisible: false,
render: (a) => a.communicationType || "-",
className: "text-xs",
},
{
key: "actorName",
label: "Actor curent",
defaultVisible: false,
render: (a) => a.actorName || "-",
},
{
key: "applicationPk",
label: "Application PK",
defaultVisible: false,
render: (a) => String(a.applicationPk ?? "-"),
className: "font-mono text-xs",
},
];
/* ------------------------------------------------------------------ */
/* 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()),
);
}
/* ------------------------------------------------------------------ */
/* Filename sanitizer (client-side) */
/* ------------------------------------------------------------------ */
function sanitize(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, "");
}
/* ------------------------------------------------------------------ */
/* Issued Documents panel */
/* ------------------------------------------------------------------ */
function IssuedDocsPanel({
applicationPk,
workspaceId,
appNo,
}: {
applicationPk: number;
workspaceId: number;
appNo: number;
}) {
const [docs, setDocs] = useState<IssuedDoc[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [blockedDocPk, setBlockedDocPk] = useState<number | null>(null);
const [downloadingAll, setDownloadingAll] = useState(false);
const [downloadProgress, setDownloadProgress] = useState("");
const blockedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(
`/api/eterra/rgi/issued-docs?applicationId=${applicationPk}&workspaceId=${workspaceId}`,
);
const data = await res.json();
const items: IssuedDoc[] = Array.isArray(data)
? data
: data?.content ?? data?.data ?? data?.list ?? [];
if (!cancelled) setDocs(items);
} catch {
if (!cancelled) setError("Eroare la incarcarea documentelor");
}
if (!cancelled) setLoading(false);
})();
return () => {
cancelled = true;
};
}, [applicationPk, workspaceId]);
// Cleanup blocked timer on unmount
useEffect(() => {
return () => {
if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current);
};
}, []);
const handleDownloadAll = useCallback(async () => {
if (!docs || docs.length === 0 || downloadingAll) return;
setDownloadingAll(true);
const zip = new JSZip();
let downloaded = 0;
let blocked = 0;
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> = {};
for (const doc of docs) {
const docName = sanitize(doc.docType || doc.documentTypeCode || "Document");
const ext = (doc.fileExtension || "pdf").toLowerCase();
const typeKey = doc.docType || "Document";
typeIdx[typeKey] = (typeIdx[typeKey] || 0) + 1;
const suffix = (typeCounts[typeKey] ?? 0) > 1 ? `_${typeIdx[typeKey]}` : "";
const filename = `${docName}_${appNo}${suffix}.${ext}`;
setDownloadProgress(`${downloaded + blocked + 1}/${docs.length}: ${doc.docType || "Document"}...`);
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 || "")}` +
`&appNo=${appNo}`;
try {
const res = await fetch(url);
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) { blocked++; continue; }
const blob = await res.blob();
zip.file(filename, blob);
downloaded++;
} 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} in ZIP, ${blocked} indisponibil${blocked !== 1 ? "e" : ""}`
: `ZIP descarcat cu ${downloaded} document${downloaded !== 1 ? "e" : ""}`,
);
setDownloadingAll(false);
setTimeout(() => setDownloadProgress(""), 5000);
}, [docs, downloadingAll, workspaceId, applicationPk, appNo]);
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 (
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Se incarca documentele...
</div>
);
}
if (error) {
return <p className="py-3 text-sm text-destructive">{error}</p>;
}
if (!docs || docs.length === 0) {
return (
<p className="py-3 text-sm text-muted-foreground">
Niciun document eliberat.
</p>
);
}
return (
<div className="space-y-1.5 py-2">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground">
{docs.length} document{docs.length > 1 ? "e" : ""} eliberat
{docs.length > 1 ? "e" : ""}
</p>
<div className="flex items-center gap-2">
{downloadProgress && (
<span className="text-[11px] text-muted-foreground">{downloadProgress}</span>
)}
<Button
size="sm"
variant="default"
className="gap-1 h-7 text-xs"
disabled={downloadingAll}
onClick={(e) => { e.stopPropagation(); void handleDownloadAll(); }}
>
{downloadingAll ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Archive className="h-3 w-3" />
)}
Descarca toate
</Button>
</div>
</div>
{docs.map((doc, i) => (
<div key={doc.documentPk || i} className="space-y-0">
<div 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">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">
{doc.docType || doc.documentTypeCode || "Document"}
</p>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{fmtTs(doc.startDate || doc.lastUpdatedDtm)}</span>
<span className="font-mono opacity-60">
.{(doc.fileExtension || "PDF").toLowerCase()}
</span>
{doc.digitallySigned === 1 && (
<span className="inline-flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400">
<Shield className="h-3 w-3" />
semnat
</span>
)}
{doc.identifierDetails && (
<span className="truncate max-w-[200px]">
{doc.identifierDetails}
</span>
)}
</div>
</div>
</div>
<Button
size="sm"
variant="outline"
className="gap-1 shrink-0"
onClick={(e) => void handleDownload(doc, e)}
>
<Download className="h-3.5 w-3.5" />
Descarca
</Button>
</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>
);
}
/* ------------------------------------------------------------------ */
/* Main page */
/* ------------------------------------------------------------------ */
export default function RgiTestPage() {
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<App[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [expandedPk, setExpandedPk] = useState<number | null>(null);
const [showColumnPicker, setShowColumnPicker] = useState(false);
const [downloadingAppPk, setDownloadingAppPk] = useState<number | null>(null);
const [filterMode, setFilterMode] = useState<"all" | "solved" | "confirmed">(
"solved",
);
const [searchQuery, setSearchQuery] = useState("");
const [sortState, setSortState] = useState<SortState>({ key: "dueDate", dir: "desc" });
// Column visibility
const [visibleCols, setVisibleCols] = useState<Set<string>>(
() => new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key)),
);
const toggleColumn = (key: string) => {
setVisibleCols((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const columns = useMemo(
() => ALL_COLUMNS.filter((c) => visibleCols.has(c.key)),
[visibleCols],
);
const downloadAllForApp = useCallback(async (app: App) => {
if (downloadingAppPk) return;
setDownloadingAppPk(app.applicationPk);
try {
// Fetch issued docs
const res = await fetch(
`/api/eterra/rgi/issued-docs?applicationId=${app.applicationPk}&workspaceId=${app.workspaceId}`,
);
const data = await res.json();
const docs: IssuedDoc[] = Array.isArray(data)
? data
: data?.content ?? data?.data ?? data?.list ?? [];
if (docs.length === 0) {
setDownloadingAppPk(null);
return;
}
// Count by type for dedup naming
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> = {};
for (const doc of docs) {
const docName = sanitize(doc.docType || doc.documentTypeCode || "Document");
const ext = (doc.fileExtension || "pdf").toLowerCase();
const typeKey = doc.docType || "Document";
typeIdx[typeKey] = (typeIdx[typeKey] || 0) + 1;
const suffix = (typeCounts[typeKey] ?? 0) > 1 ? `_${typeIdx[typeKey]}` : "";
const filename = `${docName}_${app.appNo}${suffix}.${ext}`;
const url =
`/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || app.workspaceId}` +
`&applicationId=${doc.applicationId || app.applicationPk}` +
`&documentPk=${doc.documentPk}` +
`&documentTypeId=${doc.documentTypeId}` +
`&docType=${encodeURIComponent(doc.docType || "")}` +
`&appNo=${app.appNo}`;
try {
const r = await fetch(url);
const ct = r.headers.get("content-type") || "";
if (ct.includes("application/json")) continue; // blocked
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));
} catch {
// skip
}
}
} catch {
// silent
}
setDownloadingAppPk(null);
}, [downloadingAppPk]);
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("");
setApplications([]);
setExpandedPk(null);
try {
const res = await fetch("/api/eterra/rgi/applications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId: countyId,
orgUnitId,
year,
page: 0,
nrElements: 200,
}),
});
const data = await res.json();
if (data.error) {
setError(data.error);
return;
}
const items: App[] = Array.isArray(data)
? data
: data?.content ?? data?.data ?? data?.list ?? [];
setApplications(items);
setTotalCount(
typeof data?.totalElements === "number"
? data.totalElements
: items.length,
);
} catch {
setError("Eroare de retea. Verifica conexiunea la eTerra.");
}
setLoading(false);
}, [countyId, orgUnitId, year]);
// 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");
}
// 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;
// Use raw timestamps for date columns
const dateKeys = new Set(["dueDate", "appDate"]);
result = [...result].sort((a, b) => {
if (dateKeys.has(sortState.key)) {
const va = (a[sortState.key] as number) || 0;
const vb = (b[sortState.key] as number) || 0;
return (va - vb) * dir;
}
const va = col.render(a);
const vb = col.render(b);
const na = parseFloat(va);
const nb = parseFloat(vb);
if (!isNaN(na) && !isNaN(nb)) return (na - nb) * dir;
return va.localeCompare(vb, "ro") * dir;
});
}
}
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 (
<div className="space-y-4 max-w-[1400px] mx-auto">
{/* Header */}
<div>
<h1 className="text-xl font-bold">Documente Eliberate eTerra</h1>
<p className="text-sm text-muted-foreground">
Lucrari depuse cu documente eliberate descarca direct din eTerra RGI
</p>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-4 space-y-3">
<div className="flex items-end gap-3 flex-wrap">
<div className="space-y-1">
<Label className="text-xs">Judet</Label>
<Select
value={String(countyId)}
onValueChange={(v) => setCountyId(parseInt(v, 10))}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Alege judetul" />
</SelectTrigger>
<SelectContent>
{COUNTIES.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">An</Label>
<Input
value={year}
onChange={(e) => setYear(e.target.value)}
className="w-20"
/>
</div>
<Button onClick={() => void loadApplications()} disabled={loading}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Incarca lucrari
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => setShowColumnPicker(!showColumnPicker)}
>
<Settings2 className="h-3.5 w-3.5" />
Coloane
</Button>
</div>
{/* Column picker */}
{showColumnPicker && (
<div className="flex flex-wrap gap-1.5 pt-2 border-t">
{ALL_COLUMNS.map((col) => (
<button
key={col.key}
onClick={() => toggleColumn(col.key)}
className={cn(
"px-2 py-1 rounded text-[11px] border transition-colors",
visibleCols.has(col.key)
? "bg-foreground/10 border-foreground/20 font-medium"
: "bg-transparent border-muted-foreground/15 text-muted-foreground hover:border-foreground/20",
)}
>
{col.label}
</button>
))}
</div>
)}
{/* Filter toggle + search */}
<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">
{(
[
{
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) => (
<button
key={opt.id}
onClick={() => setFilterMode(opt.id)}
className={cn(
"px-3 py-1 text-xs rounded font-medium transition-colors",
filterMode === opt.id
? "bg-background shadow text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
title={opt.desc}
>
{opt.label}
</button>
))}
</div>
{applications.length > 0 && (
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="relative flex-1 max-w-xs">
<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>
</div>
)}
</div>
</CardContent>
</Card>
{/* Error */}
{error && (
<Card className="border-destructive">
<CardContent className="py-3 flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</CardContent>
</Card>
)}
{/* Loading */}
{loading && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
<p>Se incarca lucrarile din eTerra RGI...</p>
</CardContent>
</Card>
)}
{/* Results table */}
{!loading && processed.length > 0 && (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-2 py-2 w-8"></th>
{columns.map((col) => (
<th
key={col.key}
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}
<SortIcon colKey={col.key} />
</span>
</th>
))}
<th className="px-2 py-2 w-8"></th>
</tr>
</thead>
<tbody>
{processed.map((app) => {
const pk = app.applicationPk;
const isExpanded = expandedPk === pk;
const solved = app.hasSolution === 1;
return (
<React.Fragment key={pk}>
<tr
className={cn(
"border-b cursor-pointer hover:bg-muted/30 transition-colors",
isExpanded && "bg-muted/20",
)}
onClick={() =>
setExpandedPk(isExpanded ? null : pk)
}
>
<td className="px-2 py-2.5 w-8">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="focus:outline-none"
onClick={(e) => {
e.stopPropagation();
void downloadAllForApp(app);
}}
disabled={downloadingAppPk === pk}
>
{downloadingAppPk === pk ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
) : solved ? (
<Download className="h-4 w-4 text-emerald-500" />
) : (
<Clock className="h-4 w-4 text-muted-foreground" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-xs">
<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>
<p>Termen: {fmtTs(app.dueDate)}</p>
{app.identifiers && (
<p className="truncate max-w-[250px]">{app.identifiers}</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
{columns.map((col) => (
<td
key={col.key}
className={cn(
"px-3 py-2.5 text-sm",
col.className,
)}
title={col.render(app)}
>
{col.key === "statusName" ? (
<Badge
variant={solved ? "default" : "secondary"}
className={cn(
"text-[10px]",
solved &&
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400",
)}
>
{col.render(app)}
</Badge>
) : col.key === "resolutionName" ? (
<Badge
variant="outline"
className="text-[10px]"
>
{col.render(app)}
</Badge>
) : (
col.render(app)
)}
</td>
))}
<td className="px-2 py-2.5 w-8">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</td>
</tr>
{isExpanded && (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 pb-3 bg-muted/10 border-b"
>
<IssuedDocsPanel
applicationPk={pk}
workspaceId={app.workspaceId}
appNo={app.appNo}
/>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Empty states */}
{!loading && applications.length > 0 && processed.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p>Nicio lucrare gasita pentru filtrul selectat.</p>
<p className="text-xs mt-1">
Schimba filtrul sau termenul de cautare.
</p>
</CardContent>
</Card>
)}
{!loading && applications.length === 0 && !error && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<Search className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p>Apasa &quot;Incarca lucrari&quot; pentru a incepe.</p>
</CardContent>
</Card>
)}
</div>
);
}