324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
import { useState } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Building2, Clock, QrCode, CheckCircle2,
|
|
ChevronRight, ChevronLeft, Stethoscope, Loader2
|
|
} from "lucide-react";
|
|
|
|
const STEPS = [
|
|
{
|
|
id: 1,
|
|
title: "Votre cabinet",
|
|
description: "Commençons par les informations de base de votre cabinet médical.",
|
|
icon: Building2,
|
|
},
|
|
{
|
|
id: 2,
|
|
title: "Paramètres de la file",
|
|
description: "Configurez le comportement de votre salle d'attente virtuelle.",
|
|
icon: Clock,
|
|
},
|
|
{
|
|
id: 3,
|
|
title: "Votre QR code est prêt",
|
|
description: "Tout est configuré ! Voici comment démarrer.",
|
|
icon: QrCode,
|
|
},
|
|
];
|
|
|
|
export default function Onboarding() {
|
|
const [, navigate] = useLocation();
|
|
const [step, setStep] = useState(1);
|
|
const [clinicId, setClinicId] = useState<number | null>(null);
|
|
|
|
// Form state
|
|
const [name, setName] = useState("");
|
|
const [address, setAddress] = useState("");
|
|
const [phone, setPhone] = useState("");
|
|
const [avgConsultation, setAvgConsultation] = useState(15);
|
|
const [maxQueue, setMaxQueue] = useState(30);
|
|
const [qrRotation, setQrRotation] = useState(60);
|
|
|
|
const createClinic = trpc.clinic.create.useMutation({
|
|
onSuccess: (data) => {
|
|
setClinicId(data.id);
|
|
setStep(3);
|
|
toast.success("Cabinet créé avec succès !");
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
|
|
const handleNext = () => {
|
|
if (step === 1) {
|
|
if (!name.trim()) { toast.error("Le nom du cabinet est requis."); return; }
|
|
setStep(2);
|
|
} else if (step === 2) {
|
|
createClinic.mutate({
|
|
name: name.trim(),
|
|
address: address.trim() || undefined,
|
|
phone: phone.trim() || undefined,
|
|
avgConsultationMinutes: avgConsultation,
|
|
maxQueueSize: maxQueue,
|
|
qrRotationMinutes: qrRotation,
|
|
});
|
|
}
|
|
};
|
|
|
|
const currentStep = STEPS.find(s => s.id === step)!;
|
|
const StepIcon = currentStep.icon;
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-4">
|
|
{/* Background blobs */}
|
|
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-primary/10 blur-3xl animate-pulse-glow" />
|
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-secondary/10 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
|
|
</div>
|
|
|
|
<div className="relative z-10 w-full max-w-lg">
|
|
{/* Header */}
|
|
<div className="text-center mb-8">
|
|
<div className="flex items-center justify-center gap-2 mb-4">
|
|
<div className="w-10 h-10 rounded-xl bg-primary/20 border border-primary/40 flex items-center justify-center glow-teal">
|
|
<Stethoscope className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<span className="font-display text-xl font-bold gradient-text">QueueMed</span>
|
|
</div>
|
|
<h1 className="font-display text-2xl font-bold mb-2">Configuration initiale</h1>
|
|
<p className="text-muted-foreground text-sm">Configurez votre premier cabinet en 2 minutes</p>
|
|
</div>
|
|
|
|
{/* Step indicators */}
|
|
<div className="flex items-center justify-center gap-2 mb-8">
|
|
{STEPS.map((s, i) => (
|
|
<div key={s.id} className="flex items-center gap-2">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300 ${
|
|
s.id < step ? "bg-primary text-primary-foreground" :
|
|
s.id === step ? "bg-primary/20 border-2 border-primary text-primary" :
|
|
"bg-muted text-muted-foreground"
|
|
}`}>
|
|
{s.id < step ? <CheckCircle2 className="w-4 h-4" /> : s.id}
|
|
</div>
|
|
{i < STEPS.length - 1 && (
|
|
<div className={`w-12 h-0.5 transition-all duration-300 ${s.id < step ? "bg-primary" : "bg-border"}`} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Card */}
|
|
<div className="glass-card rounded-3xl p-8">
|
|
{/* Step header */}
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<div className="w-12 h-12 rounded-2xl bg-primary/20 border border-primary/40 flex items-center justify-center glow-teal flex-shrink-0">
|
|
<StepIcon className="w-6 h-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h2 className="font-display text-xl font-bold">{currentStep.title}</h2>
|
|
<p className="text-muted-foreground text-sm">{currentStep.description}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step 1 — Cabinet info */}
|
|
{step === 1 && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="name" className="text-sm font-medium mb-1.5 block">
|
|
Nom du cabinet <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
placeholder="Ex: Cabinet Dr. Martin"
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
className="bg-muted/50 border-border/60 focus:border-primary"
|
|
onKeyDown={e => e.key === "Enter" && handleNext()}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="address" className="text-sm font-medium mb-1.5 block">
|
|
Adresse <span className="text-muted-foreground text-xs">(optionnel)</span>
|
|
</Label>
|
|
<Input
|
|
id="address"
|
|
placeholder="Ex: 12 rue de la Paix, Paris"
|
|
value={address}
|
|
onChange={e => setAddress(e.target.value)}
|
|
className="bg-muted/50 border-border/60 focus:border-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="phone" className="text-sm font-medium mb-1.5 block">
|
|
Téléphone <span className="text-muted-foreground text-xs">(optionnel)</span>
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
placeholder="Ex: 01 23 45 67 89"
|
|
value={phone}
|
|
onChange={e => setPhone(e.target.value)}
|
|
className="bg-muted/50 border-border/60 focus:border-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 2 — Queue settings */}
|
|
{step === 2 && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Label className="text-sm font-medium mb-1.5 block">
|
|
Durée moyenne de consultation
|
|
</Label>
|
|
<div className="flex items-center gap-4">
|
|
<input
|
|
type="range"
|
|
min={5} max={60} step={5}
|
|
value={avgConsultation}
|
|
onChange={e => setAvgConsultation(Number(e.target.value))}
|
|
className="flex-1 accent-primary"
|
|
/>
|
|
<span className="text-primary font-bold w-16 text-right">{avgConsultation} min</span>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs mt-1">Utilisé pour estimer le temps d'attente des patients.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-sm font-medium mb-1.5 block">
|
|
Taille maximale de la file
|
|
</Label>
|
|
<div className="flex items-center gap-4">
|
|
<input
|
|
type="range"
|
|
min={5} max={100} step={5}
|
|
value={maxQueue}
|
|
onChange={e => setMaxQueue(Number(e.target.value))}
|
|
className="flex-1 accent-primary"
|
|
/>
|
|
<span className="text-primary font-bold w-16 text-right">{maxQueue} patients</span>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs mt-1">Au-delà, les nouveaux patients ne peuvent plus rejoindre.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-sm font-medium mb-1.5 block">
|
|
Rotation du QR code (anti-triche)
|
|
</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{[0, 30, 60, 120, 240].map(v => (
|
|
<button
|
|
key={v}
|
|
onClick={() => setQrRotation(v)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
|
qrRotation === v
|
|
? "bg-primary text-primary-foreground border-primary glow-teal"
|
|
: "bg-muted/50 border-border/60 text-muted-foreground hover:border-primary/50"
|
|
}`}
|
|
>
|
|
{v === 0 ? "Désactivé" : v < 60 ? `${v} min` : `${v / 60}h`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-muted-foreground text-xs mt-2">
|
|
Le QR code change de token automatiquement pour éviter les partages frauduleux.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3 — Success */}
|
|
{step === 3 && (
|
|
<div className="text-center space-y-6">
|
|
<div className="w-20 h-20 rounded-full bg-green-500/20 border-2 border-green-500/40 flex items-center justify-center mx-auto">
|
|
<CheckCircle2 className="w-10 h-10 text-green-400" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-display text-xl font-bold text-green-400 mb-2">Cabinet créé !</h3>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
Votre cabinet <strong className="text-foreground">"{name}"</strong> est configuré.
|
|
Voici les prochaines étapes pour démarrer.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-3 text-left">
|
|
{[
|
|
{ num: "1", text: "Imprimez ou affichez le QR code à l'accueil", color: "text-primary" },
|
|
{ num: "2", text: "Ouvrez la file d'attente depuis le tableau de bord", color: "text-primary" },
|
|
{ num: "3", text: "Configurez l'écran d'affichage sur votre tablette", color: "text-primary" },
|
|
].map(item => (
|
|
<div key={item.num} className="flex items-start gap-3 p-3 rounded-xl bg-muted/30 border border-border/40">
|
|
<span className={`w-6 h-6 rounded-full bg-primary/20 border border-primary/40 flex items-center justify-center text-xs font-bold ${item.color} flex-shrink-0 mt-0.5`}>
|
|
{item.num}
|
|
</span>
|
|
<span className="text-sm text-foreground/80">{item.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 mt-8">
|
|
{step > 1 && step < 3 && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setStep(step - 1)}
|
|
className="flex-1"
|
|
disabled={createClinic.isPending}
|
|
>
|
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
|
Retour
|
|
</Button>
|
|
)}
|
|
{step < 3 ? (
|
|
<Button
|
|
onClick={handleNext}
|
|
disabled={createClinic.isPending}
|
|
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90 glow-teal font-semibold h-12"
|
|
>
|
|
{createClinic.isPending ? (
|
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Création...</>
|
|
) : (
|
|
<>{step === 2 ? "Créer le cabinet" : "Continuer"}<ChevronRight className="w-4 h-4 ml-1" /></>
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<div className="flex gap-3 w-full">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => navigate(`/dashboard/queue/${clinicId}`)}
|
|
className="flex-1"
|
|
>
|
|
<QrCode className="w-4 h-4 mr-2" />
|
|
Voir le QR code
|
|
</Button>
|
|
<Button
|
|
onClick={() => navigate("/dashboard")}
|
|
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90 glow-teal font-semibold"
|
|
>
|
|
Tableau de bord
|
|
<ChevronRight className="w-4 h-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Skip link */}
|
|
{step < 3 && (
|
|
<p className="text-center mt-4 text-sm text-muted-foreground">
|
|
<button
|
|
onClick={() => navigate("/dashboard")}
|
|
className="underline underline-offset-2 hover:text-foreground transition-colors"
|
|
>
|
|
Passer pour l'instant
|
|
</button>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|