273 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|