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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user