queue-med/client/src/pages/Analytics.tsx

273 lines
13 KiB
TypeScript

import { useState } from "react";
import {
BarChart3, Users, Clock, Activity, Sparkles, Download, Loader2,
TrendingUp, Calendar,
} from "lucide-react";
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
LineChart, Line, AreaChart, Area, Cell, PieChart, Pie,
} from "recharts";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
const DAY_NAMES = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
const PIE_COLORS = ["#10b981", "#06b6d4", "#0d9488", "#22d3ee", "#34d399", "#0891b2", "#14b8a6"];
export default function Analytics() {
const [days, setDays] = useState<number>(30);
const [clinicId, setClinicId] = useState<number | undefined>(undefined);
const clinicsQuery = trpc.clinic.list.useQuery();
const summaryQuery = trpc.analytics.summary.useQuery({ days, clinicId });
const clinics = clinicsQuery.data ?? [];
const summary = summaryQuery.data;
const exportCsv = trpc.analytics.exportCsv.useQuery(
{ clinicId: clinicId ?? clinics[0]?.id ?? 0, days },
{ enabled: false }
);
const handleExport = async () => {
if (!clinicId && !clinics[0]) {
toast.error("Aucun cabinet sélectionné");
return;
}
const result = await exportCsv.refetch();
if (!result.data) {
toast.error("Export échoué");
return;
}
const blob = new Blob([result.data.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("CSV exporté");
};
const hourData = (summary?.byHour ?? []).map((count, hour) => ({ hour: `${hour}h`, count }));
const dayData = (summary?.byDay ?? []).map((count, dow) => ({ day: DAY_NAMES[dow], count }));
const flowData = [
{ name: "Joints", value: summary?.totalJoined ?? 0 },
{ name: "Servis", value: summary?.totalServed ?? 0 },
{ name: "Absents", value: summary?.totalAbsent ?? 0 },
];
return (
<div className="container py-8">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4">
<div>
<h1 className="font-bold text-3xl mb-1">Analytics</h1>
<p className="text-slate-600">Affluence, temps d'attente et recommandations IA.</p>
</div>
<Button variant="outline" onClick={handleExport} disabled={exportCsv.isFetching}>
{exportCsv.isFetching ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Download className="w-4 h-4 mr-2" />}
Exporter CSV
</Button>
</div>
{/* Filters */}
<div className="glass-card rounded-2xl p-4 mb-6 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">Période</span>
{[7, 30, 90, 365].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
days === d
? "bg-teal-600 text-white border-teal-600"
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
}`}
>
{d} jours
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">Cabinet</span>
<select
value={clinicId ?? ""}
onChange={(e) => setClinicId(e.target.value ? Number(e.target.value) : undefined)}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm focus:outline-none focus:border-emerald-400"
>
<option value="">Tous</option>
{clinics.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
{summaryQuery.isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
) : (
<>
{/* KPI cards */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
{[
{ label: "Patients joints", value: summary?.totalJoined ?? 0, icon: Users, color: "from-emerald-500 to-teal-500" },
{ label: "Servis", value: summary?.totalServed ?? 0, icon: Activity, color: "from-cyan-500 to-blue-500" },
{ label: "Absents", value: summary?.totalAbsent ?? 0, icon: Calendar, color: "from-orange-500 to-amber-500" },
{ label: "Attente moy.", value: `${summary?.avgWaitMinutes ?? 0} min`, icon: Clock, color: "from-violet-500 to-purple-500" },
{ label: "Cons. moy.", value: `${summary?.avgConsultationMinutes ?? 0} min`, icon: TrendingUp, color: "from-pink-500 to-rose-500" },
].map((s) => {
const Icon = s.icon;
return (
<div key={s.label} className="glass-card rounded-2xl p-4">
<div className={`w-9 h-9 rounded-xl bg-gradient-to-br ${s.color} flex items-center justify-center mb-3 shadow-md`}>
<Icon className="w-4 h-4 text-white" />
</div>
<div className="font-bold text-xl text-slate-900">{s.value}</div>
<div className="text-xs text-slate-500">{s.label}</div>
</div>
);
})}
</div>
{/* Recommendations */}
{summary && summary.recommendations.length > 0 && (
<div className="glass-card rounded-2xl p-6 mb-6 border-2 border-emerald-200/60">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md flex-shrink-0">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="font-bold text-lg">Recommandations IA</h2>
<p className="text-slate-500 text-sm">Optimisations identifiées sur la période sélectionnée.</p>
</div>
</div>
<ul className="space-y-2">
{summary.recommendations.map((r, i) => (
<li key={i} className="flex items-start gap-3 p-3 rounded-xl bg-emerald-50/60 border border-emerald-100">
<span className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">{i + 1}</span>
<span className="text-sm text-slate-700">{r}</span>
</li>
))}
</ul>
</div>
)}
{/* Charts */}
<div className="grid lg:grid-cols-2 gap-6 mb-6">
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-emerald-600" />
Affluence par heure
</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={hourData}>
<defs>
<linearGradient id="barGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.95} />
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0.85} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="hour" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)", backdropFilter: "blur(8px)" }}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]} fill="url(#barGrad)" />
</BarChart>
</ResponsiveContainer>
{summary && summary.peakHour >= 0 && (
<p className="text-xs text-slate-500 mt-3">
Pic d'affluence : <strong className="text-emerald-700">{summary.peakHour}h</strong>
</p>
)}
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Activity className="w-4 h-4 text-cyan-600" />
Affluence par jour
</h3>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={dayData}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.5} />
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="day" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
/>
<Area type="monotone" dataKey="count" stroke="#06b6d4" strokeWidth={2.5} fill="url(#areaGrad)" />
</AreaChart>
</ResponsiveContainer>
{summary && summary.peakDay >= 0 && (
<p className="text-xs text-slate-500 mt-3">
Jour le plus chargé : <strong className="text-cyan-700">{DAY_NAMES[summary.peakDay]}</strong>
</p>
)}
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Users className="w-4 h-4 text-violet-600" />
Flux patients
</h3>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={flowData}
cx="50%" cy="50%"
innerRadius={60} outerRadius={100}
paddingAngle={4}
dataKey="value"
label={(entry) => entry.name}
>
{flowData.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-600" />
Temps d'attente moyen
</h3>
<div className="flex items-center justify-center h-[280px]">
<div className="text-center">
<div className="font-black text-7xl gradient-text mb-2">{summary?.avgWaitMinutes ?? 0}</div>
<div className="text-slate-500 text-sm">minutes en moyenne</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<div className="p-3 rounded-xl bg-emerald-50 border border-emerald-100">
<div className="text-xs text-emerald-700 uppercase font-bold">Consultation</div>
<div className="font-bold text-emerald-900 text-2xl mt-1">{summary?.avgConsultationMinutes ?? 0} min</div>
</div>
<div className="p-3 rounded-xl bg-cyan-50 border border-cyan-100">
<div className="text-xs text-cyan-700 uppercase font-bold">Total</div>
<div className="font-bold text-cyan-900 text-2xl mt-1">{(summary?.avgWaitMinutes ?? 0) + (summary?.avgConsultationMinutes ?? 0)} min</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
);
}