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:
+1
-1
@@ -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_TOAST_AUTO_CLOSE_DURATION=2500
|
||||||
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
||||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
|
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 { DashboardHero } from "./hero";
|
||||||
import { NoticeTypeBreakdown } from "./notice-types";
|
import { NoticeTypeBreakdown } from "./notice-types";
|
||||||
import { RecentTenders } from "./recent-tenders";
|
import { RecentTenders } from "./recent-tenders";
|
||||||
|
import { StatisticsSection } from "./statistics-section";
|
||||||
import { TenderStatCards } from "./stat-cards";
|
import { TenderStatCards } from "./stat-cards";
|
||||||
import { TenderTrendsChart } from "./trends-chart";
|
import { TenderTrendsChart } from "./trends-chart";
|
||||||
import { useDashboardData } from "./use-dashboard-data";
|
import { useDashboardData } from "./use-dashboard-data";
|
||||||
@@ -66,6 +67,8 @@ export function TenderDashboard() {
|
|||||||
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StatisticsSection />
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import gsap from "gsap";
|
import { DailyPoint, DailyTrendChart } from "./daily-trend-chart";
|
||||||
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 = {
|
type Props = {
|
||||||
data: Point[];
|
data: DailyPoint[];
|
||||||
isLoading: boolean;
|
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) {
|
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 (
|
return (
|
||||||
<div
|
<DailyTrendChart
|
||||||
ref={rootRef}
|
title="Tender Flow — Last 14 days"
|
||||||
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"
|
subtitle="New tender notices received per day"
|
||||||
>
|
data={data}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
isLoading={isLoading}
|
||||||
<div>
|
unit={{ singular: "tender", plural: "tenders" }}
|
||||||
<h2 className="text-lg font-bold text-dark dark:text-white">
|
loadingLabel="Loading flow…"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
|
|
||||||
{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,
|
useTendersQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { toLabeledPoints } from "./to-labeled-points";
|
||||||
|
|
||||||
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
|
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
|
||||||
|
|
||||||
@@ -61,21 +62,10 @@ export function useDashboardData() {
|
|||||||
refetchRecent();
|
refetchRecent();
|
||||||
};
|
};
|
||||||
|
|
||||||
const trend = useMemo(() => {
|
const trend = useMemo(
|
||||||
const series = trendResponse?.series ?? [];
|
() => toLabeledPoints(trendResponse?.series ?? []),
|
||||||
return series.map((point) => {
|
[trendResponse],
|
||||||
const [y, m, d] = point.date.split("-").map(Number);
|
);
|
||||||
const date = new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
|
|
||||||
return {
|
|
||||||
date: point.date,
|
|
||||||
label: date.toLocaleDateString("en", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}),
|
|
||||||
count: point.count,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [trendResponse]);
|
|
||||||
|
|
||||||
const countries = useMemo(
|
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(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({
|
||||||
|
...apiDefaultParams,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
search: "",
|
||||||
|
status: "",
|
||||||
|
});
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [filterFormReset, pathName, router]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ const useCmsTablePresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", key: "" });
|
||||||
router.push(pathname);
|
router.push(pathname);
|
||||||
}, [pathname, router]);
|
}, [pathname, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentCms?.id) return;
|
if (!currentCms?.id) return;
|
||||||
|
|||||||
@@ -94,8 +94,9 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", published: undefined });
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentCategory) return;
|
if (!currentCategory) return;
|
||||||
|
|||||||
@@ -100,8 +100,15 @@ export const useCompanyListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
currency: undefined,
|
||||||
|
employee_count: "",
|
||||||
|
});
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (currentCompany) {
|
if (currentCompany) {
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ export const useContactUsListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", status: "" });
|
||||||
router.push(pathname);
|
router.push(pathname);
|
||||||
}, [pathname, router]);
|
}, [pathname, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentContact) return;
|
if (!currentContact) return;
|
||||||
|
|||||||
@@ -125,8 +125,9 @@ const useCustomerListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ status: undefined, role: undefined, type: undefined });
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "" });
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
const getShowcaseSectionTitle = () => {
|
const getShowcaseSectionTitle = () => {
|
||||||
if (paramKey === "tender_id") {
|
if (paramKey === "tender_id") {
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ const useInquiriesListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", status: "" });
|
||||||
router.push(pathname);
|
router.push(pathname);
|
||||||
}, [pathname, router]);
|
}, [pathname, router, filterFormReset]);
|
||||||
|
|
||||||
const deleteInquiry = () => {
|
const deleteInquiry = () => {
|
||||||
if (!currentInquiry?.id) return;
|
if (!currentInquiry?.id) return;
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ const useNotificationHistoryTablePresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ seen: undefined, search: "" });
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notificationHistory: data?.data,
|
notificationHistory: data?.data,
|
||||||
|
|||||||
@@ -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 }) => {
|
export const useDashboardCountriesQuery = (params?: { limit?: number }) => {
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => [API_ENDPOINTS.DASHBOARD.COUNTRIES, params],
|
() => [API_ENDPOINTS.DASHBOARD.COUNTRIES, params],
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const API_ENDPOINTS = {
|
|||||||
DASHBOARD: {
|
DASHBOARD: {
|
||||||
SUMMARY: "dashboard/summary",
|
SUMMARY: "dashboard/summary",
|
||||||
TREND: "dashboard/trend",
|
TREND: "dashboard/trend",
|
||||||
|
STATISTICS: "dashboard/statistics",
|
||||||
COUNTRIES: "dashboard/countries",
|
COUNTRIES: "dashboard/countries",
|
||||||
NOTICE_TYPES: "dashboard/notice-types",
|
NOTICE_TYPES: "dashboard/notice-types",
|
||||||
CLOSING_SOON: "dashboard/closing-soon",
|
CLOSING_SOON: "dashboard/closing-soon",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
TDashboardClosingSoon,
|
TDashboardClosingSoon,
|
||||||
TDashboardCountries,
|
TDashboardCountries,
|
||||||
TDashboardNoticeTypes,
|
TDashboardNoticeTypes,
|
||||||
|
TDashboardStatistics,
|
||||||
TDashboardSummary,
|
TDashboardSummary,
|
||||||
TDashboardTrend,
|
TDashboardTrend,
|
||||||
} from "../types";
|
} 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?: {
|
countries: async (params?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<ApiResponse<TDashboardCountries>> => {
|
}): Promise<ApiResponse<TDashboardCountries>> => {
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ export type TDashboardTrend = {
|
|||||||
series: TDashboardTrendPoint[];
|
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 = {
|
export type TDashboardCountryItem = {
|
||||||
country_code: string;
|
country_code: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user