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:
AmirReza Jamali
2026-05-11 17:41:38 +03:30
parent 88bc939956
commit 2477215fce
9 changed files with 282 additions and 4 deletions
@@ -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 { shouldShowLocationSection } from "../tender-nav-utils";
import type { TenderNavItem } from "../types"; import type { TenderNavItem } from "../types";
import { scrollToTenderSection } from "../scroll-to-section"; import { scrollToTenderSection } from "../scroll-to-section";
import { TenderAiSummarySection } from "./tender-ai-summary-section"; import { TenderAiSummarySection } from "./tender-ai-summary-section";
import { TenderDescriptionSection } from "./tender-description-section"; import { TenderDescriptionSection } from "./tender-description-section";
import { TenderDocumentsSection } from "./tender-documents-section";
import { TenderLocationSection } from "./tender-location-section"; import { TenderLocationSection } from "./tender-location-section";
import { TenderProcurementSection } from "./tender-procurement-section"; import { TenderProcurementSection } from "./tender-procurement-section";
import { TenderSectionNavList } from "./tender-section-nav-list"; import { TenderSectionNavList } from "./tender-section-nav-list";
@@ -14,12 +18,18 @@ type TenderDetailsContentProps = {
tender: TTenderDetails; tender: TTenderDetails;
tenderNavItems: TenderNavItem[]; tenderNavItems: TenderNavItem[];
activeSectionId: string; activeSectionId: string;
tenderId: string;
documents: TTenderDocumentsResponse[];
documentsLoading: boolean;
}; };
export function TenderDetailsContent({ export function TenderDetailsContent({
tender, tender,
tenderNavItems, tenderNavItems,
activeSectionId, activeSectionId,
tenderId,
documents,
documentsLoading,
}: TenderDetailsContentProps) { }: TenderDetailsContentProps) {
const showLocation = shouldShowLocationSection(tender); const showLocation = shouldShowLocationSection(tender);
@@ -44,6 +54,11 @@ export function TenderDetailsContent({
{showLocation && <TenderLocationSection tender={tender} />} {showLocation && <TenderLocationSection tender={tender} />}
<TenderTranslationSection tender={tender} /> <TenderTranslationSection tender={tender} />
<TenderDocumentsSection
tenderId={tenderId}
documents={documents}
isLoading={documentsLoading}
/>
<TenderDescriptionSection tender={tender} /> <TenderDescriptionSection tender={tender} />
<TenderAiSummarySection tender={tender} /> <TenderAiSummarySection tender={tender} />
</div> </div>
@@ -106,7 +106,7 @@ export function TenderDetailsHeader({
<Button <Button
type="button" type="button"
size="small" size="small"
shape="rounded" shape="full"
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto" className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
label={ label={
isTranslating ? "Translating..." : "Translate metadata" 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>
);
}
+1
View File
@@ -10,6 +10,7 @@ export const TENDER_SECTION_IDS = {
timeline: "tender-section-timeline", timeline: "tender-section-timeline",
location: "tender-section-location", location: "tender-section-location",
translation: "tender-section-translation", translation: "tender-section-translation",
documents: "tender-section-documents",
description: "tender-section-description", description: "tender-section-description",
aiSummary: "tender-section-ai-summary", aiSummary: "tender-section-ai-summary",
} as const; } as const;
+5 -1
View File
@@ -51,7 +51,8 @@ const TenderDetails = ({ params }: IProps) => {
[tenderNavItems], [tenderNavItems],
); );
const activeSectionId = useTenderSectionScrollSpy(sectionIds); const activeSectionId = useTenderSectionScrollSpy(sectionIds);
const { data: documents } = useGetTenderDocumentsQuery(details); const { data: documentsResponse, isPending: isDocumentsLoading } =
useGetTenderDocumentsQuery(details);
const handleLanguageChange = useCallback( const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => { (event: React.ChangeEvent<HTMLSelectElement>) => {
const nextParams = new URLSearchParams(searchParams.toString()); const nextParams = new URLSearchParams(searchParams.toString());
@@ -106,6 +107,9 @@ const TenderDetails = ({ params }: IProps) => {
tender={tender} tender={tender}
tenderNavItems={tenderNavItems} tenderNavItems={tenderNavItems}
activeSectionId={activeSectionId} activeSectionId={activeSectionId}
tenderId={details}
documents={documentsResponse?.data ?? []}
documentsLoading={isDocumentsLoading}
/> />
</ShowcaseSection> </ShowcaseSection>
</> </>
@@ -33,6 +33,7 @@ export function getTenderNavItems(tender: TTenderDetails): TenderNavItem[] {
} }
items.push( items.push(
{ id: TENDER_SECTION_IDS.translation, label: "Translation" }, { 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.description, label: "Description" },
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" }, { id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
); );
+13
View File
@@ -60,4 +60,17 @@ export const tendersService = {
throw error; 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",
}),
}; };
+1 -1
View File
@@ -32,7 +32,7 @@ export const TenderDocumentsResponseSchema = z.object({
size: z.number(), size: z.number(),
last_modified: z.number(), last_modified: z.number(),
document_type: z.string(), document_type: z.string(),
}) });
export const TenderTranslationResponseSchema = z.object({ export const TenderTranslationResponseSchema = z.object({
notice_id: z.string(), notice_id: z.string(),
language: z.string(), language: z.string(),
+32
View File
@@ -1,4 +1,5 @@
import { ILabelValue } from "@/types/shared"; import { ILabelValue } from "@/types/shared";
import type { AxiosResponse } from "axios";
import * as moment from "moment"; import * as moment from "moment";
import { z } from "zod"; import { z } from "zod";
export const unixToDate = ({ export const unixToDate = ({
@@ -161,3 +162,34 @@ export const formatFileSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 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);
};