feat(dashboard): refactor home page to utilize TenderDashboard component
- Replaced the previous home page structure with a simplified TenderDashboard component for improved clarity and maintainability. - Updated the .gitignore file to include IDE-specific files, ensuring a cleaner repository. - Enhanced the useTenderListPresenter to support new parsing logic for cpv_codes in search parameters. - Introduced a new utility function, buildQueryString, for better handling of query parameters in API requests.
This commit is contained in:
@@ -47,3 +47,12 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
#ide
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.cursor
|
||||||
|
.cursor/worktrees.json
|
||||||
|
.cursor/worktrees
|
||||||
|
.cursor/worktrees.json
|
||||||
|
.claude
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: number;
|
||||||
|
duration?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
formatter?: (v: number) => string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFormatter = (v: number) =>
|
||||||
|
Math.round(v).toLocaleString("en-US");
|
||||||
|
|
||||||
|
export function AnimatedCounter({
|
||||||
|
value,
|
||||||
|
duration = 1.4,
|
||||||
|
prefix = "",
|
||||||
|
suffix = "",
|
||||||
|
formatter = defaultFormatter,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const proxy = useRef({ v: 0 });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (reduced) {
|
||||||
|
ref.current.textContent = `${prefix}${formatter(value)}${suffix}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = proxy.current;
|
||||||
|
const tween = gsap.to(target, {
|
||||||
|
v: value,
|
||||||
|
duration,
|
||||||
|
ease: "power2.out",
|
||||||
|
onUpdate: () => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.textContent = `${prefix}${formatter(target.v)}${suffix}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tween.kill();
|
||||||
|
target.v = value;
|
||||||
|
};
|
||||||
|
}, [value, duration, prefix, suffix, formatter]);
|
||||||
|
|
||||||
|
return <span ref={ref} className={className}>{prefix}0{suffix}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TTenderDetails } 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[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeLeft(unix: number) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = unix - now;
|
||||||
|
if (diff <= 0) return { label: "Expired", urgent: true };
|
||||||
|
const days = Math.floor(diff / 86400);
|
||||||
|
if (days >= 1) return { label: `${days}d left`, urgent: days <= 2 };
|
||||||
|
const hours = Math.floor(diff / 3600);
|
||||||
|
if (hours >= 1) return { label: `${hours}h left`, urgent: true };
|
||||||
|
const minutes = Math.max(1, Math.floor(diff / 60));
|
||||||
|
return { label: `${minutes}m left`, urgent: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClosingSoonCard({ tenders, isLoading }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from(rootRef.current, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 24,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.3,
|
||||||
|
});
|
||||||
|
gsap.from("[data-closing-row]", {
|
||||||
|
x: 24,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.45,
|
||||||
|
stagger: 0.07,
|
||||||
|
delay: 0.55,
|
||||||
|
ease: "power2.out",
|
||||||
|
});
|
||||||
|
}, rootRef);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [tenders.length, isLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-orange-light/10 text-orange-light">
|
||||||
|
<FireIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||||
|
Closing Soon
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-dark-6">Tenders with looming deadlines</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/tenders"
|
||||||
|
className="hidden items-center gap-1 text-xs font-medium text-primary hover:underline sm:inline-flex"
|
||||||
|
>
|
||||||
|
View all <ArrowRightIcon className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark">
|
||||||
|
{isLoading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li key={i} className="flex animate-pulse items-center gap-3 px-5 py-3.5">
|
||||||
|
<div className="size-10 rounded-lg bg-gray-2 dark:bg-dark-3" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-3 w-3/4 rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
<div className="h-2 w-1/2 rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-16 rounded-full bg-gray-2 dark:bg-dark-3" />
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: tenders.map((t) => {
|
||||||
|
const deadline = t.submission_deadline || t.tender_deadline || 0;
|
||||||
|
const tl = timeLeft(deadline);
|
||||||
|
const country = (t.country_code || "??").toUpperCase();
|
||||||
|
return (
|
||||||
|
<li key={t.id} data-closing-row>
|
||||||
|
<Link
|
||||||
|
href={`/tenders/${t.id}`}
|
||||||
|
className="group flex items-center gap-3 px-5 py-3.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-10 shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white shadow-sm",
|
||||||
|
tl.urgent
|
||||||
|
? "bg-gradient-to-br from-orange-light to-error"
|
||||||
|
: "bg-gradient-to-br from-[#5750F1] to-[#0ABEF9]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{country.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="line-clamp-1 text-sm font-semibold text-dark transition-colors group-hover:text-primary dark:text-white">
|
||||||
|
{t.title || "Untitled tender"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-dark-6">
|
||||||
|
<ClockIcon className="size-3" />
|
||||||
|
{deadline
|
||||||
|
? new Date(deadline * 1000).toLocaleDateString("en", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: "No deadline"}
|
||||||
|
{t.buyer_organization?.name && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-50">•</span>
|
||||||
|
<span className="truncate">
|
||||||
|
{t.buyer_organization.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold tabular-nums",
|
||||||
|
tl.urgent
|
||||||
|
? "bg-orange-light/15 text-orange-light"
|
||||||
|
: "bg-primary/10 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tl.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && tenders.length === 0 && (
|
||||||
|
<li className="px-5 py-10 text-center text-sm text-dark-6">
|
||||||
|
No upcoming deadlines.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||||
|
import { GlobeIcon } from "./icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
countries: { code: string; count: number }[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
"#5750F1",
|
||||||
|
"#0ABEF9",
|
||||||
|
"#22AD5C",
|
||||||
|
"#F59460",
|
||||||
|
"#F23030",
|
||||||
|
"#FCD34D",
|
||||||
|
"#8155FF",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RADIUS = 56;
|
||||||
|
const INNER = 38;
|
||||||
|
const CIRC = 2 * Math.PI * ((RADIUS + INNER) / 2);
|
||||||
|
const STROKE = RADIUS - INNER;
|
||||||
|
|
||||||
|
function flagEmoji(code: string) {
|
||||||
|
if (!code || code.length !== 2) return "";
|
||||||
|
const base = 0x1f1e6;
|
||||||
|
return String.fromCodePoint(
|
||||||
|
base + code.toUpperCase().charCodeAt(0) - 65,
|
||||||
|
base + code.toUpperCase().charCodeAt(1) - 65,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountryDistribution({ countries, isLoading }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const arcsRef = useRef<SVGGElement>(null);
|
||||||
|
const top = countries.slice(0, 6);
|
||||||
|
const totalShown = top.reduce((acc, c) => acc + c.count, 0) || 1;
|
||||||
|
|
||||||
|
const segments = useMemo(() => {
|
||||||
|
let offset = 0;
|
||||||
|
return top.map((c, i) => {
|
||||||
|
const length = (c.count / totalShown) * CIRC;
|
||||||
|
const seg = {
|
||||||
|
code: c.code,
|
||||||
|
count: c.count,
|
||||||
|
color: PALETTE[i % PALETTE.length],
|
||||||
|
dash: `${length} ${CIRC - length}`,
|
||||||
|
offset: -offset,
|
||||||
|
};
|
||||||
|
offset += length;
|
||||||
|
return seg;
|
||||||
|
});
|
||||||
|
}, [top, totalShown]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from(rootRef.current, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 24,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (arcsRef.current) {
|
||||||
|
const circles =
|
||||||
|
arcsRef.current.querySelectorAll<SVGCircleElement>("circle.arc");
|
||||||
|
circles.forEach((c) => {
|
||||||
|
const finalDash = c.getAttribute("stroke-dasharray") || "0 0";
|
||||||
|
gsap.fromTo(
|
||||||
|
c,
|
||||||
|
{ strokeDasharray: `0 ${CIRC}` },
|
||||||
|
{
|
||||||
|
strokeDasharray: finalDash,
|
||||||
|
duration: 1,
|
||||||
|
ease: "power2.out",
|
||||||
|
delay: 0.45,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.from("[data-country-row]", {
|
||||||
|
x: 20,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.45,
|
||||||
|
stagger: 0.06,
|
||||||
|
delay: 0.5,
|
||||||
|
ease: "power2.out",
|
||||||
|
});
|
||||||
|
}, rootRef);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [segments.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<GlobeIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||||
|
By Country
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-dark-6">Top originating regions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-5 md:flex-row md:items-start">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<svg width={(RADIUS + STROKE) * 2} height={(RADIUS + STROKE) * 2}>
|
||||||
|
<g
|
||||||
|
ref={arcsRef}
|
||||||
|
transform={`translate(${RADIUS + STROKE} ${RADIUS + STROKE}) rotate(-90)`}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
r={(RADIUS + INNER) / 2}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity="0.08"
|
||||||
|
strokeWidth={STROKE}
|
||||||
|
className="text-dark-6"
|
||||||
|
/>
|
||||||
|
{segments.map((s, i) => (
|
||||||
|
<circle
|
||||||
|
key={s.code}
|
||||||
|
className="arc"
|
||||||
|
r={(RADIUS + INNER) / 2}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={STROKE}
|
||||||
|
strokeDasharray={s.dash}
|
||||||
|
strokeDashoffset={s.offset}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
style={{ filter: `drop-shadow(0 0 6px ${s.color}40)` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||||
|
Total
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-dark dark:text-white">
|
||||||
|
{isLoading ? "—" : totalShown}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex-1 space-y-2">
|
||||||
|
{(isLoading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
code: `--${i}`,
|
||||||
|
count: 0,
|
||||||
|
color: PALETTE[i],
|
||||||
|
}))
|
||||||
|
: segments.length
|
||||||
|
? segments
|
||||||
|
: []
|
||||||
|
).map((s, i) => {
|
||||||
|
const pct = isLoading
|
||||||
|
? 0
|
||||||
|
: Math.round((s.count / totalShown) * 100);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={s.code}
|
||||||
|
data-country-row
|
||||||
|
className="group flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-2.5 shrink-0 rounded-full",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: s.color,
|
||||||
|
boxShadow: `0 0 0 3px ${s.color}22`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-base leading-none">
|
||||||
|
{flagEmoji(s.code)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-sm font-medium text-dark dark:text-white">
|
||||||
|
{s.code.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div className="hidden h-1.5 w-24 overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3 sm:block">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-700"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: s.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-10 text-right text-xs font-semibold tabular-nums text-dark-5 dark:text-dark-6">
|
||||||
|
{isLoading ? "—" : `${pct}%`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && segments.length === 0 && (
|
||||||
|
<li className="text-center text-sm text-dark-6">No data yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { AnimatedCounter } from "./animated-counter";
|
||||||
|
import { SparkleIcon } from "./icons";
|
||||||
|
import { LiveClock } from "./live-clock";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
closingSoon: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function greeting() {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 5) return "Working late";
|
||||||
|
if (h < 12) return "Good morning";
|
||||||
|
if (h < 17) return "Good afternoon";
|
||||||
|
if (h < 21) return "Good evening";
|
||||||
|
return "Good night";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHero({ total, active, closingSoon, isLoading }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const orbARef = useRef<HTMLDivElement>(null);
|
||||||
|
const orbBRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
|
||||||
|
gsap.from(rootRef.current, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 24,
|
||||||
|
duration: 0.7,
|
||||||
|
ease: "power3.out",
|
||||||
|
});
|
||||||
|
|
||||||
|
const chars = titleRef.current?.querySelectorAll<HTMLElement>("[data-char]");
|
||||||
|
if (chars && chars.length) {
|
||||||
|
gsap.from(chars, {
|
||||||
|
y: 28,
|
||||||
|
opacity: 0,
|
||||||
|
rotateX: -50,
|
||||||
|
duration: 0.6,
|
||||||
|
stagger: 0.025,
|
||||||
|
ease: "back.out(1.6)",
|
||||||
|
delay: 0.15,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.from("[data-hero-pill]", {
|
||||||
|
y: 12,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
stagger: 0.08,
|
||||||
|
delay: 0.4,
|
||||||
|
ease: "power2.out",
|
||||||
|
});
|
||||||
|
|
||||||
|
gsap.to(orbARef.current, {
|
||||||
|
x: 30,
|
||||||
|
y: -20,
|
||||||
|
scale: 1.15,
|
||||||
|
duration: 6,
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
ease: "sine.inOut",
|
||||||
|
});
|
||||||
|
gsap.to(orbBRef.current, {
|
||||||
|
x: -25,
|
||||||
|
y: 18,
|
||||||
|
scale: 1.1,
|
||||||
|
duration: 7,
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
ease: "sine.inOut",
|
||||||
|
});
|
||||||
|
}, rootRef);
|
||||||
|
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const headline = `${greeting()}, welcome to OppLens`;
|
||||||
|
const sub = "Real-time pulse on every tender flowing through your pipeline.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="relative overflow-hidden rounded-2xl border border-stroke-dark/60 bg-gradient-to-br from-dark via-dark-2 to-dark-3 p-6 text-white shadow-1 dark:border-stroke-dark dark:from-[#0B121F] dark:via-dark dark:to-dark-2 dark:shadow-card md:p-8"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={orbARef}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -right-16 -top-24 h-72 w-72 rounded-full bg-primary/45 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={orbBRef}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -bottom-24 -left-10 h-64 w-64 rounded-full bg-blue/35 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
|
||||||
|
backgroundSize: "18px 18px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span
|
||||||
|
data-hero-pill
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-medium uppercase tracking-wider backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<SparkleIcon className="size-3.5" />
|
||||||
|
Live tender intelligence
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
ref={titleRef}
|
||||||
|
className="mt-4 text-3xl font-bold leading-tight md:text-[40px] md:leading-[1.1]"
|
||||||
|
aria-label={headline}
|
||||||
|
>
|
||||||
|
{Array.from(headline).map((ch, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
data-char
|
||||||
|
className="inline-block whitespace-pre will-change-transform"
|
||||||
|
>
|
||||||
|
{ch}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-3 max-w-xl text-sm text-white/75 md:text-base">
|
||||||
|
{sub}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
|
<HeroPill
|
||||||
|
label="Tracking"
|
||||||
|
value={total}
|
||||||
|
suffix=" tenders"
|
||||||
|
loading={isLoading}
|
||||||
|
accent="bg-white text-[#3C50E0]"
|
||||||
|
/>
|
||||||
|
<HeroPill
|
||||||
|
label="Active"
|
||||||
|
value={active}
|
||||||
|
loading={isLoading}
|
||||||
|
accent="bg-emerald-400/20 text-emerald-50 border border-emerald-300/30"
|
||||||
|
/>
|
||||||
|
<HeroPill
|
||||||
|
label="Closing soon"
|
||||||
|
value={closingSoon}
|
||||||
|
loading={isLoading}
|
||||||
|
accent="bg-orange-400/20 text-orange-50 border border-orange-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LiveClock />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroPill({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
suffix,
|
||||||
|
loading,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
suffix?: string;
|
||||||
|
loading: boolean;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-hero-pill
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-semibold shadow-sm backdrop-blur-md",
|
||||||
|
accent,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider opacity-80">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{loading ? (
|
||||||
|
"—"
|
||||||
|
) : (
|
||||||
|
<AnimatedCounter value={value} suffix={suffix ?? ""} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
type P = SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
export const TenderIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
/>
|
||||||
|
<path d="M14 3v5h5" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M8.5 13.5h7M8.5 17h5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PulseIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 12h3l2-7 4 14 2-7h7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClockIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M12 7.5V12l3 2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CoinIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<ellipse cx="12" cy="7" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M4 7v5c0 1.7 3.6 3 8 3s8-1.3 8-3V7M4 12v5c0 1.7 3.6 3 8 3s8-1.3 8-3v-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GlobeIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M3 12h18M12 3c2.5 3 4 6 4 9s-1.5 6-4 9c-2.5-3-4-6-4-9s1.5-6 4-9Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SparkleIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m18 16 .8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8.8-2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FireIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M12 3s4 4 4 8a4 4 0 0 1-8 0c0-1.5.7-2.6 1.5-3.5C8.5 9 8 10 8 11.5a4 4 0 0 0 8 0c0-3-4-8.5-4-8.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowRightIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M5 12h14m0 0-5-5m5 5-5 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrendUpIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="m3 17 6-6 4 4 8-9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 6h7v7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrendDownIcon = (props: P) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="m3 7 6 6 4-4 8 9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 18h7v-7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ClosingSoonCard } from "./closing-soon";
|
||||||
|
import { CountryDistribution } from "./country-distribution";
|
||||||
|
import { DashboardHero } from "./hero";
|
||||||
|
import { NoticeTypeBreakdown } from "./notice-types";
|
||||||
|
import { RecentTenders } from "./recent-tenders";
|
||||||
|
import { TenderStatCards } from "./stat-cards";
|
||||||
|
import { TenderTrendsChart } from "./trends-chart";
|
||||||
|
import { useDashboardData } from "./use-dashboard-data";
|
||||||
|
|
||||||
|
export function TenderDashboard() {
|
||||||
|
const {
|
||||||
|
isPending,
|
||||||
|
total,
|
||||||
|
active,
|
||||||
|
closingSoon,
|
||||||
|
totalValue,
|
||||||
|
valueCurrency,
|
||||||
|
countries,
|
||||||
|
noticeTypes,
|
||||||
|
trend,
|
||||||
|
recent,
|
||||||
|
recentIsLoading,
|
||||||
|
closingSoonList,
|
||||||
|
} = useDashboardData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 md:space-y-6 2xl:space-y-7.5">
|
||||||
|
<DashboardHero
|
||||||
|
total={total}
|
||||||
|
active={active}
|
||||||
|
closingSoon={closingSoon}
|
||||||
|
isLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TenderStatCards
|
||||||
|
total={total}
|
||||||
|
active={active}
|
||||||
|
closingSoon={closingSoon}
|
||||||
|
totalValue={totalValue}
|
||||||
|
valueCurrency={valueCurrency}
|
||||||
|
isLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-4 md:gap-6 2xl:gap-7.5">
|
||||||
|
<div className="col-span-12 xl:col-span-8">
|
||||||
|
<TenderTrendsChart data={trend} isLoading={isPending} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-12 xl:col-span-4">
|
||||||
|
<CountryDistribution countries={countries} isLoading={isPending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 xl:col-span-5">
|
||||||
|
<NoticeTypeBreakdown
|
||||||
|
noticeTypes={noticeTypes}
|
||||||
|
isLoading={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-12 xl:col-span-7">
|
||||||
|
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12">
|
||||||
|
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type Token =
|
||||||
|
| { kind: "digit"; value: string; slot: string }
|
||||||
|
| { kind: "colon"; slot: string }
|
||||||
|
| { kind: "period"; value: string; slot: string };
|
||||||
|
|
||||||
|
function tokenize(date: Date): Token[] {
|
||||||
|
const hh = date.getHours();
|
||||||
|
const h12 = ((hh + 11) % 12) + 1;
|
||||||
|
const period = hh >= 12 ? "PM" : "AM";
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const ss = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
const hStr = String(h12).padStart(2, "0");
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ kind: "digit", value: hStr[0], slot: "h1" },
|
||||||
|
{ kind: "digit", value: hStr[1], slot: "h2" },
|
||||||
|
{ kind: "colon", slot: "c1" },
|
||||||
|
{ kind: "digit", value: mm[0], slot: "m1" },
|
||||||
|
{ kind: "digit", value: mm[1], slot: "m2" },
|
||||||
|
{ kind: "colon", slot: "c2" },
|
||||||
|
{ kind: "digit", value: ss[0], slot: "s1" },
|
||||||
|
{ kind: "digit", value: ss[1], slot: "s2" },
|
||||||
|
{ kind: "period", value: period, slot: "p" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveClock() {
|
||||||
|
const [now, setNow] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tokens = now ? tokenize(now) : tokenize(new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative shrink-0 rounded-2xl border border-white/15 bg-white/10 px-5 py-4 backdrop-blur-md">
|
||||||
|
<div className="text-[11px] uppercase tracking-widest text-white/75">
|
||||||
|
{now
|
||||||
|
? now.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-baseline overflow-hidden font-mono text-3xl font-bold tabular-nums">
|
||||||
|
{tokens.map((t) => {
|
||||||
|
if (t.kind === "digit") {
|
||||||
|
return <Digit key={t.slot} value={t.value} />;
|
||||||
|
}
|
||||||
|
if (t.kind === "colon") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={t.slot}
|
||||||
|
className="inline-block px-0.5 text-white/90"
|
||||||
|
>
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={t.slot}
|
||||||
|
className="ml-2 inline-block self-end pb-1 text-xs font-semibold tracking-[0.2em] text-white/70"
|
||||||
|
>
|
||||||
|
{t.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-white/75">
|
||||||
|
<span className="relative flex size-2">
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||||
|
</span>
|
||||||
|
Pipeline online
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Digit({ value }: { value: string }) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const prev = useRef<string>(value);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (prev.current === value) return;
|
||||||
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduced) {
|
||||||
|
prev.current = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.fromTo(
|
||||||
|
ref.current,
|
||||||
|
{
|
||||||
|
y: 22,
|
||||||
|
opacity: 0,
|
||||||
|
rotateX: -85,
|
||||||
|
filter: "blur(4px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
rotateX: 0,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
duration: 0.55,
|
||||||
|
ease: "back.out(2)",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
prev.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className="relative inline-block min-w-[0.6em] text-center text-white will-change-transform"
|
||||||
|
style={{ transformStyle: "preserve-3d" }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||||
|
import { TenderIcon } from "./icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
noticeTypes: { type: string; count: number }[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"from-[#5750F1] to-[#7B6CFF]",
|
||||||
|
"from-[#0ABEF9] to-[#3C50E0]",
|
||||||
|
"from-emerald-500 to-emerald-400",
|
||||||
|
"from-orange-light to-amber-400",
|
||||||
|
"from-[#F23030] to-[#F56060]",
|
||||||
|
"from-[#8155FF] to-[#0ABEF9]",
|
||||||
|
];
|
||||||
|
|
||||||
|
function labelize(s: string) {
|
||||||
|
return s
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/(^|\s)\w/g, (m) => m.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoticeTypeBreakdown({ noticeTypes, isLoading }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const total = useMemo(
|
||||||
|
() => noticeTypes.reduce((a, b) => a + b.count, 0) || 1,
|
||||||
|
[noticeTypes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from(rootRef.current, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 24,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.4,
|
||||||
|
});
|
||||||
|
gsap.fromTo(
|
||||||
|
"[data-bar-fill]",
|
||||||
|
{ width: 0 },
|
||||||
|
{
|
||||||
|
width: (i, el) => el.getAttribute("data-final-width") + "%",
|
||||||
|
duration: 0.9,
|
||||||
|
stagger: 0.07,
|
||||||
|
delay: 0.6,
|
||||||
|
ease: "power2.out",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, rootRef);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [noticeTypes.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-center gap-2">
|
||||||
|
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<TenderIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||||
|
Notice Type Mix
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-dark-6">Distribution across form types</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{(isLoading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
type: "—",
|
||||||
|
count: 0,
|
||||||
|
i,
|
||||||
|
}))
|
||||||
|
: noticeTypes.map((n, i) => ({ ...n, i }))
|
||||||
|
).map((n) => {
|
||||||
|
const pct = isLoading ? 0 : Math.round((n.count / total) * 100);
|
||||||
|
return (
|
||||||
|
<li key={`${n.type}-${n.i}`} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="truncate font-medium text-dark dark:text-white">
|
||||||
|
{isLoading ? "Loading…" : labelize(n.type)}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums text-xs font-semibold text-dark-5 dark:text-dark-6">
|
||||||
|
{isLoading ? "—" : `${n.count} · ${pct}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
|
||||||
|
<div
|
||||||
|
data-bar-fill
|
||||||
|
data-final-width={pct}
|
||||||
|
className={`h-full rounded-full bg-gradient-to-r ${COLORS[n.i % COLORS.length]} shadow-sm`}
|
||||||
|
style={{ width: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && noticeTypes.length === 0 && (
|
||||||
|
<li className="py-6 text-center text-sm text-dark-6">
|
||||||
|
No notice data available.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TTenderDetails } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { ArrowRightIcon, SparkleIcon } from "./icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tenders: TTenderDetails[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function relativeTime(unix: number) {
|
||||||
|
if (!unix) return "—";
|
||||||
|
const diff = Math.floor(Date.now() / 1000) - unix;
|
||||||
|
if (diff < 60) return "just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
const days = Math.floor(diff / 86400);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return new Date(unix * 1000).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string) {
|
||||||
|
const s = (status || "").toLowerCase();
|
||||||
|
switch (s) {
|
||||||
|
case "active":
|
||||||
|
return "bg-green-light-7 text-green dark:bg-green/20 dark:text-green-light-3";
|
||||||
|
case "awarded":
|
||||||
|
return "bg-blue-light-5 text-blue dark:bg-blue/20 dark:text-blue-light-3";
|
||||||
|
case "expired":
|
||||||
|
return "bg-orange-light/10 text-orange-light";
|
||||||
|
case "cancelled":
|
||||||
|
return "bg-error-light-5 text-error dark:bg-error/20 dark:text-error-light";
|
||||||
|
case "draft":
|
||||||
|
return "bg-gray-2 text-dark-5 dark:bg-dark-3 dark:text-dark-6";
|
||||||
|
default:
|
||||||
|
return "bg-gray-2 text-dark-5 dark:bg-dark-3 dark:text-dark-6";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentTenders({ tenders, isLoading }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from(rootRef.current, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 24,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.35,
|
||||||
|
});
|
||||||
|
gsap.from("[data-recent-row]", {
|
||||||
|
x: -24,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
stagger: 0.06,
|
||||||
|
delay: 0.55,
|
||||||
|
ease: "power2.out",
|
||||||
|
});
|
||||||
|
}, rootRef);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [tenders.length, isLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<SparkleIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||||
|
Recent Tenders
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-dark-6">Latest entries in the system</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/tenders"
|
||||||
|
className="hidden items-center gap-1 text-xs font-medium text-primary hover:underline sm:inline-flex"
|
||||||
|
>
|
||||||
|
Browse all <ArrowRightIcon className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark">
|
||||||
|
{isLoading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex animate-pulse items-start gap-3 px-5 py-3.5"
|
||||||
|
>
|
||||||
|
<div className="mt-1 size-2 rounded-full bg-gray-2 dark:bg-dark-3" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-3 w-2/3 rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
<div className="h-2 w-1/3 rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: tenders.map((t) => {
|
||||||
|
const ts =
|
||||||
|
t.created_at || t.publication_date || t.notice_dispatch_at || 0;
|
||||||
|
return (
|
||||||
|
<li key={t.id} data-recent-row>
|
||||||
|
<Link
|
||||||
|
href={`/tenders/${t.id}`}
|
||||||
|
className="group relative flex items-start gap-3 px-5 py-3.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
|
||||||
|
>
|
||||||
|
<span className="relative mt-1.5 flex size-2 shrink-0">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded bg-gray-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-dark-5 dark:bg-dark-3 dark:text-dark-6">
|
||||||
|
{t.country_code || "??"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase",
|
||||||
|
statusTone(t.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.status || "—"}
|
||||||
|
</span>
|
||||||
|
<time className="ml-auto shrink-0 text-[11px] text-dark-6">
|
||||||
|
{relativeTime(ts)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-sm font-medium text-dark transition-colors group-hover:text-primary dark:text-white">
|
||||||
|
{t.title || "Untitled tender"}
|
||||||
|
</p>
|
||||||
|
{t.buyer_organization?.name && (
|
||||||
|
<p className="mt-0.5 line-clamp-1 text-xs text-dark-6">
|
||||||
|
{t.buyer_organization.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && tenders.length === 0 && (
|
||||||
|
<li className="px-5 py-10 text-center text-sm text-dark-6">
|
||||||
|
No tenders yet.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { AnimatedCounter } from "./animated-counter";
|
||||||
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
CoinIcon,
|
||||||
|
PulseIcon,
|
||||||
|
TenderIcon,
|
||||||
|
TrendUpIcon,
|
||||||
|
} from "./icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
closingSoon: number;
|
||||||
|
totalValue: number;
|
||||||
|
valueCurrency: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compact = new Intl.NumberFormat("en", {
|
||||||
|
notation: "compact",
|
||||||
|
compactDisplay: "short",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmtCompact = (v: number) => compact.format(Math.max(0, v));
|
||||||
|
|
||||||
|
export function TenderStatCards({
|
||||||
|
total,
|
||||||
|
active,
|
||||||
|
closingSoon,
|
||||||
|
totalValue,
|
||||||
|
valueCurrency,
|
||||||
|
isLoading,
|
||||||
|
}: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from("[data-stat-card]", {
|
||||||
|
y: 28,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
stagger: 0.08,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.1,
|
||||||
|
});
|
||||||
|
}, rootRef);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activePct = total > 0 ? Math.round((active / total) * 100) : 0;
|
||||||
|
const closingPct = total > 0 ? Math.round((closingSoon / total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
label="Total Tenders"
|
||||||
|
value={total}
|
||||||
|
Icon={TenderIcon}
|
||||||
|
accent="from-[#5750F1] to-[#7B6CFF]"
|
||||||
|
ring="ring-[#5750F1]/30"
|
||||||
|
hint="In your watchlist"
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Active Now"
|
||||||
|
value={active}
|
||||||
|
Icon={PulseIcon}
|
||||||
|
accent="from-emerald-500 to-emerald-400"
|
||||||
|
ring="ring-emerald-400/30"
|
||||||
|
hint={`${activePct}% of total`}
|
||||||
|
delta={activePct}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Closing Soon"
|
||||||
|
value={closingSoon}
|
||||||
|
Icon={ClockIcon}
|
||||||
|
accent="from-orange-500 to-amber-400"
|
||||||
|
ring="ring-orange-400/30"
|
||||||
|
hint={`${closingPct}% in next 7 days`}
|
||||||
|
loading={isLoading}
|
||||||
|
urgent={closingSoon > 0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Estimated Value"
|
||||||
|
value={totalValue}
|
||||||
|
formatter={(v) => fmtCompact(v)}
|
||||||
|
prefix={currencySymbol(valueCurrency)}
|
||||||
|
Icon={CoinIcon}
|
||||||
|
accent="from-[#0ABEF9] to-[#3C50E0]"
|
||||||
|
ring="ring-[#0ABEF9]/30"
|
||||||
|
hint={`${valueCurrency} aggregated`}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currencySymbol(code: string) {
|
||||||
|
switch (code) {
|
||||||
|
case "EUR":
|
||||||
|
return "€";
|
||||||
|
case "USD":
|
||||||
|
return "$";
|
||||||
|
case "GBP":
|
||||||
|
return "£";
|
||||||
|
case "AED":
|
||||||
|
return "د.إ ";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
Icon,
|
||||||
|
accent,
|
||||||
|
ring,
|
||||||
|
hint,
|
||||||
|
delta,
|
||||||
|
prefix,
|
||||||
|
formatter,
|
||||||
|
loading,
|
||||||
|
urgent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
Icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||||
|
accent: string;
|
||||||
|
ring: string;
|
||||||
|
hint: string;
|
||||||
|
delta?: number;
|
||||||
|
prefix?: string;
|
||||||
|
formatter?: (v: number) => string;
|
||||||
|
loading: boolean;
|
||||||
|
urgent?: boolean;
|
||||||
|
}) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduced || !cardRef.current) return;
|
||||||
|
const el = cardRef.current;
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - r.left) / r.width - 0.5;
|
||||||
|
const py = (e.clientY - r.top) / r.height - 0.5;
|
||||||
|
gsap.to(el, {
|
||||||
|
rotateY: px * 6,
|
||||||
|
rotateX: -py * 6,
|
||||||
|
transformPerspective: 700,
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "power2.out",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onLeave = () => {
|
||||||
|
gsap.to(el, {
|
||||||
|
rotateY: 0,
|
||||||
|
rotateX: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "power3.out",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener("mousemove", onMove);
|
||||||
|
el.addEventListener("mouseleave", onLeave);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("mousemove", onMove);
|
||||||
|
el.removeEventListener("mouseleave", onLeave);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
data-stat-card
|
||||||
|
className={cn(
|
||||||
|
"group relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 transition-shadow hover:shadow-2 dark:bg-gray-dark dark:shadow-card",
|
||||||
|
"ring-1 ring-stroke dark:ring-stroke-dark",
|
||||||
|
)}
|
||||||
|
style={{ transformStyle: "preserve-3d" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute -right-10 -top-10 size-32 rounded-full bg-gradient-to-br opacity-20 blur-2xl transition-opacity duration-500 group-hover:opacity-40",
|
||||||
|
accent,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-12 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-md ring-4",
|
||||||
|
accent,
|
||||||
|
ring,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-6" />
|
||||||
|
</div>
|
||||||
|
{urgent && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-orange-light/10 px-2 py-0.5 text-[11px] font-medium text-orange-light">
|
||||||
|
<span className="relative flex size-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-light opacity-75" />
|
||||||
|
<span className="relative inline-flex size-1.5 rounded-full bg-orange-light" />
|
||||||
|
</span>
|
||||||
|
Action needed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{delta !== undefined && !urgent && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-light-7 px-2 py-0.5 text-[11px] font-medium text-green dark:bg-green/15 dark:text-green-light-3">
|
||||||
|
<TrendUpIcon className="size-3" />
|
||||||
|
{delta}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-[28px] font-bold leading-none text-dark dark:text-white">
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-block h-7 w-20 animate-pulse rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
) : (
|
||||||
|
<AnimatedCounter
|
||||||
|
value={value}
|
||||||
|
prefix={prefix ?? ""}
|
||||||
|
formatter={formatter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-sm font-medium text-dark-5 dark:text-dark-6">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-dark-6 dark:text-dark-6">
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { AnimatedCounter } from "./animated-counter";
|
||||||
|
import { PulseIcon, TrendUpIcon } from "./icons";
|
||||||
|
|
||||||
|
type Point = { date: string; label: string; count: number };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: Point[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = 1000;
|
||||||
|
const H = 240;
|
||||||
|
const PAD_L = 0;
|
||||||
|
const PAD_R = 0;
|
||||||
|
const PAD_T = 24;
|
||||||
|
const PAD_B = 36;
|
||||||
|
|
||||||
|
export function TenderTrendsChart({ data, isLoading }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const pathRef = useRef<SVGPathElement>(null);
|
||||||
|
const areaRef = useRef<SVGPathElement>(null);
|
||||||
|
const dotsRef = useRef<SVGGElement>(null);
|
||||||
|
|
||||||
|
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { linePath, areaPath, points, sumY } = useMemo(() => {
|
||||||
|
const max = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
const innerW = W - PAD_L - PAD_R;
|
||||||
|
const innerH = H - PAD_T - PAD_B;
|
||||||
|
const step = data.length > 1 ? innerW / (data.length - 1) : innerW;
|
||||||
|
|
||||||
|
const pts = data.map((d, i) => ({
|
||||||
|
x: PAD_L + i * step,
|
||||||
|
y: PAD_T + innerH - (d.count / max) * innerH,
|
||||||
|
d,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (pts.length === 0) {
|
||||||
|
return { linePath: "", areaPath: "", points: pts, sumY: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = pts
|
||||||
|
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const area = `${line} L ${pts[pts.length - 1].x.toFixed(2)} ${PAD_T + innerH} L ${pts[0].x.toFixed(2)} ${PAD_T + innerH} Z`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
linePath: line,
|
||||||
|
areaPath: area,
|
||||||
|
points: pts,
|
||||||
|
sumY: data.reduce((acc, p) => acc + p.count, 0),
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from(rootRef.current, {
|
||||||
|
y: 24,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pathRef.current && linePath) {
|
||||||
|
const length = pathRef.current.getTotalLength();
|
||||||
|
gsap.fromTo(
|
||||||
|
pathRef.current,
|
||||||
|
{ strokeDasharray: length, strokeDashoffset: length },
|
||||||
|
{
|
||||||
|
strokeDashoffset: 0,
|
||||||
|
duration: 1.4,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
delay: 0.3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (areaRef.current) {
|
||||||
|
gsap.fromTo(
|
||||||
|
areaRef.current,
|
||||||
|
{ opacity: 0, y: 12 },
|
||||||
|
{ opacity: 1, y: 0, duration: 1, delay: 0.5, ease: "power2.out" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dotsRef.current) {
|
||||||
|
gsap.fromTo(
|
||||||
|
dotsRef.current.querySelectorAll("circle"),
|
||||||
|
{ scale: 0, transformOrigin: "center" },
|
||||||
|
{
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.4,
|
||||||
|
stagger: 0.05,
|
||||||
|
ease: "back.out(2)",
|
||||||
|
delay: 0.8,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, rootRef);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [linePath]);
|
||||||
|
|
||||||
|
const last = points[points.length - 1]?.d.count ?? 0;
|
||||||
|
const prev = points[points.length - 2]?.d.count ?? 0;
|
||||||
|
const dayDelta = last - prev;
|
||||||
|
const avg = data.length ? Math.round(sumY / data.length) : 0;
|
||||||
|
|
||||||
|
const handleMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (!svgRef.current || !points.length) return;
|
||||||
|
const rect = svgRef.current.getBoundingClientRect();
|
||||||
|
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||||
|
let bestI = 0;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const d = Math.abs(points[i].x - px);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
bestI = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHoverIdx(bestI);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hovered = hoverIdx !== null ? points[hoverIdx] : null;
|
||||||
|
const innerH = H - PAD_T - PAD_B;
|
||||||
|
const tooltipLeftPct = hovered ? (hovered.x / W) * 100 : 0;
|
||||||
|
const tooltipTopPct = hovered ? (hovered.y / H) * 100 : 0;
|
||||||
|
const flipLeft = hovered ? hovered.x > W * 0.7 : false;
|
||||||
|
const flipRight = hovered ? hovered.x < W * 0.12 : false;
|
||||||
|
const flipDown = hovered ? hovered.y < H * 0.35 : false;
|
||||||
|
const tooltipTX = flipLeft ? "-100%" : flipRight ? "0%" : "-50%";
|
||||||
|
const tooltipTY = flipDown ? "calc(0% + 14px)" : "calc(-100% - 14px)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-dark dark:text-white">
|
||||||
|
Tender Flow — Last 14 days
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">
|
||||||
|
New tender notices received per day
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||||
|
Today
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-bold text-dark dark:text-white">
|
||||||
|
<AnimatedCounter value={last} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||||
|
Avg/day
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-bold text-dark dark:text-white">
|
||||||
|
<AnimatedCounter value={avg} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary sm:inline-flex">
|
||||||
|
<TrendUpIcon className="size-3.5" />
|
||||||
|
{dayDelta >= 0 ? "+" : ""}
|
||||||
|
{dayDelta} vs yesterday
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 w-full overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-[240px] items-center justify-center text-sm text-dark-6">
|
||||||
|
<PulseIcon className="mr-2 size-4 animate-pulse" /> Loading flow…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative h-[240px] min-w-[600px]">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={() => setHoverIdx(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tenderTrendFill" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#5750F1" stopOpacity="0.45" />
|
||||||
|
<stop offset="100%" stopColor="#5750F1" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="tenderTrendLine" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#5750F1" />
|
||||||
|
<stop offset="100%" stopColor="#0ABEF9" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{[0.25, 0.5, 0.75].map((p) => (
|
||||||
|
<line
|
||||||
|
key={p}
|
||||||
|
x1={PAD_L}
|
||||||
|
x2={W - PAD_R}
|
||||||
|
y1={PAD_T + innerH * p}
|
||||||
|
y2={PAD_T + innerH * p}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-stroke dark:text-stroke-dark"
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{areaPath && (
|
||||||
|
<path ref={areaRef} d={areaPath} fill="url(#tenderTrendFill)" />
|
||||||
|
)}
|
||||||
|
{linePath && (
|
||||||
|
<path
|
||||||
|
ref={pathRef}
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#tenderTrendLine)"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hovered && (
|
||||||
|
<line
|
||||||
|
x1={hovered.x}
|
||||||
|
x2={hovered.x}
|
||||||
|
y1={PAD_T}
|
||||||
|
y2={PAD_T + innerH}
|
||||||
|
stroke="#5750F1"
|
||||||
|
strokeOpacity="0.35"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="3 4"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<g ref={dotsRef}>
|
||||||
|
{points.map((p, i) => {
|
||||||
|
const isHover = hoverIdx === i;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={isHover ? 7 : 4}
|
||||||
|
fill={isHover ? "#5750F1" : "white"}
|
||||||
|
stroke="#5750F1"
|
||||||
|
strokeWidth={isHover ? 3 : 2}
|
||||||
|
className="transition-[r] duration-150 drop-shadow-sm"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{points.map((p, i) => {
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === points.length - 1;
|
||||||
|
if (!(isFirst || isLast || i % 2 === 0)) return null;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={p.x}
|
||||||
|
y={H - 12}
|
||||||
|
textAnchor={isFirst ? "start" : isLast ? "end" : "middle"}
|
||||||
|
className="fill-dark-6 text-[10px]"
|
||||||
|
>
|
||||||
|
{p.d.label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{hovered && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 rounded-xl border border-stroke bg-white px-3 py-2 text-xs shadow-lg dark:border-stroke-dark dark:bg-gray-dark"
|
||||||
|
style={{
|
||||||
|
left: `${tooltipLeftPct}%`,
|
||||||
|
top: `${tooltipTopPct}%`,
|
||||||
|
transform: `translate(${tooltipTX}, ${tooltipTY})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-semibold uppercase tracking-wider text-dark-6">
|
||||||
|
{hovered.d.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-1.5">
|
||||||
|
<span className="text-lg font-bold text-dark dark:text-white tabular-nums">
|
||||||
|
{hovered.d.count}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-dark-6">
|
||||||
|
{hovered.d.count === 1 ? "tender" : "tenders"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hoverIdx !== null && hoverIdx > 0 && (
|
||||||
|
<div className="mt-1 text-[10px] text-dark-6">
|
||||||
|
{(() => {
|
||||||
|
const diff =
|
||||||
|
hovered.d.count - points[hoverIdx - 1].d.count;
|
||||||
|
const sign = diff > 0 ? "+" : "";
|
||||||
|
return `${sign}${diff} vs previous day`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { 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 {
|
||||||
|
data: recentData,
|
||||||
|
isPending: recentPending,
|
||||||
|
isError: recentError,
|
||||||
|
refetch: refetchRecent,
|
||||||
|
} = useTendersQuery({
|
||||||
|
sort_by: "created_at",
|
||||||
|
sort_order: "desc",
|
||||||
|
offset: 0,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const realRecent = recentData?.data?.tenders ?? [];
|
||||||
|
|
||||||
|
const tenders = useMemo(
|
||||||
|
() => (USE_FAKE_AGGREGATES ? buildFakeTenders(80) : []),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPending = USE_FAKE_AGGREGATES ? false : recentPending;
|
||||||
|
const isError = recentError;
|
||||||
|
const refetch = refetchRecent;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
tenders,
|
||||||
|
recent,
|
||||||
|
recentIsLoading,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-59
@@ -1,66 +1,10 @@
|
|||||||
import { PaymentsOverview } from "@/components/Charts/payments-overview";
|
|
||||||
import { UsedDevices } from "@/components/Charts/used-devices";
|
|
||||||
import { WeeksProfit } from "@/components/Charts/weeks-profit";
|
|
||||||
import { TopChannels } from "@/components/Tables/top-channels";
|
|
||||||
import { TopChannelsSkeleton } from "@/components/Tables/top-channels/skeleton";
|
|
||||||
import { createTimeFrameExtractor } from "@/utils/timeframe-extractor";
|
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { TenderDashboard } from "./_components/dashboard";
|
||||||
import { ChatsCard } from "./_components/chats-card";
|
|
||||||
import { OverviewCardsGroup } from "./_components/overview-cards";
|
|
||||||
import { OverviewCardsSkeleton } from "./_components/overview-cards/skeleton";
|
|
||||||
import { RegionLabels } from "./_components/region-labels";
|
|
||||||
|
|
||||||
type PropsType = {
|
|
||||||
searchParams: Promise<{
|
|
||||||
selected_time_frame?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tender Dashboard",
|
title: "Tender Dashboard",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Home({ searchParams }: PropsType) {
|
export default function Home() {
|
||||||
const { selected_time_frame } = await searchParams;
|
return <TenderDashboard />;
|
||||||
const extractTimeFrame = createTimeFrameExtractor(selected_time_frame);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Suspense fallback={<OverviewCardsSkeleton />}>
|
|
||||||
<OverviewCardsGroup />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-9 2xl:gap-7.5">
|
|
||||||
<PaymentsOverview
|
|
||||||
className="col-span-12 xl:col-span-7"
|
|
||||||
key={extractTimeFrame("payments_overview")}
|
|
||||||
timeFrame={extractTimeFrame("payments_overview")?.split(":")[1]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WeeksProfit
|
|
||||||
key={extractTimeFrame("weeks_profit")}
|
|
||||||
timeFrame={extractTimeFrame("weeks_profit")?.split(":")[1]}
|
|
||||||
className="col-span-12 xl:col-span-5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UsedDevices
|
|
||||||
className="col-span-12 xl:col-span-5"
|
|
||||||
key={extractTimeFrame("used_devices")}
|
|
||||||
timeFrame={extractTimeFrame("used_devices")?.split(":")[1]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RegionLabels />
|
|
||||||
|
|
||||||
<div className="col-span-12 grid min-w-0 xl:col-span-8">
|
|
||||||
<Suspense fallback={<TopChannelsSkeleton />}>
|
|
||||||
<TopChannels />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ChatsCard />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { apiDefaultParams } from "@/constants/Api_Params";
|
|||||||
import { useTendersQuery } from "@/hooks/queries";
|
import { useTendersQuery } from "@/hooks/queries";
|
||||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||||
import { TCustomer } from "@/lib/api";
|
import { TCustomer } from "@/lib/api";
|
||||||
import { deleteEmptyKeys, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
import {
|
||||||
|
buildQueryString,
|
||||||
|
deleteEmptyKeys,
|
||||||
|
getPaginatedRowNumber,
|
||||||
|
unixToDate,
|
||||||
|
} from "@/utils/shared";
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
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";
|
||||||
@@ -16,9 +21,14 @@ const COMMA_SEPARATED_FILTER_KEYS = new Set([
|
|||||||
"notice_types",
|
"notice_types",
|
||||||
"form_types",
|
"form_types",
|
||||||
"classifications",
|
"classifications",
|
||||||
"cpv_codes",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const parseCpvCodesFilter = (value: string): string[] =>
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const normalizeFilterValues = (values: Record<string, any>) => {
|
const normalizeFilterValues = (values: Record<string, any>) => {
|
||||||
const normalized: Record<string, any> = {};
|
const normalized: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -33,6 +43,14 @@ const normalizeFilterValues = (values: Record<string, any>) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "cpv_codes") {
|
||||||
|
const codes = parseCpvCodesFilter(trimmedValue);
|
||||||
|
if (codes.length) {
|
||||||
|
normalized[key] = codes;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (COMMA_SEPARATED_FILTER_KEYS.has(key)) {
|
if (COMMA_SEPARATED_FILTER_KEYS.has(key)) {
|
||||||
normalized[key] = trimmedValue
|
normalized[key] = trimmedValue
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -76,8 +94,32 @@ const buildRangeFormValue = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatCpvCodesForForm = (value: unknown): string => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(String).filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCpvCodesFromSearchParams = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): string[] | undefined => {
|
||||||
|
const values = searchParams.getAll("cpv_codes").filter(Boolean);
|
||||||
|
if (!values.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.length === 1 && values[0].includes(",")) {
|
||||||
|
const legacy = parseCpvCodesFilter(values[0]);
|
||||||
|
return legacy.length ? legacy : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
const buildTenderFilterFormValues = (params: Record<string, any>) => ({
|
const buildTenderFilterFormValues = (params: Record<string, any>) => ({
|
||||||
...params,
|
...params,
|
||||||
|
cpv_codes: formatCpvCodesForForm(params.cpv_codes),
|
||||||
created_at_range: buildRangeFormValue(
|
created_at_range: buildRangeFormValue(
|
||||||
params,
|
params,
|
||||||
"created_at_from",
|
"created_at_from",
|
||||||
@@ -278,8 +320,16 @@ const useTenderListPresenter = () => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
};
|
};
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const key of searchParams.keys()) {
|
||||||
newParams[key] = value;
|
if (key === "cpv_codes") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newParams[key] = searchParams.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpvCodes = parseCpvCodesFromSearchParams(searchParams);
|
||||||
|
if (cpvCodes) {
|
||||||
|
newParams.cpv_codes = cpvCodes;
|
||||||
}
|
}
|
||||||
setParams(newParams);
|
setParams(newParams);
|
||||||
filterFormReset(mergeTenderFilterFormState(newParams));
|
filterFormReset(mergeTenderFilterFormState(newParams));
|
||||||
@@ -394,7 +444,7 @@ const useTenderListPresenter = () => {
|
|||||||
}
|
}
|
||||||
const newParams = buildFirstPageParams({ ...merged, ...normalizedData });
|
const newParams = buildFirstPageParams({ ...merged, ...normalizedData });
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = buildQueryString(cleanedParams);
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { serializeAxiosParams } from "@/utils/shared";
|
||||||
import api from "../axios";
|
import api from "../axios";
|
||||||
import { API_ENDPOINTS } from "../endpoints";
|
import { API_ENDPOINTS } from "../endpoints";
|
||||||
import { ApiResponse } from "../types";
|
import { ApiResponse } from "../types";
|
||||||
@@ -13,7 +14,12 @@ export const tendersService = {
|
|||||||
params?: Record<string, any>,
|
params?: Record<string, any>,
|
||||||
): Promise<ApiResponse<TTenderResponse>> => {
|
): Promise<ApiResponse<TTenderResponse>> => {
|
||||||
try {
|
try {
|
||||||
return (await api.get(API_ENDPOINTS.TENDERS.READ_ALL, { params })).data;
|
return (
|
||||||
|
await api.get(API_ENDPOINTS.TENDERS.READ_ALL, {
|
||||||
|
params,
|
||||||
|
paramsSerializer: serializeAxiosParams,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("ERROR caught in Tenders Services Read all:", error);
|
console.error("ERROR caught in Tenders Services Read all:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -111,6 +111,32 @@ export const deleteEmptyKeys = (obj: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
return newObj;
|
return newObj;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Serializes params with repeated keys for arrays (e.g. cpv_codes=1&cpv_codes=2). */
|
||||||
|
export const buildQueryString = (params: Record<string, any>): string => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
if (item !== undefined && item !== null && item !== "") {
|
||||||
|
searchParams.append(key, String(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchParams.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeAxiosParams = buildQueryString;
|
||||||
export const isValidAlpha2CountryCode = (code: string) => /^[A-Za-z]{2}$/.test(code);
|
export const isValidAlpha2CountryCode = (code: string) => /^[A-Za-z]{2}$/.test(code);
|
||||||
|
|
||||||
export const getPaginatedRowNumber = ({
|
export const getPaginatedRowNumber = ({
|
||||||
|
|||||||
Reference in New Issue
Block a user