feat(tender-details): add documents section and enhance document handling
- Introduced a new documents section in the tender details page for better organization of related documents. - Updated the API service to support downloading individual and bulk documents. - Enhanced the TenderDetailsContent component to manage document data and loading states effectively. - Refactored tender navigation items to include the new documents section. - Improved type definitions for better clarity and type safety in document handling.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import type {
|
||||
TTenderDetails,
|
||||
TTenderDocumentsResponse,
|
||||
} from "@/lib/api/types/Tenders";
|
||||
import { shouldShowLocationSection } from "../tender-nav-utils";
|
||||
import type { TenderNavItem } from "../types";
|
||||
import { scrollToTenderSection } from "../scroll-to-section";
|
||||
import { TenderAiSummarySection } from "./tender-ai-summary-section";
|
||||
import { TenderDescriptionSection } from "./tender-description-section";
|
||||
import { TenderDocumentsSection } from "./tender-documents-section";
|
||||
import { TenderLocationSection } from "./tender-location-section";
|
||||
import { TenderProcurementSection } from "./tender-procurement-section";
|
||||
import { TenderSectionNavList } from "./tender-section-nav-list";
|
||||
@@ -14,12 +18,18 @@ type TenderDetailsContentProps = {
|
||||
tender: TTenderDetails;
|
||||
tenderNavItems: TenderNavItem[];
|
||||
activeSectionId: string;
|
||||
tenderId: string;
|
||||
documents: TTenderDocumentsResponse[];
|
||||
documentsLoading: boolean;
|
||||
};
|
||||
|
||||
export function TenderDetailsContent({
|
||||
tender,
|
||||
tenderNavItems,
|
||||
activeSectionId,
|
||||
tenderId,
|
||||
documents,
|
||||
documentsLoading,
|
||||
}: TenderDetailsContentProps) {
|
||||
const showLocation = shouldShowLocationSection(tender);
|
||||
|
||||
@@ -44,6 +54,11 @@ export function TenderDetailsContent({
|
||||
{showLocation && <TenderLocationSection tender={tender} />}
|
||||
|
||||
<TenderTranslationSection tender={tender} />
|
||||
<TenderDocumentsSection
|
||||
tenderId={tenderId}
|
||||
documents={documents}
|
||||
isLoading={documentsLoading}
|
||||
/>
|
||||
<TenderDescriptionSection tender={tender} />
|
||||
<TenderAiSummarySection tender={tender} />
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@ export function TenderDetailsHeader({
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
shape="rounded"
|
||||
shape="full"
|
||||
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
||||
label={
|
||||
isTranslating ? "Translating..." : "Translate metadata"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon } from "@/components/Tables/icons";
|
||||
import { tendersService } from "@/lib/api/services/tenders-service";
|
||||
import type { TTenderDocumentsResponse } from "@/lib/api/types/Tenders";
|
||||
import {
|
||||
formatFileSize,
|
||||
saveBlobFromAxiosResponse,
|
||||
unixToDate,
|
||||
} from "@/utils/shared";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderDocumentsSectionProps = {
|
||||
tenderId: string;
|
||||
documents: TTenderDocumentsResponse[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const DOWNLOAD_STAGGER_MS = 400;
|
||||
|
||||
function headerContentType(response: AxiosResponse<Blob>) {
|
||||
const raw = response.headers["content-type"];
|
||||
return typeof raw === "string" ? raw : String(raw ?? "");
|
||||
}
|
||||
|
||||
async function assertBlobNotJsonError(response: AxiosResponse<Blob>) {
|
||||
const ct = headerContentType(response).toLowerCase();
|
||||
if (!ct.includes("application/json")) return;
|
||||
const text = await response.data.text();
|
||||
let message = "Download failed";
|
||||
try {
|
||||
const body = JSON.parse(text) as { message?: string };
|
||||
if (body?.message) message = body.message;
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export function TenderDocumentsSection({
|
||||
tenderId,
|
||||
documents,
|
||||
isLoading,
|
||||
}: TenderDocumentsSectionProps) {
|
||||
const [downloadingName, setDownloadingName] = useState<string | null>(null);
|
||||
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
|
||||
|
||||
const fetchDocument = useCallback(
|
||||
async (filename: string) => {
|
||||
const res = await tendersService.downloadTenderDocument(
|
||||
tenderId,
|
||||
filename,
|
||||
);
|
||||
await assertBlobNotJsonError(res);
|
||||
return res;
|
||||
},
|
||||
[tenderId],
|
||||
);
|
||||
|
||||
const downloadOne = useCallback(
|
||||
async (filename: string) => {
|
||||
setDownloadingName(filename);
|
||||
try {
|
||||
const res = await fetchDocument(filename);
|
||||
saveBlobFromAxiosResponse(res, filename);
|
||||
toast.success(`Downloaded ${filename}`);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Download failed";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setDownloadingName(null);
|
||||
}
|
||||
},
|
||||
[fetchDocument],
|
||||
);
|
||||
|
||||
const downloadAllSequential = useCallback(async () => {
|
||||
let ok = 0;
|
||||
for (let i = 0; i < documents.length; i++) {
|
||||
const doc = documents[i];
|
||||
setDownloadingName(doc.filename);
|
||||
try {
|
||||
const res = await fetchDocument(doc.filename);
|
||||
saveBlobFromAxiosResponse(res, doc.filename);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Download failed";
|
||||
toast.error(`${doc.filename}: ${message}`);
|
||||
} finally {
|
||||
setDownloadingName(null);
|
||||
}
|
||||
if (i < documents.length - 1) {
|
||||
await new Promise((r) => setTimeout(r, DOWNLOAD_STAGGER_MS));
|
||||
}
|
||||
}
|
||||
if (ok > 0) {
|
||||
toast.success(
|
||||
ok === documents.length
|
||||
? `Downloaded all ${ok} files`
|
||||
: `Downloaded ${ok} of ${documents.length} files`,
|
||||
);
|
||||
}
|
||||
}, [documents, fetchDocument]);
|
||||
|
||||
const handleDownloadAll = useCallback(async () => {
|
||||
if (documents.length === 0) return;
|
||||
setIsDownloadingAll(true);
|
||||
try {
|
||||
const res = await tendersService.downloadTenderDocumentsArchive(tenderId);
|
||||
const ct = headerContentType(res).toLowerCase();
|
||||
if (!ct.includes("application/json")) {
|
||||
saveBlobFromAxiosResponse(res, `tender-${tenderId}-documents.zip`);
|
||||
toast.success("Archive downloaded");
|
||||
return;
|
||||
}
|
||||
await res.data.text();
|
||||
await downloadAllSequential();
|
||||
} catch {
|
||||
await downloadAllSequential();
|
||||
} finally {
|
||||
setIsDownloadingAll(false);
|
||||
}
|
||||
}, [documents, downloadAllSequential, tenderId]);
|
||||
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.documents} className={HEADER_SCROLL_MARGIN}>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SectionHeading>Documents</SectionHeading>
|
||||
</div>
|
||||
{documents.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDownloadingAll || Boolean(downloadingName)}
|
||||
onClick={() => void handleDownloadAll()}
|
||||
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl border border-primary/35 bg-primary/10 px-4 py-2.5 text-sm font-semibold text-primary transition hover:bg-primary/15 disabled:cursor-not-allowed disabled:opacity-50 dark:border-primary/45 dark:bg-primary/15 dark:text-white dark:hover:bg-primary/25"
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4 shrink-0" />
|
||||
{isDownloadingAll ? "Downloading…" : "Download all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||
{isLoading && (
|
||||
<div className="space-y-3 p-6">
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-stroke/80 dark:bg-dark-3" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-stroke/80 dark:bg-dark-3" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-stroke/80 dark:bg-dark-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && documents.length === 0 && (
|
||||
<p className="p-6 text-sm text-dark-5 dark:text-dark-6">
|
||||
No documents are available for this tender.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && documents.length > 0 && (
|
||||
<ul className="divide-y divide-stroke/60 dark:divide-dark-3">
|
||||
{documents.map((doc) => {
|
||||
const busy =
|
||||
downloadingName === doc.filename ||
|
||||
(isDownloadingAll && downloadingName === doc.filename);
|
||||
return (
|
||||
<li
|
||||
key={doc.filename}
|
||||
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="truncate text-sm font-medium text-dark dark:text-white">
|
||||
{doc.filename}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-dark-5 dark:text-dark-6">
|
||||
<span className="rounded-md bg-white/80 px-2 py-0.5 font-medium uppercase tracking-wide text-primary dark:bg-dark-2/80 dark:text-primary">
|
||||
{doc.document_type}
|
||||
</span>
|
||||
<span>{formatFileSize(doc.size)}</span>
|
||||
<span>
|
||||
Updated{" "}
|
||||
{unixToDate({
|
||||
unix: doc.last_modified,
|
||||
hasTime: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
Boolean(downloadingName) || isDownloadingAll || busy
|
||||
}
|
||||
onClick={() => void downloadOne(doc.filename)}
|
||||
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-stroke/70 bg-white px-3 py-2 text-sm font-medium text-dark shadow-sm transition hover:border-primary/40 hover:text-primary disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-3 dark:bg-dark-2 dark:text-gray-5 dark:hover:border-primary/40 dark:hover:text-white"
|
||||
aria-label={`Download ${doc.filename}`}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{busy ? "…" : "Download"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const TENDER_SECTION_IDS = {
|
||||
timeline: "tender-section-timeline",
|
||||
location: "tender-section-location",
|
||||
translation: "tender-section-translation",
|
||||
documents: "tender-section-documents",
|
||||
description: "tender-section-description",
|
||||
aiSummary: "tender-section-ai-summary",
|
||||
} as const;
|
||||
|
||||
@@ -51,7 +51,8 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
[tenderNavItems],
|
||||
);
|
||||
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
|
||||
const { data: documents } = useGetTenderDocumentsQuery(details);
|
||||
const { data: documentsResponse, isPending: isDocumentsLoading } =
|
||||
useGetTenderDocumentsQuery(details);
|
||||
const handleLanguageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
@@ -106,6 +107,9 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
tender={tender}
|
||||
tenderNavItems={tenderNavItems}
|
||||
activeSectionId={activeSectionId}
|
||||
tenderId={details}
|
||||
documents={documentsResponse?.data ?? []}
|
||||
documentsLoading={isDocumentsLoading}
|
||||
/>
|
||||
</ShowcaseSection>
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,7 @@ export function getTenderNavItems(tender: TTenderDetails): TenderNavItem[] {
|
||||
}
|
||||
items.push(
|
||||
{ id: TENDER_SECTION_IDS.translation, label: "Translation" },
|
||||
{ id: TENDER_SECTION_IDS.documents, label: "Documents" },
|
||||
{ id: TENDER_SECTION_IDS.description, label: "Description" },
|
||||
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
|
||||
);
|
||||
|
||||
@@ -60,4 +60,17 @@ export const tendersService = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** Single file — backend expects `filename` matching the list entry. */
|
||||
downloadTenderDocument: (tenderId: string, filename: string) =>
|
||||
api.get<Blob>(API_ENDPOINTS.TENDERS.DOCUMENTS_DOWNLOAD(tenderId), {
|
||||
params: { filename },
|
||||
responseType: "blob",
|
||||
}),
|
||||
|
||||
/** Optional bulk archive when the API returns a zip without extra params. */
|
||||
downloadTenderDocumentsArchive: (tenderId: string) =>
|
||||
api.get<Blob>(API_ENDPOINTS.TENDERS.DOCUMENTS_DOWNLOAD(tenderId), {
|
||||
responseType: "blob",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export const TenderDocumentsResponseSchema = z.object({
|
||||
size: z.number(),
|
||||
last_modified: z.number(),
|
||||
document_type: z.string(),
|
||||
})
|
||||
});
|
||||
export const TenderTranslationResponseSchema = z.object({
|
||||
notice_id: z.string(),
|
||||
language: z.string(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ILabelValue } from "@/types/shared";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import * as moment from "moment";
|
||||
import { z } from "zod";
|
||||
export const unixToDate = ({
|
||||
@@ -161,3 +162,34 @@ export const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** Saves a blob from an axios GET with `responseType: "blob"` (uses Content-Disposition when present). */
|
||||
export const saveBlobFromAxiosResponse = (
|
||||
response: AxiosResponse<Blob>,
|
||||
fallbackFilename: string,
|
||||
) => {
|
||||
const disposition = response.headers["content-disposition"] as
|
||||
| string
|
||||
| undefined;
|
||||
let filename = fallbackFilename;
|
||||
if (disposition) {
|
||||
const utf8Match = /filename\*=UTF-8''([^;\n]+)/i.exec(disposition);
|
||||
const asciiMatch = /filename="([^"]+)"/i.exec(disposition);
|
||||
const raw = utf8Match?.[1] ?? asciiMatch?.[1];
|
||||
if (raw) {
|
||||
try {
|
||||
filename = decodeURIComponent(raw.replace(/['"]/g, ""));
|
||||
} catch {
|
||||
filename = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
Reference in New Issue
Block a user