diff --git a/src/app/tenders/[details]/components/tender-details-content.tsx b/src/app/tenders/[details]/components/tender-details-content.tsx index 58ccd5c..9605e0d 100644 --- a/src/app/tenders/[details]/components/tender-details-content.tsx +++ b/src/app/tenders/[details]/components/tender-details-content.tsx @@ -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 && } + diff --git a/src/app/tenders/[details]/components/tender-details-header.tsx b/src/app/tenders/[details]/components/tender-details-header.tsx index c7e01a4..25311a8 100644 --- a/src/app/tenders/[details]/components/tender-details-header.tsx +++ b/src/app/tenders/[details]/components/tender-details-header.tsx @@ -106,7 +106,7 @@ export function TenderDetailsHeader({ + )} + + +
+ {isLoading && ( +
+
+
+
+
+ )} + + {!isLoading && documents.length === 0 && ( +

+ No documents are available for this tender. +

+ )} + + {!isLoading && documents.length > 0 && ( +
    + {documents.map((doc) => { + const busy = + downloadingName === doc.filename || + (isDownloadingAll && downloadingName === doc.filename); + return ( +
  • +
    +

    + {doc.filename} +

    +
    + + {doc.document_type} + + {formatFileSize(doc.size)} + + Updated{" "} + {unixToDate({ + unix: doc.last_modified, + hasTime: true, + })} + +
    +
    + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/tenders/[details]/constants.ts b/src/app/tenders/[details]/constants.ts index a843253..b82709f 100644 --- a/src/app/tenders/[details]/constants.ts +++ b/src/app/tenders/[details]/constants.ts @@ -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; diff --git a/src/app/tenders/[details]/page.tsx b/src/app/tenders/[details]/page.tsx index 850f903..ac11fb3 100644 --- a/src/app/tenders/[details]/page.tsx +++ b/src/app/tenders/[details]/page.tsx @@ -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) => { 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} /> diff --git a/src/app/tenders/[details]/tender-nav-utils.ts b/src/app/tenders/[details]/tender-nav-utils.ts index a2a59bc..9330f44 100644 --- a/src/app/tenders/[details]/tender-nav-utils.ts +++ b/src/app/tenders/[details]/tender-nav-utils.ts @@ -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" }, ); diff --git a/src/lib/api/services/tenders-service.ts b/src/lib/api/services/tenders-service.ts index 3a28172..f42f452 100644 --- a/src/lib/api/services/tenders-service.ts +++ b/src/lib/api/services/tenders-service.ts @@ -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(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(API_ENDPOINTS.TENDERS.DOCUMENTS_DOWNLOAD(tenderId), { + responseType: "blob", + }), }; diff --git a/src/lib/api/types/Tenders.ts b/src/lib/api/types/Tenders.ts index 913c46d..f36202f 100644 --- a/src/lib/api/types/Tenders.ts +++ b/src/lib/api/types/Tenders.ts @@ -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(), diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 3c734ab..6141c67 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -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 = ({ @@ -160,4 +161,35 @@ export const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; 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, + 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); }; \ No newline at end of file