refactor(tender-details): enhance translation page tests and layout
- Updated Cypress tests for the tender translation page to improve visibility checks and streamline assertions. - Refactored test structure to utilize a dedicated function for fetching the visible title heading. - Adjusted test cases to ensure accurate language switching and URL handling for tender details. - Improved layout consistency in the translation page by refining element visibility checks and ensuring proper section rendering.
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
const tenderId = "507f1f77bcf86cd799439055";
|
const tenderId = "507f1f77bcf86cd799439055";
|
||||||
|
|
||||||
|
/** Visible title only — layout probe duplicates `<h1>` off-screen for measurement */
|
||||||
|
const tenderTitleHeading = () => cy.get("main h1").filter(":visible");
|
||||||
|
|
||||||
const makeTenderDetailsResponse = (
|
const makeTenderDetailsResponse = (
|
||||||
overrides: Partial<Record<string, any>> = {},
|
overrides: Partial<Record<string, any>> = {},
|
||||||
) => ({
|
) => ({
|
||||||
@@ -54,12 +57,18 @@ describe("tender details - translation and summary", () => {
|
|||||||
cy.visit(`/tenders/${tenderId}?lang=en`);
|
cy.visit(`/tenders/${tenderId}?lang=en`);
|
||||||
cy.wait("@getTenderDetailsEn");
|
cy.wait("@getTenderDetailsEn");
|
||||||
|
|
||||||
cy.contains("h1", "Translated title EN").should("be.visible");
|
tenderTitleHeading().should("contain.text", "Translated title EN");
|
||||||
cy.contains("Resolved language").should("be.visible");
|
cy.get("#tender-section-translation")
|
||||||
cy.contains("en").should("be.visible");
|
.should("be.visible")
|
||||||
cy.contains("AI summary").should("be.visible");
|
.and("contain.text", "Resolved language")
|
||||||
cy.contains("Summary not ready yet").should("be.visible");
|
.and("contain.text", "en");
|
||||||
cy.contains("Translated description EN").should("be.visible");
|
cy.get("#tender-section-ai-summary")
|
||||||
|
.should("be.visible")
|
||||||
|
.and("contain.text", "AI summary")
|
||||||
|
.and("contain.text", "Summary not ready yet");
|
||||||
|
cy.get("#tender-section-description")
|
||||||
|
.should("be.visible")
|
||||||
|
.and("contain.text", "Translated description EN");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches language from selector and reloads details with new lang query", () => {
|
it("switches language from selector and reloads details with new lang query", () => {
|
||||||
@@ -83,7 +92,7 @@ describe("tender details - translation and summary", () => {
|
|||||||
cy.get('select[name="lang"]').select("fr");
|
cy.get('select[name="lang"]').select("fr");
|
||||||
cy.location("search").should("include", "lang=fr");
|
cy.location("search").should("include", "lang=fr");
|
||||||
cy.wait("@getTenderDetailsFr");
|
cy.wait("@getTenderDetailsFr");
|
||||||
cy.contains("h1", "Titre FR").should("be.visible");
|
tenderTitleHeading().should("contain.text", "Titre FR");
|
||||||
cy.contains("Resume FR").should("be.visible");
|
cy.contains("Resume FR").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,22 +133,19 @@ describe("tender details - translation and summary", () => {
|
|||||||
cy.wait("@translateTender");
|
cy.wait("@translateTender");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects legacy /translation URL to tender details with lang preserved", () => {
|
it("loads tender details with lang query on the canonical URL", () => {
|
||||||
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
|
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: makeTenderDetailsResponse({
|
body: makeTenderDetailsResponse({
|
||||||
title: "Redirect check",
|
title: "Lang query check",
|
||||||
language: "fa",
|
language: "fa",
|
||||||
}),
|
}),
|
||||||
}).as("getTenderAfterRedirect");
|
}).as("getTenderWithLang");
|
||||||
|
|
||||||
cy.visit(`/tenders/${tenderId}/translation?lang=fa`);
|
cy.visit(`/tenders/${tenderId}?lang=fa`);
|
||||||
cy.location("pathname", { timeout: 15000 }).should(
|
cy.location("pathname").should("eq", `/tenders/${tenderId}`);
|
||||||
"eq",
|
|
||||||
`/tenders/${tenderId}`,
|
|
||||||
);
|
|
||||||
cy.location("search").should("include", "lang=fa");
|
cy.location("search").should("include", "lang=fa");
|
||||||
cy.wait("@getTenderAfterRedirect");
|
cy.wait("@getTenderWithLang");
|
||||||
cy.contains("h1", "Redirect check").should("be.visible");
|
tenderTitleHeading().should("contain.text", "Lang query check");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function ExternalLinkIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn("h-4 w-4 shrink-0", className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function SectionHeading({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-r from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
|
||||||
|
<h2 className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.2em] text-dark-5 dark:text-dark-6">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-l from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
|
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||||
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
|
type TenderAiSummarySectionProps = {
|
||||||
|
tender: TTenderDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderAiSummarySection({
|
||||||
|
tender,
|
||||||
|
}: TenderAiSummarySectionProps) {
|
||||||
|
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"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
|
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||||
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
|
type TenderDescriptionSectionProps = {
|
||||||
|
tender: TTenderDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderDescriptionSection({
|
||||||
|
tender,
|
||||||
|
}: TenderDescriptionSectionProps) {
|
||||||
|
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 || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
|
import { shouldShowLocationSection } from "../tender-nav-utils";
|
||||||
|
import type { TenderNavItem } from "../types";
|
||||||
|
import { scrollToTenderSection } from "../scroll-to-section";
|
||||||
|
import { TenderAiSummarySection } from "./tender-ai-summary-section";
|
||||||
|
import { TenderDescriptionSection } from "./tender-description-section";
|
||||||
|
import { TenderLocationSection } from "./tender-location-section";
|
||||||
|
import { TenderProcurementSection } from "./tender-procurement-section";
|
||||||
|
import { TenderSectionNavList } from "./tender-section-nav-list";
|
||||||
|
import { TenderTimelineSection } from "./tender-timeline-section";
|
||||||
|
import { TenderTranslationSection } from "./tender-translation-section";
|
||||||
|
|
||||||
|
type TenderDetailsContentProps = {
|
||||||
|
tender: TTenderDetails;
|
||||||
|
tenderNavItems: TenderNavItem[];
|
||||||
|
activeSectionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderDetailsContent({
|
||||||
|
tender,
|
||||||
|
tenderNavItems,
|
||||||
|
activeSectionId,
|
||||||
|
}: TenderDetailsContentProps) {
|
||||||
|
const showLocation = shouldShowLocationSection(tender);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-8 xl:pb-10 xl:pt-3 2xl:px-10">
|
||||||
|
<div className="xl:grid xl:grid-cols-[minmax(0,1fr)_13.5rem] xl:items-start xl:gap-10 2xl:grid-cols-[minmax(0,1fr)_15rem] 2xl:gap-12">
|
||||||
|
<div className="min-w-0 space-y-6 xl:space-y-10">
|
||||||
|
<div className="xl:hidden">
|
||||||
|
<div className="rounded-2xl border border-stroke/60 bg-white/70 p-3 shadow-sm backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40">
|
||||||
|
<TenderSectionNavList
|
||||||
|
items={tenderNavItems}
|
||||||
|
activeId={activeSectionId}
|
||||||
|
onSelect={scrollToTenderSection}
|
||||||
|
variant="strip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TenderProcurementSection tender={tender} />
|
||||||
|
<TenderTimelineSection tender={tender} />
|
||||||
|
|
||||||
|
{showLocation && <TenderLocationSection tender={tender} />}
|
||||||
|
|
||||||
|
<TenderTranslationSection tender={tender} />
|
||||||
|
<TenderDescriptionSection tender={tender} />
|
||||||
|
<TenderAiSummarySection tender={tender} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="sticky top-24 z-10 mt-2 hidden min-w-0 self-start xl:block">
|
||||||
|
<div className="shadow-theme-xs max-h-[calc(100vh-7rem)] overflow-y-auto rounded-2xl border border-stroke/60 bg-white/80 p-4 backdrop-blur-md dark:border-dark-3 dark:bg-dark-2/50">
|
||||||
|
<TenderSectionNavList
|
||||||
|
items={tenderNavItems}
|
||||||
|
activeId={activeSectionId}
|
||||||
|
onSelect={scrollToTenderSection}
|
||||||
|
variant="rail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||||
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type BreadcrumbItem = { name: string; href: string };
|
||||||
|
|
||||||
|
export function TenderDetailsError({
|
||||||
|
breadcrumbItems,
|
||||||
|
}: {
|
||||||
|
breadcrumbItems: BreadcrumbItem[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
<ShowcaseSection className="!p-8 sm:!p-10">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||||
|
<p className="text-lg font-semibold text-dark dark:text-white">
|
||||||
|
We couldn't load this tender
|
||||||
|
</p>
|
||||||
|
<p className="max-w-sm text-sm text-dark-5 dark:text-dark-6">
|
||||||
|
Check your connection or try again. You can go back to the tender
|
||||||
|
list anytime.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/tenders"
|
||||||
|
className="shadow-theme-xs mt-2 inline-flex items-center justify-center rounded-xl bg-primary px-5 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Back to tenders
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</ShowcaseSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import Status from "@/components/ui/Status";
|
||||||
|
import { Languages } from "@/constants/languages";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import type { FieldValues } from "react-hook-form";
|
||||||
|
import { TENDER_HEADER_TITLE_CLASS } from "../constants";
|
||||||
|
import { useStackTenderHeaderActions } from "../hooks/use-stack-tender-header-actions";
|
||||||
|
|
||||||
|
type LanguageSelectOnChange = NonNullable<
|
||||||
|
ComponentProps<typeof Select<FieldValues>>["onChange"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
type TenderDetailsHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
selectedLanguage: string;
|
||||||
|
onLanguageChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
|
onTranslate: () => void;
|
||||||
|
isTranslating: boolean;
|
||||||
|
status: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderDetailsHeader({
|
||||||
|
title,
|
||||||
|
selectedLanguage,
|
||||||
|
onLanguageChange,
|
||||||
|
onTranslate,
|
||||||
|
isTranslating,
|
||||||
|
status,
|
||||||
|
}: TenderDetailsHeaderProps) {
|
||||||
|
const headerLayoutContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stackActionsBelow = useStackTenderHeaderActions(
|
||||||
|
title,
|
||||||
|
headerLayoutContainerRef,
|
||||||
|
headerTitleProbeSizerRef,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={headerLayoutContainerRef} className="relative">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 -z-10 max-h-0 overflow-hidden opacity-0"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div ref={headerTitleProbeSizerRef}>
|
||||||
|
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-5",
|
||||||
|
!stackActionsBelow &&
|
||||||
|
"lg:flex-row lg:items-start lg:justify-between lg:gap-8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 space-y-2",
|
||||||
|
!stackActionsBelow && "lg:flex-1",
|
||||||
|
stackActionsBelow && "lg:w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||||
|
Tender
|
||||||
|
</p>
|
||||||
|
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full min-w-0 shrink-0 flex-col gap-3",
|
||||||
|
!stackActionsBelow && "lg:max-w-2xl lg:items-end",
|
||||||
|
stackActionsBelow && "lg:w-full lg:items-stretch",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full",
|
||||||
|
stackActionsBelow ? "lg:text-start" : "lg:text-end",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Language & translation
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center",
|
||||||
|
stackActionsBelow
|
||||||
|
? "sm:justify-start lg:justify-start"
|
||||||
|
: "sm:justify-end lg:justify-end",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select<FieldValues>
|
||||||
|
name="lang"
|
||||||
|
label="Display language"
|
||||||
|
items={Languages}
|
||||||
|
className={cn(
|
||||||
|
"w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]",
|
||||||
|
"[&_label]:sr-only",
|
||||||
|
)}
|
||||||
|
value={selectedLanguage}
|
||||||
|
onChange={onLanguageChange as LanguageSelectOnChange}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
shape="rounded"
|
||||||
|
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
||||||
|
label={
|
||||||
|
isTranslating ? "Translating..." : "Translate metadata"
|
||||||
|
}
|
||||||
|
disabled={isTranslating}
|
||||||
|
onClick={onTranslate}
|
||||||
|
/>
|
||||||
|
<div className="flex shrink-0 items-center sm:min-h-[3.375rem]">
|
||||||
|
<Status status={status ?? "Unknown"}>{status}</Status>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LocationIcon } from "@/assets/icons";
|
||||||
|
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries";
|
||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
|
import { isValidAlpha2CountryCode } from "@/utils/shared";
|
||||||
|
import getSymbolFromCurrency from "currency-symbol-map";
|
||||||
|
import {
|
||||||
|
HEADER_SCROLL_MARGIN,
|
||||||
|
TENDER_SECTION_IDS,
|
||||||
|
} from "../constants";
|
||||||
|
import { isValidDisplayCountryCode } from "../tender-nav-utils";
|
||||||
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
|
type TenderLocationSectionProps = {
|
||||||
|
tender: TTenderDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderLocationSection({ tender }: TenderLocationSectionProps) {
|
||||||
|
const countryCodeNormalized =
|
||||||
|
tender.country_code?.trim().toUpperCase() ?? "";
|
||||||
|
const hasValidCountryCodeForDisplay = isValidDisplayCountryCode(
|
||||||
|
countryCodeNormalized,
|
||||||
|
);
|
||||||
|
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
|
||||||
|
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
|
||||||
|
enabled: canLoadCountryFlag,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currencyCode = tender.currency?.trim().toUpperCase() ?? "";
|
||||||
|
const hasValidCurrency = Boolean(currencyCode);
|
||||||
|
const hasValidEstimatedValue =
|
||||||
|
typeof tender.estimated_value === "number" &&
|
||||||
|
Number.isFinite(tender.estimated_value) &&
|
||||||
|
tender.estimated_value > 0;
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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" />
|
||||||
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
|
Country
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/80 text-primary shadow-inner dark:bg-dark-2/80">
|
||||||
|
<LocationIcon />
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
|
||||||
|
{countryCodeNormalized}
|
||||||
|
</span>
|
||||||
|
{flagData && (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: flagData }}
|
||||||
|
className="flex h-8 w-11 items-center justify-center overflow-hidden rounded-lg border border-stroke/60 shadow-sm dark:border-dark-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasValidCurrency && (
|
||||||
|
<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-blue/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-blue/[0.12]">
|
||||||
|
<div className="pointer-events-none absolute -left-6 bottom-0 h-20 w-20 rounded-full bg-blue/15 blur-2xl dark:bg-blue/25" />
|
||||||
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
|
Currency
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
||||||
|
{currencyCode}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-medium text-dark-5 dark:text-dark-6">
|
||||||
|
{getSymbolFromCurrency(currencyCode || "USD")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasValidEstimatedValue && (
|
||||||
|
<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-green/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-green/[0.12]">
|
||||||
|
<div className="pointer-events-none absolute -right-6 bottom-0 h-20 w-20 rounded-full bg-green/15 blur-2xl dark:bg-green/25" />
|
||||||
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
|
Estimated value
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
||||||
|
{new Intl.NumberFormat().format(tender.estimated_value)}
|
||||||
|
</span>
|
||||||
|
{hasValidCurrency && (
|
||||||
|
<span className="text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
|
||||||
|
{currencyCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
|
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 { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
|
type TenderProcurementSectionProps = {
|
||||||
|
tender: TTenderDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderProcurementSection({ tender }: TenderProcurementSectionProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={TENDER_SECTION_IDS.procurement}
|
||||||
|
className={HEADER_SCROLL_MARGIN}
|
||||||
|
>
|
||||||
|
<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={`https://ted.europa.eu/en/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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { TenderNavItem } from "../types";
|
||||||
|
|
||||||
|
export function TenderSectionNavList({
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
onSelect,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
items: TenderNavItem[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
variant: "rail" | "strip";
|
||||||
|
}) {
|
||||||
|
if (variant === "strip") {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Jump to section"
|
||||||
|
className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:-mx-6 sm:px-6"
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = activeId === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full border px-3.5 py-2 text-xs font-semibold tracking-wide transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||||
|
isActive
|
||||||
|
? "border-primary/35 bg-primary/10 text-primary shadow-sm dark:border-primary/40 dark:bg-primary/15"
|
||||||
|
: "border-stroke/70 bg-white/80 text-dark-5 hover:border-primary/25 hover:text-dark dark:border-dark-3 dark:bg-dark-2/60 dark:text-dark-6 dark:hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="On this page" className="space-y-1">
|
||||||
|
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.22em] text-dark-5 dark:text-dark-6">
|
||||||
|
On this page
|
||||||
|
</p>
|
||||||
|
<ul className="relative space-y-0.5 border-l border-stroke/60 pl-3 dark:border-dark-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = activeId === item.id;
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex w-full items-center rounded-lg py-2 pl-2.5 pr-2 text-left text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/[0.08] text-primary dark:bg-primary/10"
|
||||||
|
: "text-dark-5 hover:bg-gray-2/80 hover:text-dark dark:text-dark-6 dark:hover:bg-dark-3/50 dark:hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -left-px top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full transition",
|
||||||
|
isActive
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-transparent group-hover:bg-stroke dark:group-hover:bg-dark-3",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="line-clamp-2">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
|
import { truncateString, unixToDate } from "@/utils/shared";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
HEADER_SCROLL_MARGIN,
|
||||||
|
SHOWCASE_CENTERED_BODY,
|
||||||
|
TENDER_SECTION_IDS,
|
||||||
|
} from "../constants";
|
||||||
|
import { ExternalLinkIcon } from "./external-link-icon";
|
||||||
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
|
type TenderTimelineSectionProps = {
|
||||||
|
tender: TTenderDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderTimelineSection({ tender }: TenderTimelineSectionProps) {
|
||||||
|
const submissionUrl = tender.submission_url?.trim();
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<ShowcaseSection title="Submission URL" {...SHOWCASE_CENTERED_BODY}>
|
||||||
|
<Link
|
||||||
|
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)}
|
||||||
|
</Link>
|
||||||
|
</ShowcaseSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
|
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||||
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
|
type TenderTranslationSectionProps = {
|
||||||
|
tender: TTenderDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenderTranslationSection({
|
||||||
|
tender,
|
||||||
|
}: TenderTranslationSectionProps) {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Matches the tender title `<h1>` in the header — keep in sync for layout probe */
|
||||||
|
export const TENDER_HEADER_TITLE_CLASS =
|
||||||
|
"text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]";
|
||||||
|
|
||||||
|
/** Reserved width for the actions column when deciding side‑by‑side vs stacked (lg+) */
|
||||||
|
export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
|
||||||
|
|
||||||
|
export const TENDER_SECTION_IDS = {
|
||||||
|
procurement: "tender-section-procurement",
|
||||||
|
timeline: "tender-section-timeline",
|
||||||
|
location: "tender-section-location",
|
||||||
|
translation: "tender-section-translation",
|
||||||
|
description: "tender-section-description",
|
||||||
|
aiSummary: "tender-section-ai-summary",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const HEADER_SCROLL_MARGIN = "scroll-mt-[5.5rem]";
|
||||||
|
|
||||||
|
/** Body area only: center content in the card; ShowcaseSection `title` is unchanged */
|
||||||
|
export const SHOWCASE_CENTERED_BODY = {
|
||||||
|
rootClassName: "flex h-full min-h-0 flex-col",
|
||||||
|
className:
|
||||||
|
"flex min-h-0 flex-1 flex-col items-center justify-center text-center",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||||
|
import { TENDER_HEADER_ACTIONS_RESERVE_PX } from "../constants";
|
||||||
|
|
||||||
|
export function useStackTenderHeaderActions(
|
||||||
|
title: string,
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>,
|
||||||
|
probeSizerRef: RefObject<HTMLDivElement | null>,
|
||||||
|
) {
|
||||||
|
const [stackBelow, setStackBelow] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const probeSizer = probeSizerRef.current;
|
||||||
|
if (!container || !probeSizer) return;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (!window.matchMedia("(min-width: 1024px)").matches) {
|
||||||
|
setStackBelow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = window.getComputedStyle(container);
|
||||||
|
const gapRaw = parseFloat(styles.columnGap || styles.gap);
|
||||||
|
const gap = Number.isFinite(gapRaw) ? gapRaw : 32;
|
||||||
|
const containerW = container.getBoundingClientRect().width;
|
||||||
|
const titleColumnW = Math.max(
|
||||||
|
160,
|
||||||
|
containerW - TENDER_HEADER_ACTIONS_RESERVE_PX - gap,
|
||||||
|
);
|
||||||
|
probeSizer.style.width = `${titleColumnW}px`;
|
||||||
|
|
||||||
|
const probeTitle = probeSizer.querySelector("h1");
|
||||||
|
if (!probeTitle) return;
|
||||||
|
|
||||||
|
const titleStyles = window.getComputedStyle(probeTitle);
|
||||||
|
const lineHeightRaw = parseFloat(titleStyles.lineHeight);
|
||||||
|
const fontSize = parseFloat(titleStyles.fontSize);
|
||||||
|
const lineHeight =
|
||||||
|
Number.isFinite(lineHeightRaw) && lineHeightRaw > 0
|
||||||
|
? lineHeightRaw
|
||||||
|
: fontSize * 1.25;
|
||||||
|
const lines = probeTitle.scrollHeight / lineHeight;
|
||||||
|
const next = lines > 1.35;
|
||||||
|
setStackBelow((p) => (p === next ? p : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
const ro = new ResizeObserver(run);
|
||||||
|
ro.observe(container);
|
||||||
|
window.addEventListener("resize", run);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", run);
|
||||||
|
};
|
||||||
|
}, [title, containerRef, probeSizerRef]);
|
||||||
|
|
||||||
|
return stackBelow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useTenderSectionScrollSpy(sectionIds: string[]) {
|
||||||
|
const [activeId, setActiveId] = useState(sectionIds[0] ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sectionIds.length === 0) {
|
||||||
|
setActiveId("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveId((prev) =>
|
||||||
|
sectionIds.includes(prev) ? prev : (sectionIds[0] ?? ""),
|
||||||
|
);
|
||||||
|
}, [sectionIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sectionIds.length === 0) return;
|
||||||
|
|
||||||
|
const pickActive = () => {
|
||||||
|
const offset = 96;
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const documentHeight = Math.max(
|
||||||
|
document.documentElement.scrollHeight,
|
||||||
|
document.body.scrollHeight,
|
||||||
|
);
|
||||||
|
const maxScroll = Math.max(1, documentHeight - viewportHeight);
|
||||||
|
|
||||||
|
if (scrollY + viewportHeight >= documentHeight - 2) {
|
||||||
|
const last = sectionIds[sectionIds.length - 1] ?? "";
|
||||||
|
setActiveId((prev) => (prev === last ? prev : last));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const naturalScrolls = sectionIds.map((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return Number.POSITIVE_INFINITY;
|
||||||
|
return Math.max(0, el.getBoundingClientRect().top + scrollY - offset);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activations: number[] = [];
|
||||||
|
let prevAdjusted = 0;
|
||||||
|
for (let i = 0; i < naturalScrolls.length; i++) {
|
||||||
|
const natural = naturalScrolls[i];
|
||||||
|
if (natural <= maxScroll) {
|
||||||
|
activations.push(natural);
|
||||||
|
prevAdjusted = natural;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remaining = naturalScrolls.length - i;
|
||||||
|
const range = Math.max(0, maxScroll - prevAdjusted);
|
||||||
|
const step = range / remaining;
|
||||||
|
for (let j = 0; j < remaining; j++) {
|
||||||
|
activations.push(prevAdjusted + step * (j + 1));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = sectionIds[0] ?? "";
|
||||||
|
for (let i = 0; i < activations.length; i++) {
|
||||||
|
if (activations[i] <= scrollY) current = sectionIds[i];
|
||||||
|
}
|
||||||
|
setActiveId((prev) => (prev === current ? prev : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
pickActive();
|
||||||
|
window.addEventListener("scroll", pickActive, { passive: true });
|
||||||
|
window.addEventListener("resize", pickActive, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", pickActive);
|
||||||
|
window.removeEventListener("resize", pickActive);
|
||||||
|
};
|
||||||
|
}, [sectionIds]);
|
||||||
|
|
||||||
|
return activeId;
|
||||||
|
}
|
||||||
@@ -1,36 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { LocationIcon } from "@/assets/icons";
|
|
||||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||||
import { Select } from "@/components/FormElements/select";
|
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import { Button } from "@/components/ui-elements/button";
|
|
||||||
import Status from "@/components/ui/Status";
|
|
||||||
import { Languages } from "@/constants/languages";
|
|
||||||
import {
|
import {
|
||||||
|
useGetTenderDocumentsQuery,
|
||||||
useTenderDetailQuery,
|
useTenderDetailQuery,
|
||||||
useTranslateTenderMutation,
|
useTranslateTenderMutation,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
isValidAlpha2CountryCode,
|
|
||||||
truncateString,
|
|
||||||
unixToDate,
|
|
||||||
} from "@/utils/shared";
|
|
||||||
import getSymbolFromCurrency from "currency-symbol-map";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import type { RefObject } from "react";
|
import { use, useCallback, useMemo } from "react";
|
||||||
import {
|
import { TenderDetailsContent } from "./components/tender-details-content";
|
||||||
use,
|
import { TenderDetailsError } from "./components/tender-details-error";
|
||||||
useCallback,
|
import { TenderDetailsHeader } from "./components/tender-details-header";
|
||||||
useEffect,
|
import { useTenderSectionScrollSpy } from "./hooks/use-tender-section-scroll-spy";
|
||||||
useLayoutEffect,
|
import { getTenderNavItems } from "./tender-nav-utils";
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -38,284 +22,6 @@ interface IProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidDisplayCountryCode = (code: string) =>
|
|
||||||
/^[A-Za-z]{2,3}$/.test(code);
|
|
||||||
|
|
||||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-4 flex items-center gap-3">
|
|
||||||
<span className="h-px flex-1 bg-gradient-to-r from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
|
|
||||||
<h2 className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.2em] text-dark-5 dark:text-dark-6">
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
<span className="h-px flex-1 bg-gradient-to-l from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TENDER_SECTION_IDS = {
|
|
||||||
procurement: "tender-section-procurement",
|
|
||||||
timeline: "tender-section-timeline",
|
|
||||||
location: "tender-section-location",
|
|
||||||
translation: "tender-section-translation",
|
|
||||||
description: "tender-section-description",
|
|
||||||
aiSummary: "tender-section-ai-summary",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type TenderNavItem = { id: string; label: string };
|
|
||||||
|
|
||||||
const HEADER_SCROLL_MARGIN = "scroll-mt-[5.5rem]";
|
|
||||||
|
|
||||||
function useTenderSectionScrollSpy(sectionIds: string[]) {
|
|
||||||
const [activeId, setActiveId] = useState(sectionIds[0] ?? "");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sectionIds.length === 0) {
|
|
||||||
setActiveId("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveId((prev) =>
|
|
||||||
sectionIds.includes(prev) ? prev : (sectionIds[0] ?? ""),
|
|
||||||
);
|
|
||||||
}, [sectionIds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sectionIds.length === 0) return;
|
|
||||||
|
|
||||||
const pickActive = () => {
|
|
||||||
const offset = 96;
|
|
||||||
const scrollY = window.scrollY;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const documentHeight = Math.max(
|
|
||||||
document.documentElement.scrollHeight,
|
|
||||||
document.body.scrollHeight,
|
|
||||||
);
|
|
||||||
const maxScroll = Math.max(1, documentHeight - viewportHeight);
|
|
||||||
|
|
||||||
// Safety snap: at the very bottom of the page the last section is the
|
|
||||||
// only thing the user can possibly be looking at.
|
|
||||||
if (scrollY + viewportHeight >= documentHeight - 2) {
|
|
||||||
const last = sectionIds[sectionIds.length - 1] ?? "";
|
|
||||||
setActiveId((prev) => (prev === last ? prev : last));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Natural scroll position at which each section's heading reaches the
|
|
||||||
// sticky-header offset line (i.e. would normally become "active").
|
|
||||||
const naturalScrolls = sectionIds.map((id) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return Number.POSITIVE_INFINITY;
|
|
||||||
return Math.max(0, el.getBoundingClientRect().top + scrollY - offset);
|
|
||||||
});
|
|
||||||
|
|
||||||
// On short pages some sections never reach the offset line because the
|
|
||||||
// page can't scroll far enough. Squeeze the unreachable tail evenly into
|
|
||||||
// the remaining scroll range so each section still gets its own slice.
|
|
||||||
const activations: number[] = [];
|
|
||||||
let prevAdjusted = 0;
|
|
||||||
for (let i = 0; i < naturalScrolls.length; i++) {
|
|
||||||
const natural = naturalScrolls[i];
|
|
||||||
if (natural <= maxScroll) {
|
|
||||||
activations.push(natural);
|
|
||||||
prevAdjusted = natural;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const remaining = naturalScrolls.length - i;
|
|
||||||
const range = Math.max(0, maxScroll - prevAdjusted);
|
|
||||||
const step = range / remaining;
|
|
||||||
for (let j = 0; j < remaining; j++) {
|
|
||||||
activations.push(prevAdjusted + step * (j + 1));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current = sectionIds[0] ?? "";
|
|
||||||
for (let i = 0; i < activations.length; i++) {
|
|
||||||
if (activations[i] <= scrollY) current = sectionIds[i];
|
|
||||||
}
|
|
||||||
setActiveId((prev) => (prev === current ? prev : current));
|
|
||||||
};
|
|
||||||
|
|
||||||
pickActive();
|
|
||||||
window.addEventListener("scroll", pickActive, { passive: true });
|
|
||||||
window.addEventListener("resize", pickActive, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", pickActive);
|
|
||||||
window.removeEventListener("resize", pickActive);
|
|
||||||
};
|
|
||||||
}, [sectionIds]);
|
|
||||||
|
|
||||||
return activeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TenderSectionNavList({
|
|
||||||
items,
|
|
||||||
activeId,
|
|
||||||
onSelect,
|
|
||||||
variant,
|
|
||||||
}: {
|
|
||||||
items: TenderNavItem[];
|
|
||||||
activeId: string;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
variant: "rail" | "strip";
|
|
||||||
}) {
|
|
||||||
if (variant === "strip") {
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="Jump to section"
|
|
||||||
className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:-mx-6 sm:px-6"
|
|
||||||
>
|
|
||||||
{items.map((item) => {
|
|
||||||
const isActive = activeId === item.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(item.id)}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-full border px-3.5 py-2 text-xs font-semibold tracking-wide transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
|
||||||
isActive
|
|
||||||
? "border-primary/35 bg-primary/10 text-primary shadow-sm dark:border-primary/40 dark:bg-primary/15"
|
|
||||||
: "border-stroke/70 bg-white/80 text-dark-5 hover:border-primary/25 hover:text-dark dark:border-dark-3 dark:bg-dark-2/60 dark:text-dark-6 dark:hover:text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav aria-label="On this page" className="space-y-1">
|
|
||||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.22em] text-dark-5 dark:text-dark-6">
|
|
||||||
On this page
|
|
||||||
</p>
|
|
||||||
<ul className="relative space-y-0.5 border-l border-stroke/60 pl-3 dark:border-dark-3">
|
|
||||||
{items.map((item) => {
|
|
||||||
const isActive = activeId === item.id;
|
|
||||||
return (
|
|
||||||
<li key={item.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(item.id)}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex w-full items-center rounded-lg py-2 pl-2.5 pr-2 text-left text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
|
||||||
isActive
|
|
||||||
? "bg-primary/[0.08] text-primary dark:bg-primary/10"
|
|
||||||
: "text-dark-5 hover:bg-gray-2/80 hover:text-dark dark:text-dark-6 dark:hover:bg-dark-3/50 dark:hover:text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute -left-px top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full transition",
|
|
||||||
isActive
|
|
||||||
? "bg-primary"
|
|
||||||
: "bg-transparent group-hover:bg-stroke dark:group-hover:bg-dark-3",
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="line-clamp-2">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExternalLinkIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={cn("h-4 w-4 shrink-0", className)}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Matches the tender title `<h1>` in the header — keep in sync for layout probe */
|
|
||||||
const TENDER_HEADER_TITLE_CLASS =
|
|
||||||
"text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]";
|
|
||||||
|
|
||||||
/** Reserved width for the actions column when deciding side‑by‑side vs stacked (lg+) */
|
|
||||||
const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
|
|
||||||
|
|
||||||
function useStackTenderHeaderActions(
|
|
||||||
title: string,
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>,
|
|
||||||
probeSizerRef: RefObject<HTMLDivElement | null>,
|
|
||||||
) {
|
|
||||||
const [stackBelow, setStackBelow] = useState(false);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
const probeSizer = probeSizerRef.current;
|
|
||||||
if (!container || !probeSizer) return;
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
if (!window.matchMedia("(min-width: 1024px)").matches) {
|
|
||||||
setStackBelow(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = window.getComputedStyle(container);
|
|
||||||
const gapRaw = parseFloat(styles.columnGap || styles.gap);
|
|
||||||
const gap = Number.isFinite(gapRaw) ? gapRaw : 32;
|
|
||||||
const containerW = container.getBoundingClientRect().width;
|
|
||||||
const titleColumnW = Math.max(
|
|
||||||
160,
|
|
||||||
containerW - TENDER_HEADER_ACTIONS_RESERVE_PX - gap,
|
|
||||||
);
|
|
||||||
probeSizer.style.width = `${titleColumnW}px`;
|
|
||||||
|
|
||||||
const probeTitle = probeSizer.querySelector("h1");
|
|
||||||
if (!probeTitle) return;
|
|
||||||
|
|
||||||
const titleStyles = window.getComputedStyle(probeTitle);
|
|
||||||
const lineHeightRaw = parseFloat(titleStyles.lineHeight);
|
|
||||||
const fontSize = parseFloat(titleStyles.fontSize);
|
|
||||||
const lineHeight =
|
|
||||||
Number.isFinite(lineHeightRaw) && lineHeightRaw > 0
|
|
||||||
? lineHeightRaw
|
|
||||||
: fontSize * 1.25;
|
|
||||||
const lines = probeTitle.scrollHeight / lineHeight;
|
|
||||||
const next = lines > 1.35;
|
|
||||||
setStackBelow((p) => (p === next ? p : next));
|
|
||||||
};
|
|
||||||
|
|
||||||
run();
|
|
||||||
const ro = new ResizeObserver(run);
|
|
||||||
ro.observe(container);
|
|
||||||
window.addEventListener("resize", run);
|
|
||||||
return () => {
|
|
||||||
ro.disconnect();
|
|
||||||
window.removeEventListener("resize", run);
|
|
||||||
};
|
|
||||||
}, [title]);
|
|
||||||
|
|
||||||
return stackBelow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Body area only: center content in the card; ShowcaseSection `title` is unchanged */
|
|
||||||
const SHOWCASE_CENTERED_BODY = {
|
|
||||||
rootClassName: "flex h-full min-h-0 flex-col",
|
|
||||||
className:
|
|
||||||
"flex min-h-0 flex-1 flex-col items-center justify-center text-center",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TenderDetails = ({ params }: IProps) => {
|
const TenderDetails = ({ params }: IProps) => {
|
||||||
const { details } = use(params);
|
const { details } = use(params);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -327,15 +33,7 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
|
const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
|
||||||
lang: selectedLanguage,
|
lang: selectedLanguage,
|
||||||
});
|
});
|
||||||
const countryCodeNormalized =
|
|
||||||
data?.data.country_code?.trim().toUpperCase() ?? "";
|
|
||||||
const hasValidCountryCodeForDisplay = isValidDisplayCountryCode(
|
|
||||||
countryCodeNormalized,
|
|
||||||
);
|
|
||||||
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
|
|
||||||
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
|
|
||||||
enabled: canLoadCountryFlag,
|
|
||||||
});
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ name: "Dashboard", href: "/" },
|
{ name: "Dashboard", href: "/" },
|
||||||
{ name: "Tenders", href: "/tenders" },
|
{ name: "Tenders", href: "/tenders" },
|
||||||
@@ -343,99 +41,49 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const tenderForNav = data?.data;
|
const tenderForNav = data?.data;
|
||||||
const tenderNavItems = useMemo((): TenderNavItem[] => {
|
const tenderNavItems = useMemo(
|
||||||
if (!tenderForNav) return [];
|
() => (tenderForNav ? getTenderNavItems(tenderForNav) : []),
|
||||||
const countryNorm = tenderForNav.country_code?.trim().toUpperCase() ?? "";
|
[tenderForNav],
|
||||||
const hasCountry = isValidDisplayCountryCode(countryNorm);
|
);
|
||||||
const currency = tenderForNav.currency?.trim().toUpperCase() ?? "";
|
|
||||||
const hasCurrency = Boolean(currency);
|
|
||||||
const hasEst =
|
|
||||||
typeof tenderForNav.estimated_value === "number" &&
|
|
||||||
Number.isFinite(tenderForNav.estimated_value) &&
|
|
||||||
tenderForNav.estimated_value > 0;
|
|
||||||
const showLocation = hasCountry || hasCurrency || hasEst;
|
|
||||||
|
|
||||||
const items: TenderNavItem[] = [
|
|
||||||
{ id: TENDER_SECTION_IDS.procurement, label: "Procurement" },
|
|
||||||
{ 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" },
|
|
||||||
{ id: TENDER_SECTION_IDS.description, label: "Description" },
|
|
||||||
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
|
|
||||||
);
|
|
||||||
return items;
|
|
||||||
}, [tenderForNav]);
|
|
||||||
|
|
||||||
const sectionIds = useMemo(
|
const sectionIds = useMemo(
|
||||||
() => tenderNavItems.map((item) => item.id),
|
() => tenderNavItems.map((item) => item.id),
|
||||||
[tenderNavItems],
|
[tenderNavItems],
|
||||||
);
|
);
|
||||||
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
|
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
|
||||||
|
const { data: documents } = useGetTenderDocumentsQuery(details);
|
||||||
const scrollToTenderSection = useCallback((id: string) => {
|
const handleLanguageChange = useCallback(
|
||||||
const el = document.getElementById(id);
|
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
if (!el) return;
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
const reduceMotion =
|
const nextLanguage = event.target.value;
|
||||||
typeof window !== "undefined" &&
|
if (nextLanguage) {
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
nextParams.set("lang", nextLanguage);
|
||||||
el.scrollIntoView({
|
} else {
|
||||||
behavior: reduceMotion ? "auto" : "smooth",
|
nextParams.delete("lang");
|
||||||
block: "start",
|
}
|
||||||
});
|
const queryString = nextParams.toString();
|
||||||
}, []);
|
router.replace(queryString ? `${pathname}?${queryString}` : pathname);
|
||||||
|
},
|
||||||
const headerLayoutContainerRef = useRef<HTMLDivElement>(null);
|
[pathname, router, searchParams],
|
||||||
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const headerTitleForLayout = data?.data.title ?? "";
|
|
||||||
const stackActionsBelow = useStackTenderHeaderActions(
|
|
||||||
headerTitleForLayout,
|
|
||||||
headerLayoutContainerRef,
|
|
||||||
headerTitleProbeSizerRef,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTranslate = useCallback(() => {
|
||||||
|
translateTender(
|
||||||
|
{ id: details, language: selectedLanguage },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [details, refetch, selectedLanguage, translateTender]);
|
||||||
|
|
||||||
if (isLoading) return <Loading />;
|
if (isLoading) return <Loading />;
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return <TenderDetailsError breadcrumbItems={breadcrumbItems} />;
|
||||||
<>
|
|
||||||
<Breadcrumb items={breadcrumbItems} />
|
|
||||||
<ShowcaseSection className="!p-8 sm:!p-10">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
|
||||||
<p className="text-lg font-semibold text-dark dark:text-white">
|
|
||||||
We couldn't load this tender
|
|
||||||
</p>
|
|
||||||
<p className="max-w-sm text-sm text-dark-5 dark:text-dark-6">
|
|
||||||
Check your connection or try again. You can go back to the tender
|
|
||||||
list anytime.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/tenders"
|
|
||||||
className="shadow-theme-xs mt-2 inline-flex items-center justify-center rounded-xl bg-primary px-5 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Back to tenders
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</ShowcaseSection>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tender = data!.data;
|
const tender = data!.data;
|
||||||
const submissionUrl = tender.submission_url?.trim();
|
|
||||||
const currencyCode = tender.currency?.trim().toUpperCase() ?? "";
|
|
||||||
const hasValidCurrency = Boolean(currencyCode);
|
|
||||||
const hasValidEstimatedValue =
|
|
||||||
typeof tender.estimated_value === "number" &&
|
|
||||||
Number.isFinite(tender.estimated_value) &&
|
|
||||||
tender.estimated_value > 0;
|
|
||||||
const hasValidLocationCurrencyOrValue =
|
|
||||||
hasValidCountryCodeForDisplay || hasValidCurrency || hasValidEstimatedValue;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -443,383 +91,22 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
<ShowcaseSection
|
<ShowcaseSection
|
||||||
rootClassName="overflow-clip"
|
rootClassName="overflow-clip"
|
||||||
title={
|
title={
|
||||||
<div ref={headerLayoutContainerRef} className="relative">
|
<TenderDetailsHeader
|
||||||
<div
|
title={tender.title}
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 max-h-0 overflow-hidden opacity-0"
|
selectedLanguage={selectedLanguage}
|
||||||
aria-hidden
|
onLanguageChange={handleLanguageChange}
|
||||||
>
|
onTranslate={handleTranslate}
|
||||||
<div ref={headerTitleProbeSizerRef}>
|
isTranslating={isTranslating}
|
||||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{tender.title}</h1>
|
status={tender.status}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-5",
|
|
||||||
!stackActionsBelow &&
|
|
||||||
"lg:flex-row lg:items-start lg:justify-between lg:gap-8",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 space-y-2",
|
|
||||||
!stackActionsBelow && "lg:flex-1",
|
|
||||||
stackActionsBelow && "lg:w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
|
||||||
Tender
|
|
||||||
</p>
|
|
||||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{tender.title}</h1>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex w-full min-w-0 shrink-0 flex-col gap-3",
|
|
||||||
!stackActionsBelow && "lg:max-w-2xl lg:items-end",
|
|
||||||
stackActionsBelow && "lg:w-full lg:items-stretch",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full",
|
|
||||||
stackActionsBelow ? "lg:text-start" : "lg:text-end",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Language & translation
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center",
|
|
||||||
stackActionsBelow
|
|
||||||
? "sm:justify-start lg:justify-start"
|
|
||||||
: "sm:justify-end lg:justify-end",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
name="lang"
|
|
||||||
label="Display language"
|
|
||||||
items={Languages}
|
|
||||||
className={cn(
|
|
||||||
"w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]",
|
|
||||||
"[&_label]:sr-only",
|
|
||||||
)}
|
|
||||||
value={selectedLanguage}
|
|
||||||
onChange={async (event) => {
|
|
||||||
const nextParams = new URLSearchParams(
|
|
||||||
searchParams.toString(),
|
|
||||||
);
|
|
||||||
const nextLanguage = event.target.value;
|
|
||||||
if (nextLanguage) {
|
|
||||||
nextParams.set("lang", nextLanguage);
|
|
||||||
} else {
|
|
||||||
nextParams.delete("lang");
|
|
||||||
}
|
|
||||||
const queryString = nextParams.toString();
|
|
||||||
router.replace(
|
|
||||||
queryString ? `${pathname}?${queryString}` : pathname,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
shape="rounded"
|
|
||||||
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
|
||||||
label={
|
|
||||||
isTranslating ? "Translating..." : "Translate metadata"
|
|
||||||
}
|
|
||||||
disabled={isTranslating}
|
|
||||||
onClick={() => {
|
|
||||||
translateTender(
|
|
||||||
{ id: details, language: selectedLanguage },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetch();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex shrink-0 items-center sm:min-h-[3.375rem]">
|
|
||||||
<Status status={tender.status ?? "Unknown"}>
|
|
||||||
{tender.status}
|
|
||||||
</Status>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
className="!p-0"
|
className="!p-0"
|
||||||
>
|
>
|
||||||
<div className="px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-8 xl:pb-10 xl:pt-3 2xl:px-10">
|
<TenderDetailsContent
|
||||||
<div className="xl:grid xl:grid-cols-[minmax(0,1fr)_13.5rem] xl:items-start xl:gap-10 2xl:grid-cols-[minmax(0,1fr)_15rem] 2xl:gap-12">
|
tender={tender}
|
||||||
<div className="min-w-0 space-y-6 xl:space-y-10">
|
tenderNavItems={tenderNavItems}
|
||||||
<div className="xl:hidden">
|
activeSectionId={activeSectionId}
|
||||||
<div className="rounded-2xl border border-stroke/60 bg-white/70 p-3 shadow-sm backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40">
|
/>
|
||||||
<TenderSectionNavList
|
|
||||||
items={tenderNavItems}
|
|
||||||
activeId={activeSectionId}
|
|
||||||
onSelect={scrollToTenderSection}
|
|
||||||
variant="strip"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id={TENDER_SECTION_IDS.procurement}
|
|
||||||
className={HEADER_SCROLL_MARGIN}
|
|
||||||
>
|
|
||||||
<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={`https://ted.europa.eu/en/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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 && (
|
|
||||||
<ShowcaseSection
|
|
||||||
title="Submission URL"
|
|
||||||
{...SHOWCASE_CENTERED_BODY}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
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)}
|
|
||||||
</Link>
|
|
||||||
</ShowcaseSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasValidLocationCurrencyOrValue && (
|
|
||||||
<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">
|
|
||||||
{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" />
|
|
||||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
|
||||||
Country
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/80 text-primary shadow-inner dark:bg-dark-2/80">
|
|
||||||
<LocationIcon />
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
|
|
||||||
{countryCodeNormalized}
|
|
||||||
</span>
|
|
||||||
{flagData && (
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{ __html: flagData }}
|
|
||||||
className="flex h-8 w-11 items-center justify-center overflow-hidden rounded-lg border border-stroke/60 shadow-sm dark:border-dark-3"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasValidCurrency && (
|
|
||||||
<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-blue/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-blue/[0.12]">
|
|
||||||
<div className="pointer-events-none absolute -left-6 bottom-0 h-20 w-20 rounded-full bg-blue/15 blur-2xl dark:bg-blue/25" />
|
|
||||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
|
||||||
Currency
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
|
||||||
{currencyCode}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-medium text-dark-5 dark:text-dark-6">
|
|
||||||
{getSymbolFromCurrency(currencyCode || "USD")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasValidEstimatedValue && (
|
|
||||||
<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-green/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-green/[0.12]">
|
|
||||||
<div className="pointer-events-none absolute -right-6 bottom-0 h-20 w-20 rounded-full bg-green/15 blur-2xl dark:bg-green/25" />
|
|
||||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
|
||||||
Estimated value
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
|
||||||
{new Intl.NumberFormat().format(
|
|
||||||
tender.estimated_value,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{hasValidCurrency && (
|
|
||||||
<span className="text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
|
|
||||||
{currencyCode}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 || "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="sticky top-24 z-10 mt-2 hidden min-w-0 self-start xl:block">
|
|
||||||
<div className="shadow-theme-xs max-h-[calc(100vh-7rem)] overflow-y-auto rounded-2xl border border-stroke/60 bg-white/80 p-4 backdrop-blur-md dark:border-dark-3 dark:bg-dark-2/50">
|
|
||||||
<TenderSectionNavList
|
|
||||||
items={tenderNavItems}
|
|
||||||
activeId={activeSectionId}
|
|
||||||
onSelect={scrollToTenderSection}
|
|
||||||
variant="rail"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function scrollToTenderSection(id: string) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const reduceMotion =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
el.scrollIntoView({
|
||||||
|
behavior: reduceMotion ? "auto" : "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||||
|
import { TENDER_SECTION_IDS } from "./constants";
|
||||||
|
import type { TenderNavItem } from "./types";
|
||||||
|
|
||||||
|
export const isValidDisplayCountryCode = (code: string) =>
|
||||||
|
/^[A-Za-z]{2,3}$/.test(code);
|
||||||
|
|
||||||
|
function analyzeLocationVisibility(tender: TTenderDetails) {
|
||||||
|
const countryNorm = tender.country_code?.trim().toUpperCase() ?? "";
|
||||||
|
const hasCountry = isValidDisplayCountryCode(countryNorm);
|
||||||
|
const currency = tender.currency?.trim().toUpperCase() ?? "";
|
||||||
|
const hasCurrency = Boolean(currency);
|
||||||
|
const hasEst =
|
||||||
|
typeof tender.estimated_value === "number" &&
|
||||||
|
Number.isFinite(tender.estimated_value) &&
|
||||||
|
tender.estimated_value > 0;
|
||||||
|
const showLocation = hasCountry || hasCurrency || hasEst;
|
||||||
|
return { showLocation };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenderNavItems(tender: TTenderDetails): TenderNavItem[] {
|
||||||
|
const { showLocation } = analyzeLocationVisibility(tender);
|
||||||
|
|
||||||
|
const items: TenderNavItem[] = [
|
||||||
|
{ id: TENDER_SECTION_IDS.procurement, label: "Procurement" },
|
||||||
|
{ 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" },
|
||||||
|
{ id: TENDER_SECTION_IDS.description, label: "Description" },
|
||||||
|
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowLocationSection(tender: TTenderDetails): boolean {
|
||||||
|
return analyzeLocationVisibility(tender).showLocation;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type TenderNavItem = { id: string; label: string };
|
||||||
@@ -46,16 +46,16 @@ const DatePicker = <T extends FieldValues>({
|
|||||||
minDate,
|
minDate,
|
||||||
}: IProps<T>) => {
|
}: IProps<T>) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<label
|
<label
|
||||||
htmlFor={name}
|
htmlFor={name}
|
||||||
className="text-body-sm font-medium capitalize text-dark dark:text-white"
|
className="block text-body-sm font-semibold capitalize tracking-[0.01em] text-dark dark:text-white"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="ml-1 select-none text-error">*</span>}
|
{required && <span className="ml-1 select-none text-error">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
@@ -96,18 +96,19 @@ const DatePicker = <T extends FieldValues>({
|
|||||||
format={`YYYY/MM/DD ${timePicker ? "HH:mm" : ""}`}
|
format={`YYYY/MM/DD ${timePicker ? "HH:mm" : ""}`}
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
inputClass={cn(
|
inputClass={cn(
|
||||||
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5.5 py-3 pl-12.5 text-dark outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 dark:border-dark-3 dark:bg-dark-2 dark:text-white dark:focus:border-primary dark:disabled:bg-dark",
|
"w-full rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 pl-12.5 text-dark shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] disabled:cursor-default disabled:bg-gray-2/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:text-white dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] dark:disabled:bg-dark",
|
||||||
error && "!border-red focus:!border-red dark:!border-red",
|
error &&
|
||||||
|
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
|
||||||
)}
|
)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<span className="absolute left-4.5 top-1/2 -translate-y-1/2">
|
<span className="pointer-events-none absolute left-4.5 top-1/2 -translate-y-1/2 text-dark-5 transition-colors dark:text-dark-6">
|
||||||
<CalendarIcon />
|
<CalendarIcon />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-red mt-1.5 text-sm text-error">
|
<p className="mt-1.5 text-sm font-medium text-error">
|
||||||
{error.message as string}
|
{error.message as string}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
|||||||
import { Switch } from "@/components/FormElements/switch";
|
import { Switch } from "@/components/FormElements/switch";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||||
|
import Pagination from "@/components/ui/pagination";
|
||||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||||
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||||
import { startTransition } from "react";
|
import { startTransition } from "react";
|
||||||
@@ -44,6 +46,9 @@ const CompanyCategoriesTable = () => {
|
|||||||
setFilterValue,
|
setFilterValue,
|
||||||
columns,
|
columns,
|
||||||
isMutating,
|
isMutating,
|
||||||
|
metadata,
|
||||||
|
setParams,
|
||||||
|
shouldShowPagination,
|
||||||
} = useCompanyCategoriesPresenter();
|
} = useCompanyCategoriesPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,6 +75,7 @@ const CompanyCategoriesTable = () => {
|
|||||||
list={optimisticCategories}
|
list={optimisticCategories}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No categories were found"
|
emptyMessage="No categories were found"
|
||||||
|
isLoading={isLoading}
|
||||||
>
|
>
|
||||||
{optimisticCategories.map((category, index) => (
|
{optimisticCategories.map((category, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -78,7 +84,11 @@ const CompanyCategoriesTable = () => {
|
|||||||
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
||||||
>
|
>
|
||||||
<TableCell colSpan={100}>
|
<TableCell colSpan={100}>
|
||||||
{getPaginatedRowNumber({ index })}
|
{getPaginatedRowNumber({
|
||||||
|
index,
|
||||||
|
page: metadata?.page,
|
||||||
|
limit: metadata?.limit,
|
||||||
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell colSpan={100}>{category.name}</TableCell>
|
<TableCell colSpan={100}>{category.name}</TableCell>
|
||||||
<TableCell colSpan={100}>{category.description}</TableCell>
|
<TableCell colSpan={100}>{category.description}</TableCell>
|
||||||
@@ -179,6 +189,19 @@ const CompanyCategoriesTable = () => {
|
|||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<IsVisible condition={shouldShowPagination}>
|
||||||
|
<Pagination
|
||||||
|
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||||
|
totalPages={metadata?.pages ?? 1}
|
||||||
|
onPageChange={(e) => {
|
||||||
|
setParams((currentParams) => ({
|
||||||
|
...currentParams,
|
||||||
|
offset: e.selected * (metadata?.limit ?? 10),
|
||||||
|
limit: metadata?.limit ?? 10,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, any> = {
|
const newParams: Record<string, any> = {
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
};
|
};
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
newParams[key] = value;
|
newParams[key] = value;
|
||||||
@@ -45,6 +47,10 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
|
|
||||||
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
|
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
|
||||||
const categories = data?.data.categories;
|
const categories = data?.data.categories;
|
||||||
|
const metadata = data?.meta;
|
||||||
|
const shouldShowPagination =
|
||||||
|
!isLoading &&
|
||||||
|
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
|
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
|
||||||
TCompanyCategory[],
|
TCompanyCategory[],
|
||||||
TogglePublishedOptimistic
|
TogglePublishedOptimistic
|
||||||
@@ -58,7 +64,7 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
useToggleCompanyCategoryPublished();
|
useToggleCompanyCategoryPublished();
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (data: any) => {
|
||||||
const newParams = { ...params, ...data };
|
const newParams = { ...params, ...data, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
@@ -102,6 +108,8 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
|
metadata,
|
||||||
|
shouldShowPagination,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
} from "@/assets/icons";
|
} from "@/assets/icons";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
|
import Pagination from "@/components/ui/pagination";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -45,6 +47,9 @@ const CompaniesTable = () => {
|
|||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
|
metadata,
|
||||||
|
setParams,
|
||||||
|
shouldShowPagination,
|
||||||
} = useCompanyListPresenter();
|
} = useCompanyListPresenter();
|
||||||
const companies = allCompanies.filter(
|
const companies = allCompanies.filter(
|
||||||
(c): c is NonNullable<typeof c> => c != null,
|
(c): c is NonNullable<typeof c> => c != null,
|
||||||
@@ -75,6 +80,7 @@ const CompaniesTable = () => {
|
|||||||
list={companies}
|
list={companies}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No companies were found"
|
emptyMessage="No companies were found"
|
||||||
|
isLoading={isPending}
|
||||||
>
|
>
|
||||||
{companies.map((company, index) => (
|
{companies.map((company, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -82,7 +88,11 @@ const CompaniesTable = () => {
|
|||||||
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
||||||
>
|
>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{getPaginatedRowNumber({ index })}
|
{getPaginatedRowNumber({
|
||||||
|
index,
|
||||||
|
page: metadata?.page,
|
||||||
|
limit: metadata?.limit,
|
||||||
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{company.name}
|
{company.name}
|
||||||
@@ -198,6 +208,19 @@ const CompaniesTable = () => {
|
|||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<IsVisible condition={shouldShowPagination}>
|
||||||
|
<Pagination
|
||||||
|
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||||
|
totalPages={metadata?.pages ?? 1}
|
||||||
|
onPageChange={(e) => {
|
||||||
|
setParams((currentParams) => ({
|
||||||
|
...currentParams,
|
||||||
|
offset: e.selected * (metadata?.limit ?? 10),
|
||||||
|
limit: metadata?.limit ?? 10,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
|
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
|
||||||
|
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||||
import {
|
import {
|
||||||
useCompaniesInfiniteQuery,
|
useCompaniesQuery,
|
||||||
useDeleteCompanyQuery,
|
useDeleteCompanyQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { TCompany } from "@/lib/api";
|
import { TCompany } from "@/lib/api";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useInView } from "react-intersection-observer";
|
|
||||||
|
|
||||||
export const useCompanyListPresenter = () => {
|
export const useCompanyListPresenter = () => {
|
||||||
const deleteModal = useRef<HTMLDialogElement | null>(null);
|
|
||||||
const { inView } = useInView({ threshold: 0.5 });
|
|
||||||
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
|
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||||
const [modalConfig, setModalConfig] = useState<
|
const [modalConfig, setModalConfig] = useState<
|
||||||
@@ -50,7 +48,9 @@ export const useCompanyListPresenter = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, any> = {
|
const newParams: Record<string, any> = {
|
||||||
sort: "created_at",
|
...apiDefaultParams,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
};
|
};
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
newParams[key] = value;
|
newParams[key] = value;
|
||||||
@@ -59,28 +59,17 @@ export const useCompanyListPresenter = () => {
|
|||||||
filterFormReset(newParams);
|
filterFormReset(newParams);
|
||||||
}, [searchParams, filterFormReset]);
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const {
|
const { data, error, isPending, isError } = useCompaniesQuery(params);
|
||||||
data,
|
|
||||||
error,
|
|
||||||
isPending,
|
|
||||||
isError,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
} = useCompaniesInfiniteQuery(params);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const shouldShowPagination =
|
||||||
if (inView && hasNextPage && data && data.pages.length < 5) {
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}, [inView, hasNextPage, data, fetchNextPage]);
|
|
||||||
|
|
||||||
const { mutate: deleteCompany } = useDeleteCompanyQuery(() =>
|
const { mutate: deleteCompany } = useDeleteCompanyQuery(() =>
|
||||||
setIsModalOpen(false),
|
setIsModalOpen(false),
|
||||||
);
|
);
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (data: any) => {
|
||||||
const newParams = { ...params, ...data };
|
const newParams = { ...params, ...data, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
@@ -91,22 +80,18 @@ export const useCompanyListPresenter = () => {
|
|||||||
deleteCompany(currentCompany.id);
|
deleteCompany(currentCompany.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const allCompanies = data?.pages.flatMap((page) => page.data.companies) ?? [];
|
const allCompanies = data?.data.companies ?? [];
|
||||||
|
const metadata = data?.meta;
|
||||||
return {
|
return {
|
||||||
allCompanies,
|
allCompanies,
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
deleteCompany,
|
metadata,
|
||||||
isFetchingNextPage,
|
|
||||||
data,
|
|
||||||
columns,
|
columns,
|
||||||
pathName,
|
pathName,
|
||||||
currentCompany,
|
currentCompany,
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
modalConfig,
|
modalConfig,
|
||||||
setModalConfig,
|
setModalConfig,
|
||||||
setCurrentCompany,
|
setCurrentCompany,
|
||||||
deleteModal,
|
|
||||||
error,
|
error,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
setIsModalOpen,
|
setIsModalOpen,
|
||||||
@@ -121,5 +106,7 @@ export const useCompanyListPresenter = () => {
|
|||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
|
setParams,
|
||||||
|
shouldShowPagination,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
@@ -6,6 +7,7 @@ import { TenderStatus } from "@/constants/enums";
|
|||||||
import { Languages } from "@/constants/languages";
|
import { Languages } from "@/constants/languages";
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
import {
|
import {
|
||||||
|
Control,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
UseFormHandleSubmit,
|
UseFormHandleSubmit,
|
||||||
UseFormRegister,
|
UseFormRegister,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
|
|
||||||
interface TenderListFiltersProps {
|
interface TenderListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
|
control: Control<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
@@ -25,6 +28,7 @@ interface TenderListFiltersProps {
|
|||||||
|
|
||||||
const TenderListFilters = ({
|
const TenderListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
|
control,
|
||||||
setIsFilterModalOpen,
|
setIsFilterModalOpen,
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
@@ -37,11 +41,22 @@ const TenderListFilters = ({
|
|||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
onSubmit={handleFilterSubmit(search)}
|
||||||
>
|
>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("q")}
|
||||||
|
name="q"
|
||||||
|
label="keyword"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("title")}
|
{...filterRegister("title")}
|
||||||
name="title"
|
name="title"
|
||||||
label="title"
|
label="title"
|
||||||
placeholder="Title"
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("description")}
|
||||||
|
name="description"
|
||||||
|
label="description"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -49,27 +64,100 @@ const TenderListFilters = ({
|
|||||||
items={getEnumAsArray(TenderStatus)}
|
items={getEnumAsArray(TenderStatus)}
|
||||||
label="status"
|
label="status"
|
||||||
name="status"
|
name="status"
|
||||||
placeholder="Status"
|
|
||||||
clearable
|
clearable
|
||||||
value={watch("status")}
|
value={watch("status")}
|
||||||
onClear={() => setValue("status", undefined)}
|
onClear={() => setValue("status", undefined)}
|
||||||
/>
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("country")}
|
||||||
|
name="country"
|
||||||
|
label="country"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("country_code")}
|
{...filterRegister("country_code")}
|
||||||
items={Countries}
|
items={Countries}
|
||||||
label="country code"
|
label="country code"
|
||||||
name="country_code"
|
name="country_code"
|
||||||
placeholder="Country Code"
|
|
||||||
clearable
|
clearable
|
||||||
value={watch("country_code")}
|
value={watch("country_code")}
|
||||||
onClear={() => setValue("country_code", undefined)}
|
onClear={() => setValue("country_code", undefined)}
|
||||||
/>
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("country_codes")}
|
||||||
|
name="country_codes"
|
||||||
|
label="country codes"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("notice_type")}
|
||||||
|
name="notice_type"
|
||||||
|
label="notice type"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("notice_types")}
|
||||||
|
name="notice_types"
|
||||||
|
label="notice types"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("form_types")}
|
||||||
|
name="form_types"
|
||||||
|
label="form types"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("main_classification")}
|
||||||
|
name="main_classification"
|
||||||
|
label="main classification"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("classifications")}
|
||||||
|
name="classifications"
|
||||||
|
label="classifications"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("cpv_codes")}
|
||||||
|
name="cpv_codes"
|
||||||
|
label="cpv codes"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
control={control}
|
||||||
|
name="created_at_range"
|
||||||
|
label="created at range"
|
||||||
|
range
|
||||||
|
timePicker
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
control={control}
|
||||||
|
name="tender_deadline_range"
|
||||||
|
label="tender deadline range"
|
||||||
|
range
|
||||||
|
timePicker
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
control={control}
|
||||||
|
name="publication_date_range"
|
||||||
|
label="publication date range"
|
||||||
|
range
|
||||||
|
timePicker
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
control={control}
|
||||||
|
name="submission_deadline_range"
|
||||||
|
label="submission deadline range"
|
||||||
|
range
|
||||||
|
timePicker
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("lang")}
|
{...filterRegister("lang")}
|
||||||
items={Languages}
|
items={Languages}
|
||||||
label="language"
|
label="language"
|
||||||
name="lang"
|
name="lang"
|
||||||
placeholder="Language"
|
|
||||||
clearable
|
clearable
|
||||||
value={watch("lang")}
|
value={watch("lang")}
|
||||||
onClear={() => setValue("lang", undefined)}
|
onClear={() => setValue("lang", undefined)}
|
||||||
@@ -83,10 +171,10 @@ const TenderListFilters = ({
|
|||||||
className="mt-6 w-fit capitalize"
|
className="mt-6 w-fit capitalize"
|
||||||
label={
|
label={
|
||||||
isMutating ? (
|
isMutating ? (
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||||
) : (
|
) : (
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const TendersTable = () => {
|
|||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
|
control,
|
||||||
isMutating,
|
isMutating,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
} = useTenderListPresenter();
|
} = useTenderListPresenter();
|
||||||
@@ -167,6 +168,7 @@ const TendersTable = () => {
|
|||||||
<TenderListFilters
|
<TenderListFilters
|
||||||
setValue={setFilterValue}
|
setValue={setFilterValue}
|
||||||
filterRegister={filterRegister}
|
filterRegister={filterRegister}
|
||||||
|
control={control}
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
isMutating={isMutating as number}
|
isMutating={isMutating as number}
|
||||||
search={search}
|
search={search}
|
||||||
|
|||||||
@@ -8,6 +8,110 @@ import { useIsMutating } from "@tanstack/react-query";
|
|||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
const COMMA_SEPARATED_FILTER_KEYS = new Set([
|
||||||
|
"country_codes",
|
||||||
|
"notice_types",
|
||||||
|
"form_types",
|
||||||
|
"classifications",
|
||||||
|
"cpv_codes",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalizeFilterValues = (values: Record<string, any>) => {
|
||||||
|
const normalized: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (COMMA_SEPARATED_FILTER_KEYS.has(key)) {
|
||||||
|
normalized[key] = trimmedValue
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(",");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized[key] = trimmedValue;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDateRangeFilters = (values: Record<string, any>) => {
|
||||||
|
const mappedValues = { ...values };
|
||||||
|
|
||||||
|
const mapRange = ({
|
||||||
|
key,
|
||||||
|
fromKey,
|
||||||
|
toKey,
|
||||||
|
aliases = [],
|
||||||
|
}: {
|
||||||
|
key: string;
|
||||||
|
fromKey: string;
|
||||||
|
toKey: string;
|
||||||
|
aliases?: Array<{ from: string; to: string }>;
|
||||||
|
}) => {
|
||||||
|
const rangeValue = mappedValues[key];
|
||||||
|
if (!Array.isArray(rangeValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [from, to] = rangeValue;
|
||||||
|
if (from !== undefined && from !== null && from !== "") {
|
||||||
|
mappedValues[fromKey] = from;
|
||||||
|
aliases.forEach((alias) => {
|
||||||
|
mappedValues[alias.from] = from;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to !== undefined && to !== null && to !== "") {
|
||||||
|
mappedValues[toKey] = to;
|
||||||
|
aliases.forEach((alias) => {
|
||||||
|
mappedValues[alias.to] = to;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete mappedValues[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
mapRange({
|
||||||
|
key: "created_at_range",
|
||||||
|
fromKey: "created_at_from",
|
||||||
|
toKey: "created_at_to",
|
||||||
|
});
|
||||||
|
mapRange({
|
||||||
|
key: "tender_deadline_range",
|
||||||
|
fromKey: "tender_deadline_from",
|
||||||
|
toKey: "tender_deadline_to",
|
||||||
|
aliases: [{ from: "deadline_from", to: "deadline_to" }],
|
||||||
|
});
|
||||||
|
mapRange({
|
||||||
|
key: "publication_date_range",
|
||||||
|
fromKey: "publication_date_from",
|
||||||
|
toKey: "publication_date_to",
|
||||||
|
});
|
||||||
|
mapRange({
|
||||||
|
key: "submission_deadline_range",
|
||||||
|
fromKey: "submission_deadline_from",
|
||||||
|
toKey: "submission_deadline_to",
|
||||||
|
aliases: [{ from: "submission_date_from", to: "submission_date_to" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return mappedValues;
|
||||||
|
};
|
||||||
const useTenderListPresenter = () => {
|
const useTenderListPresenter = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||||
@@ -27,6 +131,7 @@ const useTenderListPresenter = () => {
|
|||||||
reset: filterFormReset,
|
reset: filterFormReset,
|
||||||
watch,
|
watch,
|
||||||
setValue: setFilterValue,
|
setValue: setFilterValue,
|
||||||
|
control,
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,7 +153,9 @@ const useTenderListPresenter = () => {
|
|||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (data: any) => {
|
||||||
const newParams = { ...params, ...data, offset: 0 };
|
const mappedDateRanges = mapDateRangeFilters(data);
|
||||||
|
const normalizedData = normalizeFilterValues(mappedDateRanges);
|
||||||
|
const newParams = { ...params, ...normalizedData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
@@ -88,6 +195,7 @@ const useTenderListPresenter = () => {
|
|||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
|
control,
|
||||||
isMutating,
|
isMutating,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,12 +49,15 @@ const Modal: React.FC<ModalProps> = ({
|
|||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-999999 flex items-center justify-center bg-dark/50 dark:bg-black/50"
|
className="fixed inset-0 z-999999 flex items-center justify-center bg-dark/50 dark:bg-black/50"
|
||||||
onClick={onClose}
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
data-cy="modal-overlay"
|
data-cy="modal-overlay"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`max-h-[90vh] max-w-[90vw] overflow-auto rounded-lg bg-white p-5 shadow-4 dark:bg-dark-2 ${classNames}`}
|
className={`max-h-[90vh] max-w-[90vw] overflow-auto rounded-lg bg-white p-5 shadow-4 dark:bg-dark-2 ${classNames}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
data-cy="modal-content"
|
data-cy="modal-content"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const apiDefaultParams = {
|
export const apiDefaultParams = {
|
||||||
sort_by: "created_at",
|
sort_by: "created_at",
|
||||||
sort_order: "dsc",
|
sort_order: "desc",
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
};
|
};
|
||||||
|
|||||||
+53
-22
@@ -47,48 +47,64 @@
|
|||||||
/* third-party libraries CSS */
|
/* third-party libraries CSS */
|
||||||
|
|
||||||
.rmdp-wrapper {
|
.rmdp-wrapper {
|
||||||
@apply !shadow-3 dark:!shadow-card;
|
@apply !overflow-hidden !rounded-2xl !border !border-stroke/60 !bg-white/95 !shadow-[0_10px_30px_rgba(16,24,40,0.12)] dark:!border-dark-3/80 dark:!bg-dark-2/95 dark:!shadow-[0_20px_40px_rgba(2,6,23,0.55)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-calendar {
|
.rmdp-calendar {
|
||||||
@apply !p-6 dark:!bg-gray-dark;
|
@apply !p-5 dark:!bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-top-class {
|
||||||
|
@apply !border-b !border-stroke/60 !pb-3 dark:!border-dark-3/80;
|
||||||
}
|
}
|
||||||
.rmdp-time-picker {
|
.rmdp-time-picker {
|
||||||
@apply dark:!bg-gray-dark;
|
@apply !mt-2 !border-t !border-stroke/60 !pt-3 dark:!border-dark-3/80 dark:!bg-transparent;
|
||||||
}
|
}
|
||||||
.rmdp-time-picker div input {
|
.rmdp-time-picker div input {
|
||||||
@apply dark:!bg-gray-dark dark:!text-gray-5;
|
@apply !rounded-lg !border !border-stroke/70 !bg-white !text-dark !shadow-sm focus:!border-primary/70 dark:!border-dark-3/80 dark:!bg-dark-2 dark:!text-gray-5;
|
||||||
}
|
}
|
||||||
.rmdp-month-picker {
|
.rmdp-month-picker {
|
||||||
@apply dark:!bg-gray-dark;
|
@apply dark:!bg-transparent;
|
||||||
}
|
}
|
||||||
.rmdp-year-picker {
|
.rmdp-year-picker {
|
||||||
@apply dark:!bg-gray-dark;
|
@apply dark:!bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-header-values,
|
.rmdp-header-values,
|
||||||
.rmdp-week-day {
|
.rmdp-week-day {
|
||||||
@apply dark:!text-white;
|
@apply !font-semibold !text-dark/90 dark:!text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-header-values {
|
||||||
|
@apply !text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-week-day {
|
||||||
|
@apply !text-[11px] !uppercase !tracking-[0.08em] !text-dark-5 dark:!text-dark-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-arrow {
|
.rmdp-arrow {
|
||||||
@apply dark:!border-white;
|
@apply !border-dark/70 dark:!border-white/90;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-arrow:hover {
|
.rmdp-arrow-container {
|
||||||
@apply !bg-gray-2 dark:!bg-dark-2;
|
@apply !rounded-lg transition-colors duration-200 hover:!bg-gray-2 dark:hover:!bg-dark-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-day {
|
.rmdp-day {
|
||||||
@apply dark:!text-dark-6;
|
@apply !m-0 !p-0 dark:!text-dark-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-day span {
|
||||||
|
@apply !h-8 !w-8 !rounded-lg !font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden) span:hover {
|
.rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden) span:hover {
|
||||||
@apply !border-gray-2 !bg-gray-2 !text-dark dark:!border-dark-2 dark:!bg-dark-2 dark:!text-white;
|
@apply !border-primary/20 !bg-primary/10 !text-primary dark:!border-primary/30 dark:!bg-primary/20 dark:!text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-day.rmdp-today span {
|
.rmdp-day.rmdp-today span {
|
||||||
@apply !bg-gray-2/70 !text-dark dark:!bg-dark-2/70 dark:!text-white;
|
@apply !border !border-primary/35 !bg-transparent !text-primary dark:!border-primary/45 dark:!bg-transparent dark:!text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-selected span:not(.highlight) {
|
.rmdp-selected span:not(.highlight) {
|
||||||
@@ -97,19 +113,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-day.rmdp-range {
|
.rmdp-day.rmdp-range {
|
||||||
@apply !bg-gray-2 !shadow-none dark:!bg-dark-2;
|
@apply !bg-transparent !shadow-none;
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
.rmdp-day.rmdp-range.start {
|
.rmdp-day.rmdp-range span {
|
||||||
@apply !bg-gray-2 !text-white !shadow-none dark:!bg-dark-2;
|
@apply !h-8 !w-full !rounded-none !border-transparent !bg-primary/10 !text-primary dark:!bg-primary/20 dark:!text-white;
|
||||||
}
|
|
||||||
.rmdp-day.rmdp-range.end {
|
|
||||||
@apply !bg-gray-2 !text-white !shadow-none dark:!bg-dark-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rmdp-day.rmdp-range.start,
|
.rmdp-day.rmdp-range.start span,
|
||||||
.rmdp-day.rmdp-range.end {
|
.rmdp-day.rmdp-range.end span {
|
||||||
@apply !bg-primary;
|
@apply !h-8 !w-full !border-primary !bg-primary !text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-day.rmdp-range.start span {
|
||||||
|
@apply !rounded-l-lg !rounded-r-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-day.rmdp-range.end span {
|
||||||
|
@apply !rounded-l-none !rounded-r-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-day.rmdp-today.rmdp-range.start span,
|
||||||
|
.rmdp-day.rmdp-today.rmdp-range.end span,
|
||||||
|
.rmdp-day.rmdp-today.rmdp-selected span {
|
||||||
|
@apply !border-primary !bg-primary !text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmdp-disabled {
|
||||||
|
@apply !opacity-45;
|
||||||
}
|
}
|
||||||
.tableCheckbox:checked ~ div span {
|
.tableCheckbox:checked ~ div span {
|
||||||
@apply opacity-100;
|
@apply opacity-100;
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ export const useCompanyFullList = (options?: { enabled?: boolean }) => {
|
|||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const useCompaniesQuery = (params?: Record<string, any>) => {
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.COMPANIES.READ_ALL, params],
|
||||||
|
[params],
|
||||||
|
);
|
||||||
|
return useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => companiesService.getCompanies({ ...params }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useCompaniesInfiniteQuery = (params?: Record<string, any>) => {
|
export const useCompaniesInfiniteQuery = (params?: Record<string, any>) => {
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => [API_ENDPOINTS.COMPANIES.READ_ALL, params],
|
() => [API_ENDPOINTS.COMPANIES.READ_ALL, params],
|
||||||
|
|||||||
@@ -48,3 +48,13 @@ export const useTranslateTenderMutation = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const useGetTenderDocumentsQuery = (id: string) => {
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)],
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => tendersService.getDocuments(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -45,6 +45,8 @@ export const API_ENDPOINTS = {
|
|||||||
DETAILS: (id: string) => `tenders/${id}`,
|
DETAILS: (id: string) => `tenders/${id}`,
|
||||||
AI_TRANSLATE: (id: string) => `tenders/${id}/ai-translate`,
|
AI_TRANSLATE: (id: string) => `tenders/${id}/ai-translate`,
|
||||||
AI_SUMMARY: (id: string) => `tenders/${id}/ai-summary`,
|
AI_SUMMARY: (id: string) => `tenders/${id}/ai-summary`,
|
||||||
|
DOCUMENTS: (id: string) => `tenders/${id}/documents`,
|
||||||
|
DOCUMENTS_DOWNLOAD: (id: string) => `tenders/${id}/documents/download`,
|
||||||
},
|
},
|
||||||
CUSTOMERS: {
|
CUSTOMERS: {
|
||||||
READ_ALL: "customers",
|
READ_ALL: "customers",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { API_ENDPOINTS } from "../endpoints";
|
|||||||
import { ApiResponse } from "../types";
|
import { ApiResponse } from "../types";
|
||||||
import {
|
import {
|
||||||
TTenderDetails,
|
TTenderDetails,
|
||||||
|
TTenderDocumentsResponse,
|
||||||
TTenderResponse,
|
TTenderResponse,
|
||||||
TTenderTranslationResponse,
|
TTenderTranslationResponse,
|
||||||
} from "../types/Tenders";
|
} from "../types/Tenders";
|
||||||
@@ -50,4 +51,13 @@ export const tendersService = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDocuments: async (id: string): Promise<ApiResponse<TTenderDocumentsResponse[]>> => {
|
||||||
|
try {
|
||||||
|
return (await api.get(API_ENDPOINTS.TENDERS.DOCUMENTS(id))).data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ERROR caught in Tender Service => get documents: ", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export const TenderSchema = z.object({
|
|||||||
overall_summary: z.string().optional(),
|
overall_summary: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TenderDocumentsResponseSchema = z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
last_modified: z.number(),
|
||||||
|
document_type: z.string(),
|
||||||
|
})
|
||||||
export const TenderTranslationResponseSchema = z.object({
|
export const TenderTranslationResponseSchema = z.object({
|
||||||
notice_id: z.string(),
|
notice_id: z.string(),
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
@@ -36,6 +42,8 @@ export const TenderTranslationResponseSchema = z.object({
|
|||||||
export const TendersResponse = z.object({
|
export const TendersResponse = z.object({
|
||||||
tenders: z.array(TenderSchema).or(z.null()),
|
tenders: z.array(TenderSchema).or(z.null()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type TTenderDocumentsResponse = z.infer<typeof TenderDocumentsResponseSchema>;
|
||||||
export type TTenderResponse = z.infer<typeof TendersResponse>;
|
export type TTenderResponse = z.infer<typeof TendersResponse>;
|
||||||
export type TTenderDetails = z.infer<typeof TenderSchema>;
|
export type TTenderDetails = z.infer<typeof TenderSchema>;
|
||||||
export type TTenderTranslationResponse = z.infer<
|
export type TTenderTranslationResponse = z.infer<
|
||||||
|
|||||||
Reference in New Issue
Block a user