diff --git a/src/app/tenders/[details]/components/tender-ai-summary-section.tsx b/src/app/tenders/[details]/components/tender-ai-summary-section.tsx index f8c67ec..7444402 100644 --- a/src/app/tenders/[details]/components/tender-ai-summary-section.tsx +++ b/src/app/tenders/[details]/components/tender-ai-summary-section.tsx @@ -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 (
AI summary

- {tender.overall_summary || "Summary not ready yet"} + {tender.overall_summary}

diff --git a/src/app/tenders/[details]/components/tender-description-section.tsx b/src/app/tenders/[details]/components/tender-description-section.tsx index 91d5f83..7cb1bb9 100644 --- a/src/app/tenders/[details]/components/tender-description-section.tsx +++ b/src/app/tenders/[details]/components/tender-description-section.tsx @@ -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 (
Description

- {tender.description || "—"} + {tender.description}

diff --git a/src/app/tenders/[details]/components/tender-details-content.tsx b/src/app/tenders/[details]/components/tender-details-content.tsx index d54b431..40db137 100644 --- a/src/app/tenders/[details]/components/tender-details-content.tsx +++ b/src/app/tenders/[details]/components/tender-details-content.tsx @@ -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({ + {shouldShowLotsSection(tender) ? ( + + ) : null} {showLocation && } diff --git a/src/app/tenders/[details]/components/tender-location-section.tsx b/src/app/tenders/[details]/components/tender-location-section.tsx index 93e1493..8072d45 100644 --- a/src/app/tenders/[details]/components/tender-location-section.tsx +++ b/src/app/tenders/[details]/components/tender-location-section.tsx @@ -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 (
Location & currency
+ {hasValidRegion && ( +
+
+

+ Region (NUTS) +

+

+ {regionNorm} +

+
+ )} + {hasValidCountryCodeForDisplay && (
diff --git a/src/app/tenders/[details]/components/tender-lots-section.tsx b/src/app/tenders/[details]/components/tender-lots-section.tsx new file mode 100644 index 0000000..8ef15ae --- /dev/null +++ b/src/app/tenders/[details]/components/tender-lots-section.tsx @@ -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 ( +
+ Lots +
+ {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 ( +
+

+ {isNonEmptyTrimmed(lot.lot_id) ? lot.lot_id : `Lot ${idx + 1}`} +

+
+ {isNonEmptyTrimmed(lot.title) ? ( + + + {lot.title} + + + ) : null} + {isNonEmptyTrimmed(lot.description) ? ( + +

+ {lot.description} +

+
+ ) : null} + {isNonEmptyTrimmed(lot.main_nature_of_contract) ? ( + + + {lot.main_nature_of_contract} + + + ) : null} + {cpv ? ( + + {cpv} + + ) : null} + {hasValue ? ( + + + {new Intl.NumberFormat().format(lot.estimated_value!)} + {currency ? ( + + {currency} + + ) : null} + + + ) : null} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-procurement-section.tsx b/src/app/tenders/[details]/components/tender-procurement-section.tsx index b7f6eb4..25eb88b 100644 --- a/src/app/tenders/[details]/components/tender-procurement-section.tsx +++ b/src/app/tenders/[details]/components/tender-procurement-section.tsx @@ -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 (
Procurement
- - - {tender.tender_id} - - - - - {tender.buyer_organization.name} - - - - - - {tender.notice_publication_id} - - - - - - {tender.procedure_code} - - - - - {tender.procurement_type_code} - - + {isNonEmptyTrimmed(tender.tender_id) ? ( + + {tender.tender_id} + + ) : null} + + {isNonEmptyTrimmed(tender.project_name) ? ( + + + {tender.project_name} + + + ) : null} + + {showBuyerOrgName ? ( + + {buyer.name} + + ) : null} + + {noticePubId ? ( + + + + {noticePubId} + + + + ) : null} + + {isNonEmptyTrimmed(tender.procurement_project_id) ? ( + + + {tender.procurement_project_id} + + + ) : null} + + {isNonEmptyTrimmed(tender.contract_folder_id) ? ( + + + {tender.contract_folder_id} + + + ) : null} + + {isNonEmptyTrimmed(tender.contract_notice_id) ? ( + + + {tender.contract_notice_id} + + + ) : null} + + {isNonEmptyTrimmed(tender.notice_identifier) ? ( + + + {tender.notice_identifier} + + + ) : null} + + {isNonEmptyTrimmed(tender.notice_version) ? ( + + + {tender.notice_version} + + + ) : null} + + {isNonEmptyTrimmed(tender.gazette_id) ? ( + + {tender.gazette_id} + + ) : null} + + {isNonEmptyTrimmed(tender.form_type) ? ( + + {tender.form_type} + + ) : null} + + {isNonEmptyTrimmed(tender.notice_sub_type_code) ? ( + + + {tender.notice_sub_type_code} + + + ) : null} + + {isNonEmptyTrimmed(tender.source) ? ( + + {tender.source} + + ) : null} + + {isNonEmptyTrimmed(tender.procedure_code) ? ( + + {tender.procedure_code} + + ) : null} + + {isNonEmptyTrimmed(tender.procurement_type_code) ? ( + + + {tender.procurement_type_code} + + + ) : null} + {noticeTypeCode ? ( + {noticeTypeCode} + + ) : null} + + {mainCpv ? ( + + {mainCpv} + + ) : null} + + {isNonEmptyTrimmed(tender.estimated_value_vat_basis) ? ( + - {noticeTypeCode} + {tender.estimated_value_vat_basis} ) : null} - {mainClassification ? ( - - - {mainClassification} - + + {durationLabel ? ( + + {durationLabel} ) : null} + {relatedNoticeIds.length > 0 ? ( ) : null}
+ + {showBuyerExtended ? ( +
+

+ Buyer contact & address +

+
+ {isNonEmptyTrimmed(buyer.company_id) ? ( + + {buyer.company_id} + + ) : null} + {isValidHttpUrl(buyer.website_uri) ? ( + + + {buyer.website_uri!.trim()} + + + ) : null} + {isNonEmptyTrimmed(buyer.contact_name) ? ( + + + {buyer.contact_name} + + + ) : null} + {isNonEmptyTrimmed(buyer.contact_telephone) ? ( + + + {buyer.contact_telephone} + + + ) : null} + {isNonEmptyTrimmed(buyer.contact_email) ? ( + + + {buyer.contact_email!.trim()} + + + ) : null} + {buyerAddressLines.length > 0 ? ( + +
+ {buyerAddressLines.map((line, i) => ( +
+ {line} +
+ ))} +
+
+ ) : null} +
+
+ ) : null}
); } diff --git a/src/app/tenders/[details]/components/tender-timeline-section.tsx b/src/app/tenders/[details]/components/tender-timeline-section.tsx index 3e33640..0469393 100644 --- a/src/app/tenders/[details]/components/tender-timeline-section.tsx +++ b/src/app/tenders/[details]/components/tender-timeline-section.tsx @@ -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 (
Timeline
- - - - - - - - - - - - - {submissionUrl && ( + {isValidPositiveUnix(tender.award_date) ? ( + + + + ) : null} + + {showWinnerBlock ? ( + + {winnerName ? ( +

{winnerName}

+ ) : null} + {winnerLines.length > 0 ? ( +
+ {winnerLines.map((line, i) => ( +

+ {line} +

+ ))} +
+ ) : null} +
+ ) : null} + + {dispatchLabel ? ( + + + + ) : null} + + {isValidPositiveUnix(tender.issue_date) ? ( + + + + ) : null} + + {isValidPositiveUnix(tender.issue_time) ? ( + + + + ) : null} + + {deadlineCards.map((d) => ( + + + + ))} + + {showSubmissionUrl ? ( - {truncateString(submissionUrl, 42)} + {truncateString(submissionUrl!, 42)} - )} + ) : null} + + {showExternal ? ( + +
    + {isValidHttpUrl(tender.document_url) ? ( +
  • + + + Document (TED) + +
  • + ) : null} + {isValidHttpUrl(tender.tender_url) ? ( +
  • + + + {truncateString(tender.tender_url!.trim(), 56)} + +
  • + ) : null} + {isValidHttpUrl(tender.buyer_profile_url) ? ( +
  • + + + Buyer profile + +
  • + ) : null} +
+
+ ) : null}
); diff --git a/src/app/tenders/[details]/components/tender-translation-section.tsx b/src/app/tenders/[details]/components/tender-translation-section.tsx index ad9fd92..bb3b34f 100644 --- a/src/app/tenders/[details]/components/tender-translation-section.tsx +++ b/src/app/tenders/[details]/components/tender-translation-section.tsx @@ -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 (
Translation
-
-

- Resolved language -

-

- {tender.language || "original"} -

-
+ {showNoticeLang ? ( +
+

+ Notice language +

+

+ {tender.notice_language_code} +

+
+ ) : null} + {showResolved ? ( +
+

+ Resolved language +

+

{tender.language}

+
+ ) : null}
); diff --git a/src/app/tenders/[details]/constants.ts b/src/app/tenders/[details]/constants.ts index b82709f..83dc12e 100644 --- a/src/app/tenders/[details]/constants.ts +++ b/src/app/tenders/[details]/constants.ts @@ -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", diff --git a/src/app/tenders/[details]/page.tsx b/src/app/tenders/[details]/page.tsx index dfe4e7f..31e4fc5 100644 --- a/src/app/tenders/[details]/page.tsx +++ b/src/app/tenders/[details]/page.tsx @@ -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 ; } - const tender = data!.data; + const tender: TTenderDetails = data!.data; return ( <> diff --git a/src/app/tenders/[details]/tender-display-utils.ts b/src/app/tenders/[details]/tender-display-utils.ts new file mode 100644 index 0000000..c808834 --- /dev/null +++ b/src/app/tenders/[details]/tender-display-utils.ts @@ -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); +} diff --git a/src/app/tenders/[details]/tender-nav-utils.ts b/src/app/tenders/[details]/tender-nav-utils.ts index 97a92ad..85d623d 100644 --- a/src/app/tenders/[details]/tender-nav-utils.ts +++ b/src/app/tenders/[details]/tender-nav-utils.ts @@ -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; } diff --git a/src/lib/api/types/Tenders.ts b/src/lib/api/types/Tenders.ts index f7742d2..5203058 100644 --- a/src/lib/api/types/Tenders.ts +++ b/src/lib/api/types/Tenders.ts @@ -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; + +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; + +export const WinningTendererSchema = z + .object({ + name: z.string().optional(), + address: TenderAddressSchema.optional(), + }) + .passthrough(); + +export type TWinningTenderer = z.infer; + +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; + +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(),