feat(dashboard): integrate new dashboard queries and refactor closing soon component

- Replaced TTenderDetails with TDashboardClosingSoonItem in the ClosingSoonCard component for better type accuracy.
- Removed the fake-data.ts file as it is no longer needed with the new dashboard queries.
- Introduced new hooks for fetching dashboard data, including summary, trend, countries, notice types, and closing soon items.
- Updated useDashboardData to utilize the new dashboard queries, enhancing data retrieval and management.
- Enhanced the API service layer with new endpoints for dashboard functionalities, improving overall architecture.
This commit is contained in:
AmirReza Jamali
2026-05-20 13:01:36 +03:30
parent d7a3db07ab
commit f080d51e63
14 changed files with 310 additions and 313 deletions
@@ -1,14 +1,14 @@
"use client";
import { cn } from "@/lib/utils";
import { TTenderDetails } from "@/lib/api";
import { TDashboardClosingSoonItem } from "@/lib/api";
import gsap from "gsap";
import Link from "next/link";
import { useLayoutEffect, useRef } from "react";
import { ArrowRightIcon, ClockIcon, FireIcon } from "./icons";
type Props = {
tenders: TTenderDetails[];
tenders: TDashboardClosingSoonItem[];
isLoading: boolean;
};
@@ -121,12 +121,10 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
day: "numeric",
})
: "No deadline"}
{t.buyer_organization?.name && (
{t.buyer_name && (
<>
<span className="opacity-50"></span>
<span className="truncate">
{t.buyer_organization.name}
</span>
<span className="truncate">{t.buyer_name}</span>
</>
)}
</p>
@@ -1,168 +0,0 @@
import { TTenderDetails } from "@/lib/api";
const NOW = Math.floor(Date.now() / 1000);
const DAY = 86_400;
const HOUR = 3600;
const COUNTRIES = [
"DE",
"FR",
"IT",
"ES",
"NL",
"BE",
"PT",
"PL",
"SE",
"AT",
"AE",
"GB",
];
const NOTICE_TYPES = [
"CONTRACT_NOTICE",
"PRIOR_INFORMATION",
"CONTRACT_AWARD",
"MODIFICATION",
"CONCESSION",
"DESIGN_CONTEST",
];
const FORM_TYPES = ["PLANNING", "COMPETITION", "RESULT", "DIRECTIVE"];
const STATUSES = [
"active",
"active",
"active",
"active",
"awarded",
"awarded",
"expired",
"cancelled",
"draft",
];
const BUYERS = [
"Ministerio de Hacienda",
"Comune di Milano",
"City of Rotterdam",
"Bundesministerium für Verkehr",
"Région Île-de-France",
"Stockholm Stad",
"Generalitat de Catalunya",
"Wiener Stadtwerke",
"Servicio Andaluz de Salud",
"Dubai Municipality",
"Camara Municipal de Lisboa",
"Polska Grupa Energetyczna",
"Ayuntamiento de Madrid",
"Deutsche Bahn AG",
"Aéroports de Paris",
];
const TITLES = [
"Construction of intermodal logistics hub",
"Supply of energy-efficient LED street lighting",
"Modernization of municipal water treatment plant",
"IT consultancy for public sector cloud migration",
"Renovation of historic city center facades",
"Procurement of electric public transit buses",
"Maintenance services for highway infrastructure",
"Hospital medical equipment and consumables",
"Smart city traffic monitoring system rollout",
"Cybersecurity audit and penetration testing",
"Educational software licenses for public schools",
"Green roof installation across municipal buildings",
"Catering services for government cafeterias",
"Construction supervision of metro line extension",
"Renewable energy plant feasibility study",
"Digital archive platform for national library",
"Waste collection and recycling vehicles supply",
"Emergency response communications upgrade",
"Cleaning and sanitation services framework",
"Urban park redevelopment design contest",
];
const CURRENCIES = ["EUR", "EUR", "EUR", "USD", "GBP", "AED"];
function rand(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function pick<T>(arr: T[]): T {
return arr[rand(0, arr.length - 1)];
}
/**
* Deterministic seed so the dashboard looks stable across reloads
* within the same calendar day (titles/timestamps shuffle once per day).
*/
function seededShuffle<T>(arr: T[], seed: number) {
const out = [...arr];
let s = seed;
for (let i = out.length - 1; i > 0; i--) {
s = (s * 9301 + 49297) % 233280;
const j = Math.floor((s / 233280) * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
export function buildFakeTenders(count = 80): TTenderDetails[] {
const today = new Date();
const seed =
today.getFullYear() * 10000 +
(today.getMonth() + 1) * 100 +
today.getDate();
const titlePool = seededShuffle(TITLES, seed);
const buyerPool = seededShuffle(BUYERS, seed + 1);
const tenders: TTenderDetails[] = [];
for (let i = 0; i < count; i++) {
const country = COUNTRIES[i % COUNTRIES.length];
const isUrgentDeadline = i < 8;
const isFuture = i < 30;
const status = isUrgentDeadline ? "active" : pick(STATUSES);
const created = NOW - rand(0, 14) * DAY - rand(0, 23) * HOUR;
const published = created - rand(0, 2) * DAY;
const submissionDeadline = isUrgentDeadline
? NOW + rand(2, 6) * HOUR + i * DAY * 0.3
: isFuture
? NOW + rand(2, 21) * DAY
: NOW - rand(1, 60) * DAY;
const tenderDeadline = submissionDeadline + rand(0, 5) * DAY;
tenders.push({
id: `fake-${i + 1}`,
notice_publication_id: `PUB-${100000 + i}`,
tender_id: `T-${2026000 + i}`,
title: titlePool[i % titlePool.length],
description:
"Demonstration tender record generated for the dashboard preview. Replace with backend data when the dashboard API is wired up.",
procurement_type_code: pick(["WORKS", "SUPPLIES", "SERVICES"]),
procedure_code: pick(["OPEN", "RESTRICTED", "NEGOTIATED"]),
main_classification: "45000000",
estimated_value: rand(50_000, 12_000_000),
currency: pick(CURRENCIES),
publication_date: published,
tender_deadline: tenderDeadline,
submission_deadline: submissionDeadline,
application_deadline: submissionDeadline,
submission_url: "https://example.com/tender",
country_code: country,
buyer_organization: {
name: buyerPool[i % buyerPool.length],
contact_email: "procurement@example.com",
},
status,
form_type: pick(FORM_TYPES),
notice_type_code: pick(NOTICE_TYPES),
created_at: created,
} as unknown as TTenderDetails);
}
return tenders;
}
@@ -1,24 +1,24 @@
"use client";
import { useTendersQuery } from "@/hooks/queries";
import {
useDashboardClosingSoonQuery,
useDashboardCountriesQuery,
useDashboardNoticeTypesQuery,
useDashboardSummaryQuery,
useDashboardTrendQuery,
useTendersQuery,
} from "@/hooks/queries";
import { useMemo } from "react";
import { buildFakeTenders } from "./fake-data";
const NOW_SECONDS = () => Math.floor(Date.now() / 1000);
const DAY = 86_400;
/**
* Toggle to `false` once the aggregate dashboard API documented in
* `docs/backend/dashboard-api.md` is available — the hook will then
* source summary/trend/country metrics from the backend instead of
* the bundled demo set. The "Recent Tenders" list always uses the
* real `useTendersQuery` source.
*/
const USE_FAKE_AGGREGATES = true;
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
export function useDashboardData() {
const summaryQuery = useDashboardSummaryQuery();
const trendQuery = useDashboardTrendQuery({ days: 14, metric: "created" });
const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
const noticeTypesQuery = useDashboardNoticeTypesQuery();
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
const {
data: recentData,
isPending: recentPending,
@@ -31,136 +31,88 @@ export function useDashboardData() {
limit: 5,
});
const realRecent = recentData?.data?.tenders ?? [];
const summary = summaryQuery.data?.data;
const trendResponse = trendQuery.data?.data;
const countriesResponse = countriesQuery.data?.data;
const noticeTypesResponse = noticeTypesQuery.data?.data;
const closingSoonList = closingSoonQuery.data?.data?.items ?? [];
const tenders = useMemo(
() => (USE_FAKE_AGGREGATES ? buildFakeTenders(80) : []),
[],
);
const isPending =
summaryQuery.isPending ||
trendQuery.isPending ||
countriesQuery.isPending ||
noticeTypesQuery.isPending ||
closingSoonQuery.isPending;
const isPending = USE_FAKE_AGGREGATES ? false : recentPending;
const isError = recentError;
const refetch = refetchRecent;
const isError =
summaryQuery.isError ||
trendQuery.isError ||
countriesQuery.isError ||
noticeTypesQuery.isError ||
closingSoonQuery.isError ||
recentError;
const derived = useMemo(() => {
const now = NOW_SECONDS();
const total = tenders.length;
let active = 0;
let closingSoon = 0;
let awarded = 0;
let totalValue = 0;
let valueCurrency = "EUR";
const countryCounts = new Map<string, number>();
const noticeTypeCounts = new Map<string, number>();
const dailyCounts = new Map<string, number>();
for (const t of tenders) {
const status = (t.status || "").toLowerCase();
if (status === "active") active += 1;
if (status === "awarded") awarded += 1;
const deadline = t.submission_deadline || t.tender_deadline || 0;
if (deadline > now && deadline - now < 7 * DAY) closingSoon += 1;
if (typeof t.estimated_value === "number" && t.estimated_value > 0) {
totalValue += t.estimated_value;
if (t.currency) valueCurrency = t.currency;
}
if (t.country_code) {
countryCounts.set(
t.country_code,
(countryCounts.get(t.country_code) ?? 0) + 1,
);
}
const noticeKey =
t.form_type || t.notice_type_code || t.procurement_type_code || "OTHER";
noticeTypeCounts.set(
noticeKey,
(noticeTypeCounts.get(noticeKey) ?? 0) + 1,
);
const created = t.created_at || t.publication_date;
if (created) {
const d = new Date(created * 1000);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
dailyCounts.set(key, (dailyCounts.get(key) ?? 0) + 1);
}
}
const countries = Array.from(countryCounts.entries())
.map(([code, count]) => ({ code, count }))
.sort((a, b) => b.count - a.count);
const noticeTypes = Array.from(noticeTypeCounts.entries())
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const trendDays = 14;
const trend: { date: string; label: string; count: number }[] = [];
for (let i = trendDays - 1; i >= 0; i--) {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - i);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
trend.push({
date: key,
label: d.toLocaleDateString("en", { month: "short", day: "numeric" }),
count: dailyCounts.get(key) ?? 0,
});
}
const fakeRecent = [...tenders]
.sort(
(a, b) =>
(b.created_at || b.publication_date || 0) -
(a.created_at || a.publication_date || 0),
)
.slice(0, 5);
const closingSoonList = tenders
.filter((t) => {
const dl = t.submission_deadline || t.tender_deadline || 0;
return dl > now;
})
.sort(
(a, b) =>
(a.submission_deadline || a.tender_deadline || 0) -
(b.submission_deadline || b.tender_deadline || 0),
)
.slice(0, 5);
return {
total,
active,
awarded,
closingSoon,
totalValue,
valueCurrency,
countries,
noticeTypes,
trend,
fakeRecent,
closingSoonList,
const refetch = () => {
summaryQuery.refetch();
trendQuery.refetch();
countriesQuery.refetch();
noticeTypesQuery.refetch();
closingSoonQuery.refetch();
refetchRecent();
};
}, [tenders]);
const recent = realRecent.length ? realRecent : derived.fakeRecent;
const recentIsLoading = recentPending && realRecent.length === 0;
const trend = useMemo(() => {
const series = trendResponse?.series ?? [];
return series.map((point) => {
const [y, m, d] = point.date.split("-").map(Number);
const date = new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
return {
date: point.date,
label: date.toLocaleDateString("en", {
month: "short",
day: "numeric",
}),
count: point.count,
};
});
}, [trendResponse]);
const { fakeRecent: _ignored, ...rest } = derived;
const countries = useMemo(
() =>
(countriesResponse?.items ?? []).map((item) => ({
code: item.country_code,
count: item.count,
})),
[countriesResponse],
);
const noticeTypes = useMemo(
() =>
(noticeTypesResponse?.items ?? []).map((item) => ({
type: item.type,
count: item.count,
})),
[noticeTypesResponse],
);
const recent = recentData?.data?.tenders ?? [];
const recentIsLoading = recentPending && recent.length === 0;
return {
isPending,
isError,
refetch,
tenders,
total: summary?.total_tenders ?? 0,
active: summary?.active_tenders ?? 0,
awarded: summary?.awarded_tenders ?? 0,
closingSoon: summary?.closing_soon ?? 0,
totalValue: summary?.total_estimated_value ?? 0,
valueCurrency: summary?.value_currency ?? "EUR",
countries,
noticeTypes,
trend,
recent,
recentIsLoading,
...rest,
closingSoonList,
};
}
+6 -2
View File
@@ -16,6 +16,7 @@ import { TenderDetailsError } from "./components/tender-details-error";
import { TenderDetailsHeader } from "./components/tender-details-header";
import { useTenderSectionScrollSpy } from "./hooks/use-tender-section-scroll-spy";
import { getTenderNavItems } from "./tender-nav-utils";
import { shouldShowAiSummarySection } from "./tender-display-utils";
interface IProps {
params: Promise<{
@@ -43,11 +44,14 @@ const TenderDetails = ({ params }: IProps) => {
];
const tenderForNav = data?.data;
const hasValidAiSummary = tenderForNav
? shouldShowAiSummarySection(tenderForNav)
: false;
const { data: documentsResponse, isPending: isDocumentsLoading } =
useGetTenderDocumentsQuery(details);
useGetTenderDocumentsQuery(details, { enabled: hasValidAiSummary });
const documents = documentsResponse?.data ?? [];
const showDocumentsSection =
isDocumentsLoading || documents.length > 0;
hasValidAiSummary && (isDocumentsLoading || documents.length > 0);
const tenderNavItems = useMemo(
() =>
+1 -1
View File
@@ -335,7 +335,7 @@ export function Select<T extends FieldValues>({
id={listboxId}
role="listbox"
aria-label={label}
className="z-[200] max-h-[min(22rem,calc(100vh-6rem))] overflow-hidden rounded-xl border border-stroke/70 bg-white shadow-[0_18px_50px_-12px_rgba(15,23,42,0.25)] dark:border-white/10 dark:bg-dark-2 dark:shadow-[0_24px_60px_-12px_rgba(0,0,0,0.65)]"
className="z-[1000000] max-h-[min(22rem,calc(100vh-6rem))] overflow-hidden rounded-xl border border-stroke/70 bg-white shadow-[0_18px_50px_-12px_rgba(15,23,42,0.25)] dark:border-white/10 dark:bg-dark-2 dark:shadow-[0_24px_60px_-12px_rgba(0,0,0,0.65)]"
style={{
position: "fixed",
top: panelRect.top,
+1
View File
@@ -15,3 +15,4 @@ export * from "./useTenders";
export * from "./useUsersQueries";
export * from "./useAdminListQuery";
export * from "./useSelectSearchQuery";
export * from "./useDashboardQueries";
+62
View File
@@ -0,0 +1,62 @@
import { API_ENDPOINTS, dashboardService } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useDashboardSummaryQuery = (params?: {
closing_window?: number;
}) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.DASHBOARD.SUMMARY, params],
[params],
);
return useQuery({
queryKey,
queryFn: () => dashboardService.summary(params),
});
};
export const useDashboardTrendQuery = (params?: {
days?: number;
metric?: "created" | "published" | "awarded";
}) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.DASHBOARD.TREND, params],
[params],
);
return useQuery({
queryKey,
queryFn: () => dashboardService.trend(params),
});
};
export const useDashboardCountriesQuery = (params?: { limit?: number }) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.DASHBOARD.COUNTRIES, params],
[params],
);
return useQuery({
queryKey,
queryFn: () => dashboardService.countries(params),
});
};
export const useDashboardNoticeTypesQuery = () => {
return useQuery({
queryKey: [API_ENDPOINTS.DASHBOARD.NOTICE_TYPES],
queryFn: () => dashboardService.noticeTypes(),
});
};
export const useDashboardClosingSoonQuery = (params?: {
limit?: number;
window?: number;
}) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.DASHBOARD.CLOSING_SOON, params],
[params],
);
return useQuery({
queryKey,
queryFn: () => dashboardService.closingSoon(params),
});
};
+5 -1
View File
@@ -48,7 +48,10 @@ export const useTranslateTenderMutation = () => {
},
});
};
export const useGetTenderDocumentsQuery = (id: string) => {
export const useGetTenderDocumentsQuery = (
id: string,
options?: { enabled?: boolean },
) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)],
[id],
@@ -56,5 +59,6 @@ export const useGetTenderDocumentsQuery = (id: string) => {
return useQuery({
queryKey,
queryFn: () => tendersService.getDocuments(id),
enabled: options?.enabled ?? true,
});
};
+1 -1
View File
@@ -10,7 +10,7 @@ import axios from "axios";
const api = axios.create({
baseURL: "/api/proxy/",
timeout: 10000,
timeout: 60000,
headers: {
"Content-Type": "application/json",
},
+9
View File
@@ -76,4 +76,13 @@ export const API_ENDPOINTS = {
BASE: (id?: string) => (id ? `inquiries/${id}` : "inquiries"),
STATUS: (id: string) => `inquiries/${id}/status`,
},
DASHBOARD: {
SUMMARY: "dashboard/summary",
TREND: "dashboard/trend",
COUNTRIES: "dashboard/countries",
NOTICE_TYPES: "dashboard/notice-types",
CLOSING_SOON: "dashboard/closing-soon",
RECENT: "dashboard/recent",
},
} as const;
+74
View File
@@ -0,0 +1,74 @@
import api from "../axios";
import { API_ENDPOINTS } from "../endpoints";
import {
ApiResponse,
TDashboardClosingSoon,
TDashboardCountries,
TDashboardNoticeTypes,
TDashboardSummary,
TDashboardTrend,
} from "../types";
export const dashboardService = {
summary: async (params?: {
closing_window?: number;
}): Promise<ApiResponse<TDashboardSummary>> => {
try {
return (await api.get(API_ENDPOINTS.DASHBOARD.SUMMARY, { params })).data;
} catch (error) {
console.error("ERROR caught in Dashboard Service => summary:", error);
throw error;
}
},
trend: async (params?: {
days?: number;
metric?: "created" | "published" | "awarded";
}): Promise<ApiResponse<TDashboardTrend>> => {
try {
return (await api.get(API_ENDPOINTS.DASHBOARD.TREND, { params })).data;
} catch (error) {
console.error("ERROR caught in Dashboard Service => trend:", error);
throw error;
}
},
countries: async (params?: {
limit?: number;
}): Promise<ApiResponse<TDashboardCountries>> => {
try {
return (await api.get(API_ENDPOINTS.DASHBOARD.COUNTRIES, { params })).data;
} catch (error) {
console.error("ERROR caught in Dashboard Service => countries:", error);
throw error;
}
},
noticeTypes: async (): Promise<ApiResponse<TDashboardNoticeTypes>> => {
try {
return (await api.get(API_ENDPOINTS.DASHBOARD.NOTICE_TYPES)).data;
} catch (error) {
console.error(
"ERROR caught in Dashboard Service => notice-types:",
error,
);
throw error;
}
},
closingSoon: async (params?: {
limit?: number;
window?: number;
}): Promise<ApiResponse<TDashboardClosingSoon>> => {
try {
return (await api.get(API_ENDPOINTS.DASHBOARD.CLOSING_SOON, { params }))
.data;
} catch (error) {
console.error(
"ERROR caught in Dashboard Service => closing-soon:",
error,
);
throw error;
}
},
};
+1
View File
@@ -1,4 +1,5 @@
export * from "./companies-service";
export * from "./dashboard-service";
export * from "./inquiries-service";
export * from "./profile-service";
export * from "./tenders-service";
+59
View File
@@ -0,0 +1,59 @@
export type TDashboardSummary = {
total_tenders: number;
active_tenders: number;
awarded_tenders: number;
expired_tenders: number;
cancelled_tenders: number;
closing_soon: number;
total_estimated_value: number;
value_currency: string;
generated_at: number;
};
export type TDashboardTrendPoint = {
date: string;
count: number;
};
export type TDashboardTrend = {
metric: "created" | "published" | "awarded";
days: number;
series: TDashboardTrendPoint[];
};
export type TDashboardCountryItem = {
country_code: string;
count: number;
};
export type TDashboardCountries = {
total: number;
items: TDashboardCountryItem[];
other_count: number;
};
export type TDashboardNoticeTypeItem = {
type: string;
count: number;
};
export type TDashboardNoticeTypes = {
total: number;
items: TDashboardNoticeTypeItem[];
};
export type TDashboardClosingSoonItem = {
id: string;
title: string;
country_code: string;
buyer_name?: string;
submission_deadline: number;
tender_deadline?: number;
status: string;
estimated_value?: number;
currency?: string;
};
export type TDashboardClosingSoon = {
items: TDashboardClosingSoonItem[];
};
+1
View File
@@ -10,3 +10,4 @@ export * from "./TInquiries";
export * from "./TCms";
export * from "./Feedback";
export * from "./Tenders";
export * from "./Dashboard";