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,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 isError =
summaryQuery.isError ||
trendQuery.isError ||
countriesQuery.isError ||
noticeTypesQuery.isError ||
closingSoonQuery.isError ||
recentError;
const refetch = () => {
summaryQuery.refetch();
trendQuery.refetch();
countriesQuery.refetch();
noticeTypesQuery.refetch();
closingSoonQuery.refetch();
refetchRecent();
};
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 countries = useMemo(
() =>
(countriesResponse?.items ?? []).map((item) => ({
code: item.country_code,
count: item.count,
})),
[countriesResponse],
);
const isPending = USE_FAKE_AGGREGATES ? false : recentPending;
const isError = recentError;
const refetch = refetchRecent;
const noticeTypes = useMemo(
() =>
(noticeTypesResponse?.items ?? []).map((item) => ({
type: item.type,
count: item.count,
})),
[noticeTypesResponse],
);
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,
};
}, [tenders]);
const recent = realRecent.length ? realRecent : derived.fakeRecent;
const recentIsLoading = recentPending && realRecent.length === 0;
const { fakeRecent: _ignored, ...rest } = derived;
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,
};
}