feat(tender-details): add lots section and enhance tender details functionality
- Introduced a new "Lots" section in the tender details page to display lot-specific information, improving data organization and visibility. - Updated the tender navigation to conditionally include the "Lots" section based on its availability. - Enhanced the tender display utilities with functions to determine the visibility of various sections, including lots, description, and translation. - Refactored components to utilize the new utilities for better conditional rendering and improved user experience. - Improved type definitions for tender details to ensure better type safety and clarity.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||
import { shouldShowAiSummarySection } from "../tender-display-utils";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderAiSummarySectionProps = {
|
||||
@@ -9,12 +10,14 @@ type TenderAiSummarySectionProps = {
|
||||
export function TenderAiSummarySection({
|
||||
tender,
|
||||
}: TenderAiSummarySectionProps) {
|
||||
if (!shouldShowAiSummarySection(tender)) return null;
|
||||
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.aiSummary} className={HEADER_SCROLL_MARGIN}>
|
||||
<SectionHeading>AI summary</SectionHeading>
|
||||
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-dark dark:text-white/90">
|
||||
{tender.overall_summary || "Summary not ready yet"}
|
||||
{tender.overall_summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||
import { shouldShowDescriptionSection } from "../tender-display-utils";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderDescriptionSectionProps = {
|
||||
@@ -9,12 +10,14 @@ type TenderDescriptionSectionProps = {
|
||||
export function TenderDescriptionSection({
|
||||
tender,
|
||||
}: TenderDescriptionSectionProps) {
|
||||
if (!shouldShowDescriptionSection(tender)) return null;
|
||||
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.description} className={HEADER_SCROLL_MARGIN}>
|
||||
<SectionHeading>Description</SectionHeading>
|
||||
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-dark dark:text-white/90">
|
||||
{tender.description || "—"}
|
||||
{tender.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
import { shouldShowLocationSection } from "../tender-nav-utils";
|
||||
import type { TenderNavItem } from "../types";
|
||||
import { scrollToTenderSection } from "../scroll-to-section";
|
||||
import { shouldShowLotsSection } from "../tender-display-utils";
|
||||
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 { TenderLotsSection } from "./tender-lots-section";
|
||||
import { TenderProcurementSection } from "./tender-procurement-section";
|
||||
import { TenderSectionNavList } from "./tender-section-nav-list";
|
||||
import { TenderTimelineSection } from "./tender-timeline-section";
|
||||
@@ -52,6 +54,9 @@ export function TenderDetailsContent({
|
||||
</div>
|
||||
|
||||
<TenderProcurementSection tender={tender} />
|
||||
{shouldShowLotsSection(tender) ? (
|
||||
<TenderLotsSection tender={tender} />
|
||||
) : null}
|
||||
<TenderTimelineSection tender={tender} />
|
||||
|
||||
{showLocation && <TenderLocationSection tender={tender} />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
HEADER_SCROLL_MARGIN,
|
||||
TENDER_SECTION_IDS,
|
||||
} from "../constants";
|
||||
import { isValidRegionCode } from "../tender-display-utils";
|
||||
import { isValidDisplayCountryCode } from "../tender-nav-utils";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
@@ -34,10 +35,25 @@ export function TenderLocationSection({ tender }: TenderLocationSectionProps) {
|
||||
Number.isFinite(tender.estimated_value) &&
|
||||
tender.estimated_value > 0;
|
||||
|
||||
const regionNorm = tender.region_code?.trim() ?? "";
|
||||
const hasValidRegion = isValidRegionCode(regionNorm);
|
||||
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.location} className={HEADER_SCROLL_MARGIN}>
|
||||
<SectionHeading>Location & currency</SectionHeading>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-5xl lg:grid-cols-3">
|
||||
{hasValidRegion && (
|
||||
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-violet/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-violet/[0.12]">
|
||||
<div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-violet/15 blur-2xl dark:bg-violet/25" />
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||
Region (NUTS)
|
||||
</h3>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
|
||||
{regionNorm}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasValidCountryCodeForDisplay && (
|
||||
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-primary/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.12]">
|
||||
<div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-primary/15 blur-2xl dark:bg-primary/25" />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import type { TTenderDetails, TTenderLot } from "@/lib/api/types/Tenders";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isNonEmptyTrimmed } from "../tender-display-utils";
|
||||
import {
|
||||
HEADER_SCROLL_MARGIN,
|
||||
SHOWCASE_CENTERED_BODY,
|
||||
TENDER_SECTION_IDS,
|
||||
} from "../constants";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderLotsSectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
function lotHasVisibleContent(lot: TTenderLot): boolean {
|
||||
return (
|
||||
isNonEmptyTrimmed(lot.lot_id) ||
|
||||
isNonEmptyTrimmed(lot.title) ||
|
||||
isNonEmptyTrimmed(lot.description) ||
|
||||
isNonEmptyTrimmed(lot.main_nature_of_contract) ||
|
||||
isNonEmptyTrimmed(lot.main_classification) ||
|
||||
isNonEmptyTrimmed(lot.main_classification_display) ||
|
||||
(typeof lot.estimated_value === "number" &&
|
||||
Number.isFinite(lot.estimated_value) &&
|
||||
lot.estimated_value > 0) ||
|
||||
isNonEmptyTrimmed(lot.currency)
|
||||
);
|
||||
}
|
||||
|
||||
export function TenderLotsSection({ tender }: TenderLotsSectionProps) {
|
||||
const lots = (tender.lots ?? []).filter(lotHasVisibleContent);
|
||||
if (lots.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.lots} className={HEADER_SCROLL_MARGIN}>
|
||||
<SectionHeading>Lots</SectionHeading>
|
||||
<div className="space-y-6">
|
||||
{lots.map((lot, idx) => {
|
||||
const key = lot.lot_id?.trim() || `lot-${idx}`;
|
||||
const cpv =
|
||||
lot.main_classification_display?.trim() ||
|
||||
lot.main_classification?.trim();
|
||||
const hasValue =
|
||||
typeof lot.estimated_value === "number" &&
|
||||
Number.isFinite(lot.estimated_value) &&
|
||||
lot.estimated_value > 0;
|
||||
const currency = lot.currency?.trim().toUpperCase() ?? "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-2xl border border-stroke/60 bg-gray-2/30 p-4 shadow-inner dark:border-dark-3 dark:bg-dark-3/25 sm:p-6"
|
||||
>
|
||||
<h3 className="mb-4 text-sm font-semibold text-dark dark:text-white">
|
||||
{isNonEmptyTrimmed(lot.lot_id) ? lot.lot_id : `Lot ${idx + 1}`}
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{isNonEmptyTrimmed(lot.title) ? (
|
||||
<ShowcaseSection
|
||||
title="Title"
|
||||
rootClassName={cn("sm:col-span-2 xl:col-span-3")}
|
||||
className="text-start"
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{lot.title}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{isNonEmptyTrimmed(lot.description) ? (
|
||||
<ShowcaseSection
|
||||
title="Description"
|
||||
rootClassName={cn("sm:col-span-2 xl:col-span-3")}
|
||||
className="text-start"
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-dark dark:text-white/90">
|
||||
{lot.description}
|
||||
</p>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{isNonEmptyTrimmed(lot.main_nature_of_contract) ? (
|
||||
<ShowcaseSection title="Nature of contract" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{lot.main_nature_of_contract}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{cpv ? (
|
||||
<ShowcaseSection title="Classification (CPV)" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{cpv}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{hasValue ? (
|
||||
<ShowcaseSection title="Estimated value" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{new Intl.NumberFormat().format(lot.estimated_value!)}
|
||||
{currency ? (
|
||||
<span className="ms-1 text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
|
||||
{currency}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import type { TTenderAddress, TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
HEADER_SCROLL_MARGIN,
|
||||
SHOWCASE_CENTERED_BODY,
|
||||
TENDER_SECTION_IDS,
|
||||
} from "../constants";
|
||||
import {
|
||||
hasBuyerExtendedDetails,
|
||||
isNonEmptyTrimmed,
|
||||
isValidHttpUrl,
|
||||
} from "../tender-display-utils";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderProcurementSectionProps = {
|
||||
@@ -28,11 +33,38 @@ function uniqueTrimmedNoticeIds(ids: string[] | undefined): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAddressLines(addr: TTenderAddress | undefined): string[] {
|
||||
if (!addr) return [];
|
||||
const lines: string[] = [];
|
||||
if (isNonEmptyTrimmed(addr.street_name)) lines.push(addr.street_name!.trim());
|
||||
const cityLine = [addr.postal_zone, addr.city_name]
|
||||
.map((s) => s?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (cityLine) lines.push(cityLine);
|
||||
const regionCountry = [addr.country_subentity_code, addr.country_code]
|
||||
.map((s) => s?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
if (regionCountry) lines.push(regionCountry);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function TenderProcurementSection({ tender }: TenderProcurementSectionProps) {
|
||||
const relatedNoticeIds = uniqueTrimmedNoticeIds(tender.related_notice_publication_ids);
|
||||
|
||||
const noticeTypeCode = tender.notice_type_code?.trim();
|
||||
const mainClassification = tender.main_classification?.trim();
|
||||
const mainCpv =
|
||||
tender.main_classification_display?.trim() || tender.main_classification?.trim();
|
||||
const noticePubId = tender.notice_publication_id?.trim();
|
||||
const durationParts = [tender.duration?.trim(), tender.duration_unit?.trim()].filter(
|
||||
Boolean,
|
||||
);
|
||||
const durationLabel = durationParts.join(" ");
|
||||
|
||||
const buyer = tender.buyer_organization;
|
||||
const buyerAddressLines = formatAddressLines(buyer.address);
|
||||
const showBuyerOrgName = isNonEmptyTrimmed(buyer.name);
|
||||
const showBuyerExtended = hasBuyerExtendedDetails(tender);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -41,61 +73,153 @@ export function TenderProcurementSection({ tender }: TenderProcurementSectionPro
|
||||
>
|
||||
<SectionHeading>Procurement</SectionHeading>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
<ShowcaseSection title="Tender ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{tender.tender_id}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection
|
||||
title="Buyer organization"
|
||||
rootClassName={SHOWCASE_CENTERED_BODY.rootClassName}
|
||||
className={cn(
|
||||
"sm:col-span-2 xl:col-span-1 2xl:col-span-2",
|
||||
SHOWCASE_CENTERED_BODY.className,
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{tender.buyer_organization.name}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Notice publication ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
<Link
|
||||
href={`${TED_NOTICE_DETAIL}/${tender.notice_publication_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{tender.notice_publication_id}
|
||||
</Link>
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Procedure code" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{tender.procedure_code}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Procurement type" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{tender.procurement_type_code}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
{isNonEmptyTrimmed(tender.tender_id) ? (
|
||||
<ShowcaseSection title="Tender ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{tender.tender_id}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.project_name) ? (
|
||||
<ShowcaseSection title="Project name" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="break-words font-medium text-dark dark:text-white">
|
||||
{tender.project_name}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{showBuyerOrgName ? (
|
||||
<ShowcaseSection
|
||||
title="Buyer organization"
|
||||
rootClassName={SHOWCASE_CENTERED_BODY.rootClassName}
|
||||
className={cn(
|
||||
"sm:col-span-2 xl:col-span-1 2xl:col-span-2",
|
||||
SHOWCASE_CENTERED_BODY.className,
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">{buyer.name}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{noticePubId ? (
|
||||
<ShowcaseSection title="Notice publication ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
<Link
|
||||
href={`${TED_NOTICE_DETAIL}/${noticePubId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{noticePubId}
|
||||
</Link>
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.procurement_project_id) ? (
|
||||
<ShowcaseSection title="Procurement project ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="break-all font-medium text-dark dark:text-white">
|
||||
{tender.procurement_project_id}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.contract_folder_id) ? (
|
||||
<ShowcaseSection title="Contract folder ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="break-all font-medium text-dark dark:text-white">
|
||||
{tender.contract_folder_id}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.contract_notice_id) ? (
|
||||
<ShowcaseSection title="Contract notice ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="break-all font-medium text-dark dark:text-white">
|
||||
{tender.contract_notice_id}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.notice_identifier) ? (
|
||||
<ShowcaseSection title="Notice identifier" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="break-all font-medium text-dark dark:text-white">
|
||||
{tender.notice_identifier}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.notice_version) ? (
|
||||
<ShowcaseSection title="Notice version" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{tender.notice_version}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.gazette_id) ? (
|
||||
<ShowcaseSection title="Gazette ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{tender.gazette_id}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.form_type) ? (
|
||||
<ShowcaseSection title="Form type" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{tender.form_type}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.notice_sub_type_code) ? (
|
||||
<ShowcaseSection title="Notice sub-type code" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{tender.notice_sub_type_code}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.source) ? (
|
||||
<ShowcaseSection title="Source" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{tender.source}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.procedure_code) ? (
|
||||
<ShowcaseSection title="Procedure code" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{tender.procedure_code}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.procurement_type_code) ? (
|
||||
<ShowcaseSection title="Procurement type" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{tender.procurement_type_code}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{noticeTypeCode ? (
|
||||
<ShowcaseSection title="Notice type code" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{noticeTypeCode}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{mainCpv ? (
|
||||
<ShowcaseSection title="Main classification (CPV)" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{mainCpv}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isNonEmptyTrimmed(tender.estimated_value_vat_basis) ? (
|
||||
<ShowcaseSection title="Estimated value (VAT basis)" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{noticeTypeCode}
|
||||
{tender.estimated_value_vat_basis}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{mainClassification ? (
|
||||
<ShowcaseSection
|
||||
title="Main classification (CPV)"
|
||||
{...SHOWCASE_CENTERED_BODY}
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{mainClassification}
|
||||
</span>
|
||||
|
||||
{durationLabel ? (
|
||||
<ShowcaseSection title="Duration" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{durationLabel}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{relatedNoticeIds.length > 0 ? (
|
||||
<ShowcaseSection
|
||||
title="Related notice publications"
|
||||
@@ -122,6 +246,78 @@ export function TenderProcurementSection({ tender }: TenderProcurementSectionPro
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showBuyerExtended ? (
|
||||
<div className="mt-8 space-y-4">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||
Buyer contact & address
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
{isNonEmptyTrimmed(buyer.company_id) ? (
|
||||
<ShowcaseSection title="Company ID" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">{buyer.company_id}</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{isValidHttpUrl(buyer.website_uri) ? (
|
||||
<ShowcaseSection title="Buyer website" {...SHOWCASE_CENTERED_BODY}>
|
||||
<Link
|
||||
href={buyer.website_uri!.trim()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{buyer.website_uri!.trim()}
|
||||
</Link>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{isNonEmptyTrimmed(buyer.contact_name) ? (
|
||||
<ShowcaseSection title="Contact name" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{buyer.contact_name}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{isNonEmptyTrimmed(buyer.contact_telephone) ? (
|
||||
<ShowcaseSection title="Contact telephone" {...SHOWCASE_CENTERED_BODY}>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{buyer.contact_telephone}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{isNonEmptyTrimmed(buyer.contact_email) ? (
|
||||
<ShowcaseSection title="Contact email" {...SHOWCASE_CENTERED_BODY}>
|
||||
<a
|
||||
href={`mailto:${buyer.contact_email!.trim()}`}
|
||||
className="break-all font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{buyer.contact_email!.trim()}
|
||||
</a>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
{buyerAddressLines.length > 0 ? (
|
||||
<ShowcaseSection
|
||||
title="Address"
|
||||
rootClassName={cn(
|
||||
SHOWCASE_CENTERED_BODY.rootClassName,
|
||||
"sm:col-span-2 xl:col-span-2 2xl:col-span-3",
|
||||
)}
|
||||
className="text-start"
|
||||
>
|
||||
<address className="not-italic">
|
||||
{buyerAddressLines.map((line, i) => (
|
||||
<div
|
||||
key={`${line}-${i}`}
|
||||
className="text-sm font-medium text-dark dark:text-white"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</address>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { truncateString, unixToDate } from "@/utils/shared";
|
||||
import { isoStringToDate, truncateString, unixToDate } from "@/utils/shared";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
HEADER_SCROLL_MARGIN,
|
||||
SHOWCASE_CENTERED_BODY,
|
||||
TENDER_SECTION_IDS,
|
||||
} from "../constants";
|
||||
import {
|
||||
hasAddressContent,
|
||||
hasWinningTendererContent,
|
||||
isNonEmptyTrimmed,
|
||||
isValidHttpUrl,
|
||||
isValidIsoDateTimeString,
|
||||
isValidPositiveUnix,
|
||||
shouldShowExternalLinksBlock,
|
||||
} from "../tender-display-utils";
|
||||
import { ExternalLinkIcon } from "./external-link-icon";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
@@ -14,58 +23,212 @@ type TenderTimelineSectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
function formatWinnerAddress(tender: TTenderDetails): string[] {
|
||||
const w = tender.winning_tenderer;
|
||||
if (!w?.address || !hasAddressContent(w.address)) return [];
|
||||
const a = w.address;
|
||||
const lines: string[] = [];
|
||||
if (isNonEmptyTrimmed(a.street_name)) lines.push(a.street_name!.trim());
|
||||
const cityLine = [a.postal_zone, a.city_name]
|
||||
.map((s) => s?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (cityLine) lines.push(cityLine);
|
||||
const tail = [a.country_subentity_code, a.country_code]
|
||||
.map((s) => s?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
if (tail) lines.push(tail);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function TenderTimelineSection({ tender }: TenderTimelineSectionProps) {
|
||||
const submissionUrl = tender.submission_url?.trim();
|
||||
const dispatchIso = tender.notice_dispatch_date_with_timezone?.trim();
|
||||
const dispatchLabel = isValidIsoDateTimeString(dispatchIso)
|
||||
? isoStringToDate({ isoString: dispatchIso!, hasTime: true })
|
||||
: isValidPositiveUnix(tender.notice_dispatch_at)
|
||||
? unixToDate({
|
||||
unix: tender.notice_dispatch_at!,
|
||||
hasTime: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
const winnerName = tender.winning_tenderer?.name?.trim();
|
||||
const winnerLines = formatWinnerAddress(tender);
|
||||
const showWinnerBlock =
|
||||
hasWinningTendererContent(tender) &&
|
||||
(Boolean(winnerName) || winnerLines.length > 0);
|
||||
|
||||
const showSubmissionUrl = Boolean(submissionUrl) && isValidHttpUrl(submissionUrl);
|
||||
const showExternal = shouldShowExternalLinksBlock(tender);
|
||||
|
||||
const deadlineCards = [
|
||||
{
|
||||
key: "application",
|
||||
title: "Application deadline",
|
||||
unix: tender.application_deadline,
|
||||
},
|
||||
{
|
||||
key: "publication",
|
||||
title: "Publication date",
|
||||
unix: tender.publication_date,
|
||||
},
|
||||
{
|
||||
key: "submission",
|
||||
title: "Submission deadline",
|
||||
unix: tender.submission_deadline,
|
||||
},
|
||||
{
|
||||
key: "tender",
|
||||
title: "Tender deadline",
|
||||
unix: tender.tender_deadline,
|
||||
},
|
||||
].filter((d) => isValidPositiveUnix(d.unix));
|
||||
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.timeline} className={HEADER_SCROLL_MARGIN}>
|
||||
<SectionHeading>Timeline</SectionHeading>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<ShowcaseSection title="Application deadline" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: tender.application_deadline ?? 0,
|
||||
hasTime: true,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Publication date" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: tender.publication_date ?? 0,
|
||||
hasTime: true,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Submission deadline" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: tender.submission_deadline ?? 0,
|
||||
hasTime: true,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Tender deadline" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: tender.tender_deadline ?? 0,
|
||||
hasTime: true,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
{submissionUrl && (
|
||||
{isValidPositiveUnix(tender.award_date) ? (
|
||||
<ShowcaseSection title="Award date" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: tender.award_date!,
|
||||
hasTime: true,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{showWinnerBlock ? (
|
||||
<ShowcaseSection
|
||||
title="Winning tenderer"
|
||||
rootClassName="sm:col-span-2 lg:col-span-3 xl:col-span-5"
|
||||
className="text-start"
|
||||
>
|
||||
{winnerName ? (
|
||||
<p className="font-medium text-dark dark:text-white">{winnerName}</p>
|
||||
) : null}
|
||||
{winnerLines.length > 0 ? (
|
||||
<div className={winnerName ? "mt-2 space-y-1" : "space-y-1"}>
|
||||
{winnerLines.map((line, i) => (
|
||||
<p
|
||||
key={`w-${i}`}
|
||||
className="text-sm text-dark dark:text-white/90"
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{dispatchLabel ? (
|
||||
<ShowcaseSection title="Notice dispatch" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{dispatchLabel}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isValidPositiveUnix(tender.issue_date) ? (
|
||||
<ShowcaseSection title="Issue date" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: tender.issue_date!,
|
||||
hasTime: false,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{isValidPositiveUnix(tender.issue_time) ? (
|
||||
<ShowcaseSection title="Issue time" {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: tender.issue_time!,
|
||||
hasTime: true,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
|
||||
{deadlineCards.map((d) => (
|
||||
<ShowcaseSection key={d.key} title={d.title} {...SHOWCASE_CENTERED_BODY}>
|
||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||
{unixToDate({
|
||||
unix: d.unix as number,
|
||||
hasTime: true,
|
||||
})}
|
||||
</time>
|
||||
</ShowcaseSection>
|
||||
))}
|
||||
|
||||
{showSubmissionUrl ? (
|
||||
<ShowcaseSection title="Submission URL" {...SHOWCASE_CENTERED_BODY}>
|
||||
<Link
|
||||
href={submissionUrl}
|
||||
href={submissionUrl!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
<ExternalLinkIcon className="shrink-0" />
|
||||
{truncateString(submissionUrl, 42)}
|
||||
{truncateString(submissionUrl!, 42)}
|
||||
</Link>
|
||||
</ShowcaseSection>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{showExternal ? (
|
||||
<ShowcaseSection
|
||||
title="External links"
|
||||
rootClassName="sm:col-span-2 lg:col-span-3 xl:col-span-5"
|
||||
className="text-start"
|
||||
>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{isValidHttpUrl(tender.document_url) ? (
|
||||
<li>
|
||||
<Link
|
||||
href={tender.document_url!.trim()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
<ExternalLinkIcon className="shrink-0" />
|
||||
Document (TED)
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{isValidHttpUrl(tender.tender_url) ? (
|
||||
<li>
|
||||
<Link
|
||||
href={tender.tender_url!.trim()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
<ExternalLinkIcon className="shrink-0" />
|
||||
{truncateString(tender.tender_url!.trim(), 56)}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{isValidHttpUrl(tender.buyer_profile_url) ? (
|
||||
<li>
|
||||
<Link
|
||||
href={tender.buyer_profile_url!.trim()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
<ExternalLinkIcon className="shrink-0" />
|
||||
Buyer profile
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</ShowcaseSection>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||
import {
|
||||
isNonEmptyTrimmed,
|
||||
shouldShowTranslationSection,
|
||||
} from "../tender-display-utils";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderTranslationSectionProps = {
|
||||
@@ -9,18 +13,33 @@ type TenderTranslationSectionProps = {
|
||||
export function TenderTranslationSection({
|
||||
tender,
|
||||
}: TenderTranslationSectionProps) {
|
||||
if (!shouldShowTranslationSection(tender)) return null;
|
||||
|
||||
const showNoticeLang = isNonEmptyTrimmed(tender.notice_language_code);
|
||||
const showResolved = isNonEmptyTrimmed(tender.language);
|
||||
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.translation} className={HEADER_SCROLL_MARGIN}>
|
||||
<SectionHeading>Translation</SectionHeading>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||
Resolved language
|
||||
</p>
|
||||
<p className="text-sm text-dark dark:text-white">
|
||||
{tender.language || "original"}
|
||||
</p>
|
||||
</div>
|
||||
{showNoticeLang ? (
|
||||
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||
Notice language
|
||||
</p>
|
||||
<p className="text-sm text-dark dark:text-white">
|
||||
{tender.notice_language_code}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showResolved ? (
|
||||
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||
Resolved language
|
||||
</p>
|
||||
<p className="text-sm text-dark dark:text-white">{tender.language}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
|
||||
|
||||
export const TENDER_SECTION_IDS = {
|
||||
procurement: "tender-section-procurement",
|
||||
lots: "tender-section-lots",
|
||||
timeline: "tender-section-timeline",
|
||||
location: "tender-section-location",
|
||||
translation: "tender-section-translation",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useTenderDetailQuery,
|
||||
useTranslateTenderMutation,
|
||||
} from "@/hooks/queries";
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { use, useCallback, useMemo } from "react";
|
||||
import { TenderDetailsContent } from "./components/tender-details-content";
|
||||
@@ -93,7 +94,7 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
return <TenderDetailsError breadcrumbItems={breadcrumbItems} />;
|
||||
}
|
||||
|
||||
const tender = data!.data;
|
||||
const tender: TTenderDetails = data!.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { TTenderAddress, TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
|
||||
export function isNonEmptyTrimmed(value: string | undefined | null): boolean {
|
||||
return Boolean(value?.trim());
|
||||
}
|
||||
|
||||
export function isValidPositiveUnix(value: number | undefined | null): boolean {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
export function isValidHttpUrl(value: string | undefined | null): boolean {
|
||||
const t = value?.trim();
|
||||
if (!t) return false;
|
||||
try {
|
||||
const u = new URL(t);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidIsoDateTimeString(
|
||||
value: string | undefined | null,
|
||||
): boolean {
|
||||
const t = value?.trim();
|
||||
if (!t) return false;
|
||||
const ms = Date.parse(t);
|
||||
return Number.isFinite(ms);
|
||||
}
|
||||
|
||||
export function hasAddressContent(addr: TTenderAddress | undefined): boolean {
|
||||
if (!addr) return false;
|
||||
return (
|
||||
isNonEmptyTrimmed(addr.street_name) ||
|
||||
isNonEmptyTrimmed(addr.city_name) ||
|
||||
isNonEmptyTrimmed(addr.postal_zone) ||
|
||||
isNonEmptyTrimmed(addr.country_subentity_code) ||
|
||||
isNonEmptyTrimmed(addr.country_code)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasWinningTendererContent(tender: TTenderDetails): boolean {
|
||||
const w = tender.winning_tenderer;
|
||||
if (!w) return false;
|
||||
if (isNonEmptyTrimmed(w.name)) return true;
|
||||
return hasAddressContent(w.address);
|
||||
}
|
||||
|
||||
export function shouldShowLotsSection(tender: TTenderDetails): boolean {
|
||||
const lots = tender.lots ?? [];
|
||||
return lots.some(
|
||||
(lot) =>
|
||||
isNonEmptyTrimmed(lot.lot_id) ||
|
||||
isNonEmptyTrimmed(lot.title) ||
|
||||
isNonEmptyTrimmed(lot.description) ||
|
||||
isNonEmptyTrimmed(lot.main_nature_of_contract) ||
|
||||
isNonEmptyTrimmed(lot.main_classification) ||
|
||||
isNonEmptyTrimmed(lot.main_classification_display) ||
|
||||
(typeof lot.estimated_value === "number" &&
|
||||
Number.isFinite(lot.estimated_value) &&
|
||||
lot.estimated_value > 0) ||
|
||||
isNonEmptyTrimmed(lot.currency),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBuyerExtendedDetails(tender: TTenderDetails): boolean {
|
||||
const b = tender.buyer_organization;
|
||||
return (
|
||||
isNonEmptyTrimmed(b.company_id) ||
|
||||
isValidHttpUrl(b.website_uri) ||
|
||||
isNonEmptyTrimmed(b.contact_name) ||
|
||||
isNonEmptyTrimmed(b.contact_telephone) ||
|
||||
isNonEmptyTrimmed(b.contact_email) ||
|
||||
hasAddressContent(b.address)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowDescriptionSection(tender: TTenderDetails): boolean {
|
||||
return isNonEmptyTrimmed(tender.description);
|
||||
}
|
||||
|
||||
export function shouldShowTranslationSection(tender: TTenderDetails): boolean {
|
||||
return (
|
||||
isNonEmptyTrimmed(tender.language) ||
|
||||
isNonEmptyTrimmed(tender.notice_language_code)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowAiSummarySection(tender: TTenderDetails): boolean {
|
||||
return isNonEmptyTrimmed(tender.overall_summary);
|
||||
}
|
||||
|
||||
export function shouldShowExternalLinksBlock(tender: TTenderDetails): boolean {
|
||||
return (
|
||||
isValidHttpUrl(tender.document_url) ||
|
||||
isValidHttpUrl(tender.tender_url) ||
|
||||
isValidHttpUrl(tender.buyer_profile_url)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidRegionCode(code: string | undefined | null): boolean {
|
||||
const t = code?.trim() ?? "";
|
||||
if (t.length < 2) return false;
|
||||
return /^[A-Z0-9]{2,10}$/i.test(t);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { TENDER_SECTION_IDS } from "./constants";
|
||||
import type { TenderNavItem } from "./types";
|
||||
import {
|
||||
isValidRegionCode,
|
||||
shouldShowAiSummarySection,
|
||||
shouldShowDescriptionSection,
|
||||
shouldShowLotsSection,
|
||||
shouldShowTranslationSection,
|
||||
} from "./tender-display-utils";
|
||||
|
||||
export const isValidDisplayCountryCode = (code: string) =>
|
||||
/^[A-Za-z]{2,3}$/.test(code);
|
||||
@@ -14,7 +21,8 @@ function analyzeLocationVisibility(tender: TTenderDetails) {
|
||||
typeof tender.estimated_value === "number" &&
|
||||
Number.isFinite(tender.estimated_value) &&
|
||||
tender.estimated_value > 0;
|
||||
const showLocation = hasCountry || hasCurrency || hasEst;
|
||||
const hasRegion = isValidRegionCode(tender.region_code);
|
||||
const showLocation = hasCountry || hasCurrency || hasEst || hasRegion;
|
||||
return { showLocation };
|
||||
}
|
||||
|
||||
@@ -27,22 +35,29 @@ export function getTenderNavItems(
|
||||
|
||||
const items: TenderNavItem[] = [
|
||||
{ id: TENDER_SECTION_IDS.procurement, label: "Procurement" },
|
||||
{ id: TENDER_SECTION_IDS.timeline, label: "Timeline" },
|
||||
];
|
||||
if (shouldShowLotsSection(tender)) {
|
||||
items.push({ id: TENDER_SECTION_IDS.lots, label: "Lots" });
|
||||
}
|
||||
items.push({ id: TENDER_SECTION_IDS.timeline, label: "Timeline" });
|
||||
if (showLocation) {
|
||||
items.push({
|
||||
id: TENDER_SECTION_IDS.location,
|
||||
label: "Location & currency",
|
||||
});
|
||||
}
|
||||
items.push({ id: TENDER_SECTION_IDS.translation, label: "Translation" });
|
||||
if (shouldShowTranslationSection(tender)) {
|
||||
items.push({ id: TENDER_SECTION_IDS.translation, label: "Translation" });
|
||||
}
|
||||
if (showDocuments) {
|
||||
items.push({ id: TENDER_SECTION_IDS.documents, label: "Documents" });
|
||||
}
|
||||
items.push(
|
||||
{ id: TENDER_SECTION_IDS.description, label: "Description" },
|
||||
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
|
||||
);
|
||||
if (shouldShowDescriptionSection(tender)) {
|
||||
items.push({ id: TENDER_SECTION_IDS.description, label: "Description" });
|
||||
}
|
||||
if (shouldShowAiSummarySection(tender)) {
|
||||
items.push({ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
+106
-32
@@ -1,37 +1,111 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TenderSchema = z.object({
|
||||
id: z.string(),
|
||||
buyer_organization: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
application_deadline: z.number(),
|
||||
country_code: z.string(),
|
||||
currency: z.string(),
|
||||
description: z.string(),
|
||||
duration: z.string(),
|
||||
duration_unit: z.string(),
|
||||
estimated_value: z.number(),
|
||||
notice_publication_id: z.string(),
|
||||
/** Related TED notice publication IDs (e.g. modifications, same procedure). */
|
||||
related_notice_publication_ids: z.array(z.string()).optional(),
|
||||
/** TED form / notice type code (e.g. can-modif). */
|
||||
notice_type_code: z.string().optional(),
|
||||
/** Primary CPV classification code. */
|
||||
main_classification: z.string().optional(),
|
||||
procedure_code: z.string(),
|
||||
procurement_type_code: z.string(),
|
||||
publication_date: z.number(),
|
||||
status: z.string(),
|
||||
submission_deadline: z.number(),
|
||||
submission_url: z.string(),
|
||||
tender_deadline: z.number(),
|
||||
created_at: z.number().optional(),
|
||||
tender_id: z.string(),
|
||||
title: z.string(),
|
||||
language: z.string().optional(),
|
||||
overall_summary: z.string().optional(),
|
||||
});
|
||||
/** TED / eForms notice address block */
|
||||
export const TenderAddressSchema = z
|
||||
.object({
|
||||
street_name: z.string().optional(),
|
||||
city_name: z.string().optional(),
|
||||
postal_zone: z.string().optional(),
|
||||
country_subentity_code: z.string().optional(),
|
||||
country_code: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TTenderAddress = z.infer<typeof TenderAddressSchema>;
|
||||
|
||||
export const BuyerOrganizationSchema = z
|
||||
.object({
|
||||
name: z.string().default(""),
|
||||
company_id: z.string().optional(),
|
||||
website_uri: z.string().optional(),
|
||||
contact_name: z.string().optional(),
|
||||
contact_telephone: z.string().optional(),
|
||||
contact_email: z.string().optional(),
|
||||
address: TenderAddressSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TBuyerOrganization = z.infer<typeof BuyerOrganizationSchema>;
|
||||
|
||||
export const WinningTendererSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
address: TenderAddressSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TWinningTenderer = z.infer<typeof WinningTendererSchema>;
|
||||
|
||||
export const TenderLotSchema = z
|
||||
.object({
|
||||
lot_id: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
main_nature_of_contract: z.string().optional(),
|
||||
main_classification: z.string().optional(),
|
||||
main_classification_display: z.string().optional(),
|
||||
estimated_value: z.number().optional(),
|
||||
currency: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TTenderLot = z.infer<typeof TenderLotSchema>;
|
||||
|
||||
export const TenderSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
notice_publication_id: z.string(),
|
||||
/** Related TED notice publication IDs (e.g. modifications, same procedure). */
|
||||
related_notice_publication_ids: z.array(z.string()).optional(),
|
||||
contract_folder_id: z.string().optional(),
|
||||
procurement_project_id: z.string().optional(),
|
||||
contract_notice_id: z.string().optional(),
|
||||
notice_type_code: z.string().optional(),
|
||||
form_type: z.string().optional(),
|
||||
notice_sub_type_code: z.string().optional(),
|
||||
notice_identifier: z.string().optional(),
|
||||
notice_version: z.string().optional(),
|
||||
notice_language_code: z.string().optional(),
|
||||
gazette_id: z.string().optional(),
|
||||
notice_dispatch_at: z.number().optional(),
|
||||
notice_dispatch_date_with_timezone: z.string().optional(),
|
||||
issue_date: z.number().optional(),
|
||||
issue_time: z.number().optional(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
procurement_type_code: z.string(),
|
||||
procedure_code: z.string(),
|
||||
main_classification: z.string().optional(),
|
||||
main_classification_display: z.string().optional(),
|
||||
estimated_value: z.number(),
|
||||
currency: z.string(),
|
||||
estimated_value_vat_basis: z.string().optional(),
|
||||
duration: z.string().optional(),
|
||||
duration_unit: z.string().optional(),
|
||||
publication_date: z.number(),
|
||||
tender_deadline: z.number(),
|
||||
submission_deadline: z.number(),
|
||||
application_deadline: z.number(),
|
||||
submission_url: z.string(),
|
||||
country_code: z.string(),
|
||||
region_code: z.string().optional(),
|
||||
document_url: z.string().optional(),
|
||||
tender_url: z.string().optional(),
|
||||
buyer_profile_url: z.string().optional(),
|
||||
buyer_organization: BuyerOrganizationSchema,
|
||||
winning_tenderer: WinningTendererSchema.optional(),
|
||||
lots: z.array(TenderLotSchema).optional(),
|
||||
award_date: z.number().optional(),
|
||||
status: z.string(),
|
||||
source: z.string().optional(),
|
||||
tender_id: z.string(),
|
||||
project_name: z.string().optional(),
|
||||
created_at: z.number().optional(),
|
||||
/** Resolved / UI language (may come from translation pipeline). */
|
||||
language: z.string().optional(),
|
||||
overall_summary: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const TenderDocumentsResponseSchema = z.object({
|
||||
filename: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user