feat(rgi): download all docs button + tooltips on status icon
Download all: - "Descarca toate" button in expanded docs panel - Downloads each document sequentially with correct filename (e.g. Receptie_tehnica_66903_10183217654.pdf) - Progress indicator: "2/5: Harti & planuri..." - Skips blocked docs, shows summary "3 descarcate, 2 indisponibile" - 300ms delay between downloads to avoid browser blocking Status icon tooltip: - Hover on green/clock icon shows: Nr cerere, Obiect, Status, Rezolutie, Termen, Identificatori Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,14 @@ import {
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -278,6 +285,22 @@ function matchesSearch(text: string, query: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -295,6 +318,8 @@ function IssuedDocsPanel({
|
||||
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(() => {
|
||||
@@ -326,6 +351,58 @@ function IssuedDocsPanel({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownloadAll = useCallback(async () => {
|
||||
if (!docs || docs.length === 0 || downloadingAll) return;
|
||||
setDownloadingAll(true);
|
||||
let downloaded = 0;
|
||||
let blocked = 0;
|
||||
|
||||
for (const doc of docs) {
|
||||
const docName = sanitize(doc.docType || doc.documentTypeCode || "Document");
|
||||
const ext = (doc.fileExtension || "pdf").toLowerCase();
|
||||
const filename = `${docName}_${appNo}_${doc.documentPk}.${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();
|
||||
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);
|
||||
downloaded++;
|
||||
// Small delay between downloads so browser doesn't block them
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
} catch {
|
||||
blocked++;
|
||||
}
|
||||
}
|
||||
|
||||
setDownloadProgress(
|
||||
blocked > 0
|
||||
? `${downloaded} descarcat${downloaded !== 1 ? "e" : ""}, ${blocked} indisponibil${blocked !== 1 ? "e" : ""}`
|
||||
: `${downloaded} document${downloaded !== 1 ? "e" : ""} descarcat${downloaded !== 1 ? "e" : ""}`,
|
||||
);
|
||||
setDownloadingAll(false);
|
||||
setTimeout(() => setDownloadProgress(""), 5000);
|
||||
}, [docs, downloadingAll, workspaceId, applicationPk, appNo]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (doc: IssuedDoc, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -399,10 +476,31 @@ function IssuedDocsPanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 py-2">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-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" : ""}:
|
||||
{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">
|
||||
@@ -795,15 +893,38 @@ export default function RgiTestPage() {
|
||||
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">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Download all docs for this app
|
||||
setExpandedPk(pk);
|
||||
}}
|
||||
>
|
||||
{solved ? (
|
||||
<CheckCircle2 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">Nr. {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
|
||||
|
||||
Reference in New Issue
Block a user