feat(dashboard): add daily trend chart and statistics section to dashboard

- Introduced DailyTrendChart component for visualizing daily scraped TED notices, documents, and translated notices.
- Implemented StatisticsSection to display key metrics with animated counters and a days selector for data range.
- Created utility function to convert API data into labeled points for chart rendering.
- Updated dashboard index to include the new StatisticsSection, enhancing overall dashboard functionality and user experience.
- Refactored existing trends chart to utilize the new DailyPoint type for consistency in data handling.
This commit is contained in:
AmirReza Jamali
2026-06-07 12:39:15 +03:30
parent da1e2f89e7
commit 72948a9b55
21 changed files with 766 additions and 396 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_APP_VERSION=2.2.8
NEXT_PUBLIC_APP_VERSION=2.3.0
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
@@ -0,0 +1,414 @@
"use client";
import gsap from "gsap";
import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AnimatedCounter } from "./animated-counter";
import { PulseIcon, TrendUpIcon } from "./icons";
export type DailyPoint = { date: string; label: string; count: number };
export type DailyChartAccent = {
/** Three stops for the line gradient (left → middle → right). */
line: [string, string, string];
/** Solid colour used for the area fill, dots and pulse. */
fill: string;
};
type Props = {
title: string;
subtitle: string;
data: DailyPoint[];
isLoading: boolean;
accent?: DailyChartAccent;
unit?: { singular: string; plural: string };
loadingLabel?: string;
};
const W = 1000;
const H = 240;
const PAD_L = 0;
const PAD_R = 0;
const PAD_T = 24;
const PAD_B = 36;
const DEFAULT_ACCENT: DailyChartAccent = {
line: ["#5750F1", "#8155FF", "#0ABEF9"],
fill: "#5750F1",
};
export function DailyTrendChart({
title,
subtitle,
data,
isLoading,
accent = DEFAULT_ACCENT,
unit = { singular: "item", plural: "items" },
loadingLabel = "Loading…",
}: 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);
// Unique gradient/filter ids so multiple charts can live on one page.
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const fillId = `dtFill-${uid}`;
const lineId = `dtLine-${uid}`;
const glowId = `dtGlow-${uid}`;
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">
{title}
</h2>
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">{subtitle}</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 px-3 py-1 text-xs font-medium sm:inline-flex"
style={{ backgroundColor: `${accent.fill}1a`, color: accent.fill }}
>
<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" /> {loadingLabel}
</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={fillId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={accent.fill} stopOpacity="0.5" />
<stop offset="100%" stopColor={accent.fill} stopOpacity="0" />
</linearGradient>
<linearGradient id={lineId} x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor={accent.line[0]} />
<stop offset="50%" stopColor={accent.line[1]} />
<stop offset="100%" stopColor={accent.line[2]} />
</linearGradient>
<filter id={glowId} x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</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(#${fillId})`} />
)}
{linePath && (
<>
<path
d={linePath}
fill="none"
stroke={`url(#${lineId})`}
strokeWidth="6"
strokeOpacity="0.35"
strokeLinecap="round"
strokeLinejoin="round"
filter={`url(#${glowId})`}
vectorEffect="non-scaling-stroke"
/>
<path
ref={pathRef}
d={linePath}
fill="none"
stroke={`url(#${lineId})`}
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={accent.fill}
strokeOpacity="0.35"
strokeWidth="1"
strokeDasharray="3 4"
vectorEffect="non-scaling-stroke"
/>
)}
<g ref={dotsRef}>
{points.map((p, i) => {
const isHover = hoverIdx === i;
const isLatest = i === points.length - 1;
return (
<g key={i}>
{isLatest && (
<>
<circle
cx={p.x}
cy={p.y}
r="11"
fill={accent.fill}
opacity="0.25"
>
<animate
attributeName="r"
values="6;18;6"
dur="2.4s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.35;0;0.35"
dur="2.4s"
repeatCount="indefinite"
/>
</circle>
<circle
cx={p.x}
cy={p.y}
r="7"
fill={accent.fill}
opacity="0.35"
/>
</>
)}
<circle
cx={p.x}
cy={p.y}
r={isHover ? 7 : isLatest ? 5 : 4}
fill={isHover || isLatest ? accent.fill : "white"}
stroke={accent.fill}
strokeWidth={isHover ? 3 : 2}
className="transition-[r] duration-150 drop-shadow-sm"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
</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 ? unit.singular : unit.plural}
</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>
);
}
@@ -5,6 +5,7 @@ import { CountryDistribution } from "./country-distribution";
import { DashboardHero } from "./hero";
import { NoticeTypeBreakdown } from "./notice-types";
import { RecentTenders } from "./recent-tenders";
import { StatisticsSection } from "./statistics-section";
import { TenderStatCards } from "./stat-cards";
import { TenderTrendsChart } from "./trends-chart";
import { useDashboardData } from "./use-dashboard-data";
@@ -66,6 +67,8 @@ export function TenderDashboard() {
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
</div>
</div>
<StatisticsSection />
</div>
);
}
@@ -0,0 +1,193 @@
"use client";
import { cn } from "@/lib/utils";
import { AnimatedCounter } from "./animated-counter";
import { DailyChartAccent, DailyTrendChart } from "./daily-trend-chart";
import { SparkleIcon, TenderIcon } from "./icons";
import {
STATISTICS_DAY_OPTIONS,
StatisticsDays,
useStatistics,
} from "./use-statistics";
const TED_ACCENT: DailyChartAccent = {
line: ["#5750F1", "#8155FF", "#0ABEF9"],
fill: "#5750F1",
};
const DOCS_ACCENT: DailyChartAccent = {
line: ["#0ABEF9", "#3C9DF8", "#22AD5C"],
fill: "#0ABEF9",
};
const TRANSLATE_ACCENT: DailyChartAccent = {
line: ["#F59460", "#F97316", "#EF4444"],
fill: "#F59460",
};
export function StatisticsSection() {
const {
days,
setDays,
isLoading,
scrapedTed,
scrapedDocuments,
translatedNotices,
totalScrapedDocuments,
totalTranslatedTenders,
} = useStatistics();
return (
<section className="space-y-4 md:space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-dark dark:text-white">
Scraping & Translation
</h2>
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">
Daily ingestion and translation activity
</p>
</div>
<DaysSelector value={days} onChange={setDays} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TotalCard
label="Total Scraped Documents"
hint="All documents across every tender"
value={totalScrapedDocuments}
Icon={TenderIcon}
color="#0ABEF9"
accentGradient="from-[#0ABEF9] to-[#3C50E0]"
loading={isLoading}
/>
<TotalCard
label="Total Translated Tenders"
hint="Lifetime successful AI translations"
value={totalTranslatedTenders}
Icon={SparkleIcon}
color="#F59460"
accentGradient="from-orange-500 to-amber-400"
loading={isLoading}
/>
</div>
<div className="space-y-4 md:space-y-6">
<DailyTrendChart
title="Scraped TED per day"
subtitle="TED notices imported into the system"
data={scrapedTed}
isLoading={isLoading}
accent={TED_ACCENT}
unit={{ singular: "notice", plural: "notices" }}
loadingLabel="Loading TED activity…"
/>
<DailyTrendChart
title="Scraped documents per day"
subtitle="Individual tender documents scraped"
data={scrapedDocuments}
isLoading={isLoading}
accent={DOCS_ACCENT}
unit={{ singular: "document", plural: "documents" }}
loadingLabel="Loading documents…"
/>
<DailyTrendChart
title="Translated notices per day"
subtitle="Successful AI translation requests"
data={translatedNotices}
isLoading={isLoading}
accent={TRANSLATE_ACCENT}
unit={{ singular: "notice", plural: "notices" }}
loadingLabel="Loading translations…"
/>
</div>
</section>
);
}
function DaysSelector({
value,
onChange,
}: {
value: StatisticsDays;
onChange: (days: StatisticsDays) => void;
}) {
return (
<div className="inline-flex items-center gap-1 rounded-xl border border-stroke bg-white p-1 dark:border-stroke-dark dark:bg-gray-dark">
{STATISTICS_DAY_OPTIONS.map((option) => (
<button
key={option}
type="button"
onClick={() => onChange(option)}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
value === option
? "bg-primary text-white"
: "text-dark-5 hover:bg-gray-2 dark:text-dark-6 dark:hover:bg-dark-3",
)}
>
{option}d
</button>
))}
</div>
);
}
function TotalCard({
label,
hint,
value,
Icon,
color,
accentGradient,
loading,
}: {
label: string;
hint: string;
value: number;
Icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
color: string;
accentGradient: string;
loading: boolean;
}) {
return (
<div className="relative isolate 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">
<div
aria-hidden
className={cn(
"pointer-events-none absolute -right-12 -top-12 z-0 size-32 rounded-full bg-gradient-to-br opacity-25 blur-2xl",
accentGradient,
)}
/>
<div className="relative z-10 flex items-center gap-4">
<div
className={cn(
"inline-flex size-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-lg",
accentGradient,
)}
style={{ boxShadow: `0 10px 30px -10px ${color}88` }}
>
<Icon className="size-6" />
</div>
<div className="min-w-0">
<div className="text-[30px] font-bold leading-none" dir="ltr" lang="en">
{loading ? (
<span className="inline-block h-8 w-24 animate-pulse rounded bg-gray-2 dark:bg-dark-3" />
) : (
<span
className={cn(
"inline-block bg-gradient-to-br bg-clip-text text-transparent",
accentGradient,
)}
>
<AnimatedCounter value={value} />
</span>
)}
</div>
<div className="mt-2 text-sm font-semibold text-dark dark:text-white">
{label}
</div>
<div className="mt-1 text-xs text-dark-6">{hint}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import type { TDashboardTrendPoint } from "@/lib/api/types";
import type { DailyPoint } from "./daily-trend-chart";
/**
* Convert an API daily series (`{ date: "YYYY-MM-DD", count }`) into chart
* points with a locale-formatted axis label. API dates are UTC calendar days
* and are kept verbatim in `date` for lookups.
*/
export function toLabeledPoints(series: TDashboardTrendPoint[]): DailyPoint[] {
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,
};
});
}
@@ -1,382 +1,21 @@
"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 };
import { DailyPoint, DailyTrendChart } from "./daily-trend-chart";
type Props = {
data: Point[];
data: DailyPoint[];
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.5" />
<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="50%" stopColor="#8155FF" />
<stop offset="100%" stopColor="#0ABEF9" />
</linearGradient>
<filter id="tenderTrendGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</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"
<DailyTrendChart
title="Tender Flow — Last 14 days"
subtitle="New tender notices received per day"
data={data}
isLoading={isLoading}
unit={{ singular: "tender", plural: "tenders" }}
loadingLabel="Loading flow…"
/>
))}
{areaPath && (
<path ref={areaRef} d={areaPath} fill="url(#tenderTrendFill)" />
)}
{linePath && (
<>
<path
d={linePath}
fill="none"
stroke="url(#tenderTrendLine)"
strokeWidth="6"
strokeOpacity="0.35"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#tenderTrendGlow)"
vectorEffect="non-scaling-stroke"
/>
<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;
const isLatest = i === points.length - 1;
return (
<g key={i}>
{isLatest && (
<>
<circle
cx={p.x}
cy={p.y}
r="11"
fill="#5750F1"
opacity="0.25"
>
<animate
attributeName="r"
values="6;18;6"
dur="2.4s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.35;0;0.35"
dur="2.4s"
repeatCount="indefinite"
/>
</circle>
<circle
cx={p.x}
cy={p.y}
r="7"
fill="#5750F1"
opacity="0.35"
/>
</>
)}
<circle
cx={p.x}
cy={p.y}
r={isHover ? 7 : isLatest ? 5 : 4}
fill={isHover || isLatest ? "#5750F1" : "white"}
stroke="#5750F1"
strokeWidth={isHover ? 3 : 2}
className="transition-[r] duration-150 drop-shadow-sm"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
</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>
);
}
@@ -9,6 +9,7 @@ import {
useTendersQuery,
} from "@/hooks/queries";
import { useMemo } from "react";
import { toLabeledPoints } from "./to-labeled-points";
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
@@ -61,21 +62,10 @@ export function useDashboardData() {
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 trend = useMemo(
() => toLabeledPoints(trendResponse?.series ?? []),
[trendResponse],
);
const countries = useMemo(
() =>
@@ -0,0 +1,44 @@
"use client";
import { useDashboardStatisticsQuery } from "@/hooks/queries";
import { useMemo, useState } from "react";
import { toLabeledPoints } from "./to-labeled-points";
export const STATISTICS_DAY_OPTIONS = [7, 14, 30, 90] as const;
export type StatisticsDays = (typeof STATISTICS_DAY_OPTIONS)[number];
export function useStatistics() {
const [days, setDays] = useState<StatisticsDays>(14);
const query = useDashboardStatisticsQuery({ days });
const stats = query.data?.data;
const daily = stats?.daily;
const scrapedTed = useMemo(
() => toLabeledPoints(daily?.scraped_ted ?? []),
[daily],
);
const scrapedDocuments = useMemo(
() => toLabeledPoints(daily?.scraped_documents ?? []),
[daily],
);
const translatedNotices = useMemo(
() => toLabeledPoints(daily?.translated_notices ?? []),
[daily],
);
return {
days,
setDays,
isLoading: query.isPending,
isError: query.isError,
refetch: query.refetch,
scrapedTed,
scrapedDocuments,
translatedNotices,
totalScrapedDocuments: stats?.totals.scraped_documents ?? 0,
totalTranslatedTenders: stats?.totals.translated_tenders ?? 0,
};
}
@@ -218,8 +218,15 @@ export const useAdminsPresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({
...apiDefaultParams,
offset: 0,
limit: 10,
search: "",
status: "",
});
router.push(pathName);
}, [pathName, router]);
}, [filterFormReset, pathName, router]);
const columns = [
"row",
@@ -61,8 +61,9 @@ const useCmsTablePresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ q: "", key: "" });
router.push(pathname);
}, [pathname, router]);
}, [pathname, router, filterFormReset]);
const onConfirmDelete = () => {
if (!currentCms?.id) return;
@@ -94,8 +94,9 @@ const useCompanyCategoriesPresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ q: "", published: undefined });
router.push(pathName);
}, [pathName, router]);
}, [pathName, router, filterFormReset]);
const onConfirmDelete = () => {
if (!currentCategory) return;
@@ -100,8 +100,15 @@ export const useCompanyListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({
name: "",
email: "",
phone: "",
currency: undefined,
employee_count: "",
});
router.push(pathName);
}, [pathName, router]);
}, [pathName, router, filterFormReset]);
const onConfirmDelete = () => {
if (currentCompany) {
@@ -90,8 +90,9 @@ export const useContactUsListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ q: "", status: "" });
router.push(pathname);
}, [pathname, router]);
}, [pathname, router, filterFormReset]);
const onConfirmDelete = () => {
if (!currentContact) return;
@@ -125,8 +125,9 @@ const useCustomerListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ status: undefined, role: undefined, type: undefined });
router.push(pathName);
}, [pathName, router]);
}, [pathName, router, filterFormReset]);
const columns = [
"row",
@@ -90,8 +90,9 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ q: "" });
router.push(pathName);
}, [pathName, router]);
}, [pathName, router, filterFormReset]);
const getShowcaseSectionTitle = () => {
if (paramKey === "tender_id") {
@@ -88,8 +88,9 @@ const useInquiriesListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ q: "", status: "" });
router.push(pathname);
}, [pathname, router]);
}, [pathname, router, filterFormReset]);
const deleteInquiry = () => {
if (!currentInquiry?.id) return;
@@ -83,8 +83,9 @@ const useNotificationHistoryTablePresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ seen: undefined, search: "" });
router.push(pathName);
}, [pathName, router]);
}, [pathName, router, filterFormReset]);
return {
notificationHistory: data?.data,
+12
View File
@@ -29,6 +29,18 @@ export const useDashboardTrendQuery = (params?: {
});
};
export const useDashboardStatisticsQuery = (params?: { days?: number }) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.DASHBOARD.STATISTICS, params],
[params],
);
return useQuery({
queryKey,
queryFn: () => dashboardService.statistics(params),
refetchInterval: 60_000,
});
};
export const useDashboardCountriesQuery = (params?: { limit?: number }) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.DASHBOARD.COUNTRIES, params],
+1
View File
@@ -86,6 +86,7 @@ export const API_ENDPOINTS = {
DASHBOARD: {
SUMMARY: "dashboard/summary",
TREND: "dashboard/trend",
STATISTICS: "dashboard/statistics",
COUNTRIES: "dashboard/countries",
NOTICE_TYPES: "dashboard/notice-types",
CLOSING_SOON: "dashboard/closing-soon",
+13
View File
@@ -5,6 +5,7 @@ import {
TDashboardClosingSoon,
TDashboardCountries,
TDashboardNoticeTypes,
TDashboardStatistics,
TDashboardSummary,
TDashboardTrend,
} from "../types";
@@ -33,6 +34,18 @@ export const dashboardService = {
}
},
statistics: async (params?: {
days?: number;
}): Promise<ApiResponse<TDashboardStatistics>> => {
try {
return (await api.get(API_ENDPOINTS.DASHBOARD.STATISTICS, { params }))
.data;
} catch (error) {
console.error("ERROR caught in Dashboard Service => statistics:", error);
throw error;
}
},
countries: async (params?: {
limit?: number;
}): Promise<ApiResponse<TDashboardCountries>> => {
+18
View File
@@ -21,6 +21,24 @@ export type TDashboardTrend = {
series: TDashboardTrendPoint[];
};
export type TDashboardStatisticsDaily = {
scraped_ted: TDashboardTrendPoint[];
scraped_documents: TDashboardTrendPoint[];
translated_notices: TDashboardTrendPoint[];
};
export type TDashboardStatisticsTotals = {
scraped_documents: number;
translated_tenders: number;
};
export type TDashboardStatistics = {
days: number;
generated_at: number;
daily: TDashboardStatisticsDaily;
totals: TDashboardStatisticsTotals;
};
export type TDashboardCountryItem = {
country_code: string;
count: number;