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,
|
ArrowUpDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
Archive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { cn } from "@/shared/lib/utils";
|
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 */
|
/* Issued Documents panel */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -295,6 +318,8 @@ function IssuedDocsPanel({
|
|||||||
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 [blockedDocPk, setBlockedDocPk] = useState<number | null>(null);
|
||||||
|
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState("");
|
||||||
const blockedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const blockedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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(
|
const handleDownload = useCallback(
|
||||||
async (doc: IssuedDoc, e: React.MouseEvent) => {
|
async (doc: IssuedDoc, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -399,10 +476,31 @@ function IssuedDocsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5 py-2">
|
<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} document{docs.length > 1 ? "e" : ""} eliberat
|
||||||
{docs.length > 1 ? "e" : ""}:
|
{docs.length > 1 ? "e" : ""}
|
||||||
</p>
|
</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) => (
|
{docs.map((doc, i) => (
|
||||||
<div key={doc.documentPk || i} className="space-y-0">
|
<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 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)
|
setExpandedPk(isExpanded ? null : pk)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td
|
<td className="px-2 py-2.5 w-8">
|
||||||
className="px-2 py-2.5 w-8"
|
<TooltipProvider>
|
||||||
title={solved ? "Solutionata" : "In lucru"}
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus:outline-none"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Download all docs for this app
|
||||||
|
setExpandedPk(pk);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{solved ? (
|
{solved ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
) : (
|
) : (
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
<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>
|
</td>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
|
|||||||
Reference in New Issue
Block a user