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 type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||||
|
import { shouldShowAiSummarySection } from "../tender-display-utils";
|
||||||
import { SectionHeading } from "./section-heading";
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
type TenderAiSummarySectionProps = {
|
type TenderAiSummarySectionProps = {
|
||||||
@@ -9,12 +10,14 @@ type TenderAiSummarySectionProps = {
|
|||||||
export function TenderAiSummarySection({
|
export function TenderAiSummarySection({
|
||||||
tender,
|
tender,
|
||||||
}: TenderAiSummarySectionProps) {
|
}: TenderAiSummarySectionProps) {
|
||||||
|
if (!shouldShowAiSummarySection(tender)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={TENDER_SECTION_IDS.aiSummary} className={HEADER_SCROLL_MARGIN}>
|
<div id={TENDER_SECTION_IDS.aiSummary} className={HEADER_SCROLL_MARGIN}>
|
||||||
<SectionHeading>AI summary</SectionHeading>
|
<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">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||||
|
import { shouldShowDescriptionSection } from "../tender-display-utils";
|
||||||
import { SectionHeading } from "./section-heading";
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
type TenderDescriptionSectionProps = {
|
type TenderDescriptionSectionProps = {
|
||||||
@@ -9,12 +10,14 @@ type TenderDescriptionSectionProps = {
|
|||||||
export function TenderDescriptionSection({
|
export function TenderDescriptionSection({
|
||||||
tender,
|
tender,
|
||||||
}: TenderDescriptionSectionProps) {
|
}: TenderDescriptionSectionProps) {
|
||||||
|
if (!shouldShowDescriptionSection(tender)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={TENDER_SECTION_IDS.description} className={HEADER_SCROLL_MARGIN}>
|
<div id={TENDER_SECTION_IDS.description} className={HEADER_SCROLL_MARGIN}>
|
||||||
<SectionHeading>Description</SectionHeading>
|
<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">
|
<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">
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-dark dark:text-white/90">
|
||||||
{tender.description || "—"}
|
{tender.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import type {
|
|||||||
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 { shouldShowLotsSection } from "../tender-display-utils";
|
||||||
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 { TenderDocumentsSection } from "./tender-documents-section";
|
||||||
import { TenderLocationSection } from "./tender-location-section";
|
import { TenderLocationSection } from "./tender-location-section";
|
||||||
|
import { TenderLotsSection } from "./tender-lots-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";
|
||||||
import { TenderTimelineSection } from "./tender-timeline-section";
|
import { TenderTimelineSection } from "./tender-timeline-section";
|
||||||
@@ -52,6 +54,9 @@ export function TenderDetailsContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TenderProcurementSection tender={tender} />
|
<TenderProcurementSection tender={tender} />
|
||||||
|
{shouldShowLotsSection(tender) ? (
|
||||||
|
<TenderLotsSection tender={tender} />
|
||||||
|
) : null}
|
||||||
<TenderTimelineSection tender={tender} />
|
<TenderTimelineSection tender={tender} />
|
||||||
|
|
||||||
{showLocation && <TenderLocationSection tender={tender} />}
|
{showLocation && <TenderLocationSection tender={tender} />}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
HEADER_SCROLL_MARGIN,
|
HEADER_SCROLL_MARGIN,
|
||||||
TENDER_SECTION_IDS,
|
TENDER_SECTION_IDS,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import { isValidRegionCode } from "../tender-display-utils";
|
||||||
import { isValidDisplayCountryCode } from "../tender-nav-utils";
|
import { isValidDisplayCountryCode } from "../tender-nav-utils";
|
||||||
import { SectionHeading } from "./section-heading";
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
@@ -34,10 +35,25 @@ export function TenderLocationSection({ tender }: TenderLocationSectionProps) {
|
|||||||
Number.isFinite(tender.estimated_value) &&
|
Number.isFinite(tender.estimated_value) &&
|
||||||
tender.estimated_value > 0;
|
tender.estimated_value > 0;
|
||||||
|
|
||||||
|
const regionNorm = tender.region_code?.trim() ?? "";
|
||||||
|
const hasValidRegion = isValidRegionCode(regionNorm);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={TENDER_SECTION_IDS.location} className={HEADER_SCROLL_MARGIN}>
|
<div id={TENDER_SECTION_IDS.location} className={HEADER_SCROLL_MARGIN}>
|
||||||
<SectionHeading>Location & currency</SectionHeading>
|
<SectionHeading>Location & currency</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-5xl lg:grid-cols-3">
|
<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 && (
|
{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="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" />
|
<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 { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
|
import type { TTenderAddress, TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
HEADER_SCROLL_MARGIN,
|
HEADER_SCROLL_MARGIN,
|
||||||
SHOWCASE_CENTERED_BODY,
|
SHOWCASE_CENTERED_BODY,
|
||||||
TENDER_SECTION_IDS,
|
TENDER_SECTION_IDS,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import {
|
||||||
|
hasBuyerExtendedDetails,
|
||||||
|
isNonEmptyTrimmed,
|
||||||
|
isValidHttpUrl,
|
||||||
|
} from "../tender-display-utils";
|
||||||
import { SectionHeading } from "./section-heading";
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
type TenderProcurementSectionProps = {
|
type TenderProcurementSectionProps = {
|
||||||
@@ -28,11 +33,38 @@ function uniqueTrimmedNoticeIds(ids: string[] | undefined): string[] {
|
|||||||
return out;
|
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) {
|
export function TenderProcurementSection({ tender }: TenderProcurementSectionProps) {
|
||||||
const relatedNoticeIds = uniqueTrimmedNoticeIds(tender.related_notice_publication_ids);
|
const relatedNoticeIds = uniqueTrimmedNoticeIds(tender.related_notice_publication_ids);
|
||||||
|
|
||||||
const noticeTypeCode = tender.notice_type_code?.trim();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -41,61 +73,153 @@ export function TenderProcurementSection({ tender }: TenderProcurementSectionPro
|
|||||||
>
|
>
|
||||||
<SectionHeading>Procurement</SectionHeading>
|
<SectionHeading>Procurement</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||||
<ShowcaseSection title="Tender ID" {...SHOWCASE_CENTERED_BODY}>
|
{isNonEmptyTrimmed(tender.tender_id) ? (
|
||||||
<span className="font-medium text-dark dark:text-white">
|
<ShowcaseSection title="Tender ID" {...SHOWCASE_CENTERED_BODY}>
|
||||||
{tender.tender_id}
|
<span className="font-medium text-dark dark:text-white">{tender.tender_id}</span>
|
||||||
</span>
|
</ShowcaseSection>
|
||||||
</ShowcaseSection>
|
) : null}
|
||||||
<ShowcaseSection
|
|
||||||
title="Buyer organization"
|
{isNonEmptyTrimmed(tender.project_name) ? (
|
||||||
rootClassName={SHOWCASE_CENTERED_BODY.rootClassName}
|
<ShowcaseSection title="Project name" {...SHOWCASE_CENTERED_BODY}>
|
||||||
className={cn(
|
<span className="break-words font-medium text-dark dark:text-white">
|
||||||
"sm:col-span-2 xl:col-span-1 2xl:col-span-2",
|
{tender.project_name}
|
||||||
SHOWCASE_CENTERED_BODY.className,
|
</span>
|
||||||
)}
|
</ShowcaseSection>
|
||||||
>
|
) : null}
|
||||||
<span className="font-medium text-dark dark:text-white">
|
|
||||||
{tender.buyer_organization.name}
|
{showBuyerOrgName ? (
|
||||||
</span>
|
<ShowcaseSection
|
||||||
</ShowcaseSection>
|
title="Buyer organization"
|
||||||
<ShowcaseSection title="Notice publication ID" {...SHOWCASE_CENTERED_BODY}>
|
rootClassName={SHOWCASE_CENTERED_BODY.rootClassName}
|
||||||
<span className="font-medium text-primary underline-offset-4 hover:underline">
|
className={cn(
|
||||||
<Link
|
"sm:col-span-2 xl:col-span-1 2xl:col-span-2",
|
||||||
href={`${TED_NOTICE_DETAIL}/${tender.notice_publication_id}`}
|
SHOWCASE_CENTERED_BODY.className,
|
||||||
target="_blank"
|
)}
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
>
|
<span className="font-medium text-dark dark:text-white">{buyer.name}</span>
|
||||||
{tender.notice_publication_id}
|
</ShowcaseSection>
|
||||||
</Link>
|
) : null}
|
||||||
</span>
|
|
||||||
</ShowcaseSection>
|
{noticePubId ? (
|
||||||
<ShowcaseSection title="Procedure code" {...SHOWCASE_CENTERED_BODY}>
|
<ShowcaseSection title="Notice publication ID" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<span className="font-medium text-dark dark:text-white">
|
<span className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
{tender.procedure_code}
|
<Link
|
||||||
</span>
|
href={`${TED_NOTICE_DETAIL}/${noticePubId}`}
|
||||||
</ShowcaseSection>
|
target="_blank"
|
||||||
<ShowcaseSection title="Procurement type" {...SHOWCASE_CENTERED_BODY}>
|
rel="noopener noreferrer"
|
||||||
<span className="font-medium text-dark dark:text-white">
|
>
|
||||||
{tender.procurement_type_code}
|
{noticePubId}
|
||||||
</span>
|
</Link>
|
||||||
</ShowcaseSection>
|
</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 ? (
|
{noticeTypeCode ? (
|
||||||
<ShowcaseSection title="Notice type code" {...SHOWCASE_CENTERED_BODY}>
|
<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">
|
<span className="font-medium text-dark dark:text-white">
|
||||||
{noticeTypeCode}
|
{tender.estimated_value_vat_basis}
|
||||||
</span>
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
) : null}
|
) : null}
|
||||||
{mainClassification ? (
|
|
||||||
<ShowcaseSection
|
{durationLabel ? (
|
||||||
title="Main classification (CPV)"
|
<ShowcaseSection title="Duration" {...SHOWCASE_CENTERED_BODY}>
|
||||||
{...SHOWCASE_CENTERED_BODY}
|
<span className="font-medium text-dark dark:text-white">{durationLabel}</span>
|
||||||
>
|
|
||||||
<span className="font-medium text-dark dark:text-white">
|
|
||||||
{mainClassification}
|
|
||||||
</span>
|
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{relatedNoticeIds.length > 0 ? (
|
{relatedNoticeIds.length > 0 ? (
|
||||||
<ShowcaseSection
|
<ShowcaseSection
|
||||||
title="Related notice publications"
|
title="Related notice publications"
|
||||||
@@ -122,6 +246,78 @@ export function TenderProcurementSection({ tender }: TenderProcurementSectionPro
|
|||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
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 Link from "next/link";
|
||||||
import {
|
import {
|
||||||
HEADER_SCROLL_MARGIN,
|
HEADER_SCROLL_MARGIN,
|
||||||
SHOWCASE_CENTERED_BODY,
|
SHOWCASE_CENTERED_BODY,
|
||||||
TENDER_SECTION_IDS,
|
TENDER_SECTION_IDS,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import {
|
||||||
|
hasAddressContent,
|
||||||
|
hasWinningTendererContent,
|
||||||
|
isNonEmptyTrimmed,
|
||||||
|
isValidHttpUrl,
|
||||||
|
isValidIsoDateTimeString,
|
||||||
|
isValidPositiveUnix,
|
||||||
|
shouldShowExternalLinksBlock,
|
||||||
|
} from "../tender-display-utils";
|
||||||
import { ExternalLinkIcon } from "./external-link-icon";
|
import { ExternalLinkIcon } from "./external-link-icon";
|
||||||
import { SectionHeading } from "./section-heading";
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
@@ -14,58 +23,212 @@ type TenderTimelineSectionProps = {
|
|||||||
tender: TTenderDetails;
|
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) {
|
export function TenderTimelineSection({ tender }: TenderTimelineSectionProps) {
|
||||||
const submissionUrl = tender.submission_url?.trim();
|
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 (
|
return (
|
||||||
<div id={TENDER_SECTION_IDS.timeline} className={HEADER_SCROLL_MARGIN}>
|
<div id={TENDER_SECTION_IDS.timeline} className={HEADER_SCROLL_MARGIN}>
|
||||||
<SectionHeading>Timeline</SectionHeading>
|
<SectionHeading>Timeline</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
<ShowcaseSection title="Application deadline" {...SHOWCASE_CENTERED_BODY}>
|
{isValidPositiveUnix(tender.award_date) ? (
|
||||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
<ShowcaseSection title="Award date" {...SHOWCASE_CENTERED_BODY}>
|
||||||
{unixToDate({
|
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||||
unix: tender.application_deadline ?? 0,
|
{unixToDate({
|
||||||
hasTime: true,
|
unix: tender.award_date!,
|
||||||
})}
|
hasTime: true,
|
||||||
</time>
|
})}
|
||||||
</ShowcaseSection>
|
</time>
|
||||||
<ShowcaseSection title="Publication date" {...SHOWCASE_CENTERED_BODY}>
|
</ShowcaseSection>
|
||||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
) : null}
|
||||||
{unixToDate({
|
|
||||||
unix: tender.publication_date ?? 0,
|
{showWinnerBlock ? (
|
||||||
hasTime: true,
|
<ShowcaseSection
|
||||||
})}
|
title="Winning tenderer"
|
||||||
</time>
|
rootClassName="sm:col-span-2 lg:col-span-3 xl:col-span-5"
|
||||||
</ShowcaseSection>
|
className="text-start"
|
||||||
<ShowcaseSection title="Submission deadline" {...SHOWCASE_CENTERED_BODY}>
|
>
|
||||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
{winnerName ? (
|
||||||
{unixToDate({
|
<p className="font-medium text-dark dark:text-white">{winnerName}</p>
|
||||||
unix: tender.submission_deadline ?? 0,
|
) : null}
|
||||||
hasTime: true,
|
{winnerLines.length > 0 ? (
|
||||||
})}
|
<div className={winnerName ? "mt-2 space-y-1" : "space-y-1"}>
|
||||||
</time>
|
{winnerLines.map((line, i) => (
|
||||||
</ShowcaseSection>
|
<p
|
||||||
<ShowcaseSection title="Tender deadline" {...SHOWCASE_CENTERED_BODY}>
|
key={`w-${i}`}
|
||||||
<time className="font-medium tabular-nums text-dark dark:text-white">
|
className="text-sm text-dark dark:text-white/90"
|
||||||
{unixToDate({
|
>
|
||||||
unix: tender.tender_deadline ?? 0,
|
{line}
|
||||||
hasTime: true,
|
</p>
|
||||||
})}
|
))}
|
||||||
</time>
|
</div>
|
||||||
</ShowcaseSection>
|
) : null}
|
||||||
{submissionUrl && (
|
</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}>
|
<ShowcaseSection title="Submission URL" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<Link
|
<Link
|
||||||
href={submissionUrl}
|
href={submissionUrl!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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" />
|
<ExternalLinkIcon className="shrink-0" />
|
||||||
{truncateString(submissionUrl, 42)}
|
{truncateString(submissionUrl!, 42)}
|
||||||
</Link>
|
</Link>
|
||||||
</ShowcaseSection>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||||
|
import {
|
||||||
|
isNonEmptyTrimmed,
|
||||||
|
shouldShowTranslationSection,
|
||||||
|
} from "../tender-display-utils";
|
||||||
import { SectionHeading } from "./section-heading";
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
type TenderTranslationSectionProps = {
|
type TenderTranslationSectionProps = {
|
||||||
@@ -9,18 +13,33 @@ type TenderTranslationSectionProps = {
|
|||||||
export function TenderTranslationSection({
|
export function TenderTranslationSection({
|
||||||
tender,
|
tender,
|
||||||
}: TenderTranslationSectionProps) {
|
}: TenderTranslationSectionProps) {
|
||||||
|
if (!shouldShowTranslationSection(tender)) return null;
|
||||||
|
|
||||||
|
const showNoticeLang = isNonEmptyTrimmed(tender.notice_language_code);
|
||||||
|
const showResolved = isNonEmptyTrimmed(tender.language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={TENDER_SECTION_IDS.translation} className={HEADER_SCROLL_MARGIN}>
|
<div id={TENDER_SECTION_IDS.translation} className={HEADER_SCROLL_MARGIN}>
|
||||||
<SectionHeading>Translation</SectionHeading>
|
<SectionHeading>Translation</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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">
|
{showNoticeLang ? (
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
<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">
|
||||||
Resolved language
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
</p>
|
Notice language
|
||||||
<p className="text-sm text-dark dark:text-white">
|
</p>
|
||||||
{tender.language || "original"}
|
<p className="text-sm text-dark dark:text-white">
|
||||||
</p>
|
{tender.notice_language_code}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
|
|||||||
|
|
||||||
export const TENDER_SECTION_IDS = {
|
export const TENDER_SECTION_IDS = {
|
||||||
procurement: "tender-section-procurement",
|
procurement: "tender-section-procurement",
|
||||||
|
lots: "tender-section-lots",
|
||||||
timeline: "tender-section-timeline",
|
timeline: "tender-section-timeline",
|
||||||
location: "tender-section-location",
|
location: "tender-section-location",
|
||||||
translation: "tender-section-translation",
|
translation: "tender-section-translation",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useTenderDetailQuery,
|
useTenderDetailQuery,
|
||||||
useTranslateTenderMutation,
|
useTranslateTenderMutation,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { use, useCallback, useMemo } from "react";
|
import { use, useCallback, useMemo } from "react";
|
||||||
import { TenderDetailsContent } from "./components/tender-details-content";
|
import { TenderDetailsContent } from "./components/tender-details-content";
|
||||||
@@ -93,7 +94,7 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
return <TenderDetailsError breadcrumbItems={breadcrumbItems} />;
|
return <TenderDetailsError breadcrumbItems={breadcrumbItems} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tender = data!.data;
|
const tender: TTenderDetails = data!.data;
|
||||||
|
|
||||||
return (
|
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 type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
import { TENDER_SECTION_IDS } from "./constants";
|
import { TENDER_SECTION_IDS } from "./constants";
|
||||||
import type { TenderNavItem } from "./types";
|
import type { TenderNavItem } from "./types";
|
||||||
|
import {
|
||||||
|
isValidRegionCode,
|
||||||
|
shouldShowAiSummarySection,
|
||||||
|
shouldShowDescriptionSection,
|
||||||
|
shouldShowLotsSection,
|
||||||
|
shouldShowTranslationSection,
|
||||||
|
} from "./tender-display-utils";
|
||||||
|
|
||||||
export const isValidDisplayCountryCode = (code: string) =>
|
export const isValidDisplayCountryCode = (code: string) =>
|
||||||
/^[A-Za-z]{2,3}$/.test(code);
|
/^[A-Za-z]{2,3}$/.test(code);
|
||||||
@@ -14,7 +21,8 @@ function analyzeLocationVisibility(tender: TTenderDetails) {
|
|||||||
typeof tender.estimated_value === "number" &&
|
typeof tender.estimated_value === "number" &&
|
||||||
Number.isFinite(tender.estimated_value) &&
|
Number.isFinite(tender.estimated_value) &&
|
||||||
tender.estimated_value > 0;
|
tender.estimated_value > 0;
|
||||||
const showLocation = hasCountry || hasCurrency || hasEst;
|
const hasRegion = isValidRegionCode(tender.region_code);
|
||||||
|
const showLocation = hasCountry || hasCurrency || hasEst || hasRegion;
|
||||||
return { showLocation };
|
return { showLocation };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,22 +35,29 @@ export function getTenderNavItems(
|
|||||||
|
|
||||||
const items: TenderNavItem[] = [
|
const items: TenderNavItem[] = [
|
||||||
{ id: TENDER_SECTION_IDS.procurement, label: "Procurement" },
|
{ 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) {
|
if (showLocation) {
|
||||||
items.push({
|
items.push({
|
||||||
id: TENDER_SECTION_IDS.location,
|
id: TENDER_SECTION_IDS.location,
|
||||||
label: "Location & currency",
|
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) {
|
if (showDocuments) {
|
||||||
items.push({ id: TENDER_SECTION_IDS.documents, label: "Documents" });
|
items.push({ id: TENDER_SECTION_IDS.documents, label: "Documents" });
|
||||||
}
|
}
|
||||||
items.push(
|
if (shouldShowDescriptionSection(tender)) {
|
||||||
{ id: TENDER_SECTION_IDS.description, label: "Description" },
|
items.push({ id: TENDER_SECTION_IDS.description, label: "Description" });
|
||||||
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
|
}
|
||||||
);
|
if (shouldShowAiSummarySection(tender)) {
|
||||||
|
items.push({ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" });
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-32
@@ -1,37 +1,111 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const TenderSchema = z.object({
|
/** TED / eForms notice address block */
|
||||||
id: z.string(),
|
export const TenderAddressSchema = z
|
||||||
buyer_organization: z.object({
|
.object({
|
||||||
name: z.string(),
|
street_name: z.string().optional(),
|
||||||
}),
|
city_name: z.string().optional(),
|
||||||
application_deadline: z.number(),
|
postal_zone: z.string().optional(),
|
||||||
country_code: z.string(),
|
country_subentity_code: z.string().optional(),
|
||||||
currency: z.string(),
|
country_code: z.string().optional(),
|
||||||
description: z.string(),
|
})
|
||||||
duration: z.string(),
|
.passthrough();
|
||||||
duration_unit: z.string(),
|
|
||||||
estimated_value: z.number(),
|
export type TTenderAddress = z.infer<typeof TenderAddressSchema>;
|
||||||
notice_publication_id: z.string(),
|
|
||||||
/** Related TED notice publication IDs (e.g. modifications, same procedure). */
|
export const BuyerOrganizationSchema = z
|
||||||
related_notice_publication_ids: z.array(z.string()).optional(),
|
.object({
|
||||||
/** TED form / notice type code (e.g. can-modif). */
|
name: z.string().default(""),
|
||||||
notice_type_code: z.string().optional(),
|
company_id: z.string().optional(),
|
||||||
/** Primary CPV classification code. */
|
website_uri: z.string().optional(),
|
||||||
main_classification: z.string().optional(),
|
contact_name: z.string().optional(),
|
||||||
procedure_code: z.string(),
|
contact_telephone: z.string().optional(),
|
||||||
procurement_type_code: z.string(),
|
contact_email: z.string().optional(),
|
||||||
publication_date: z.number(),
|
address: TenderAddressSchema.optional(),
|
||||||
status: z.string(),
|
})
|
||||||
submission_deadline: z.number(),
|
.passthrough();
|
||||||
submission_url: z.string(),
|
|
||||||
tender_deadline: z.number(),
|
export type TBuyerOrganization = z.infer<typeof BuyerOrganizationSchema>;
|
||||||
created_at: z.number().optional(),
|
|
||||||
tender_id: z.string(),
|
export const WinningTendererSchema = z
|
||||||
title: z.string(),
|
.object({
|
||||||
language: z.string().optional(),
|
name: z.string().optional(),
|
||||||
overall_summary: 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({
|
export const TenderDocumentsResponseSchema = z.object({
|
||||||
filename: z.string(),
|
filename: z.string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user