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_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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>> => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user