233 lines
7.8 KiB
TypeScript
233 lines
7.8 KiB
TypeScript
import { useState } from "react";
|
|
import { Loader2, Plus, Trash2, UserPlus, Users } from "lucide-react";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Button } from "@/components/ui/button";
|
|
import { toast } from "sonner";
|
|
|
|
const PRESET_COLORS = [
|
|
"#10b981", // emerald
|
|
"#06b6d4", // cyan
|
|
"#0d9488", // teal
|
|
"#8b5cf6", // violet
|
|
"#f97316", // orange
|
|
"#ec4899", // pink
|
|
"#3b82f6", // blue
|
|
"#eab308", // yellow
|
|
];
|
|
|
|
export default function PractitionerManager({ clinicId }: { clinicId: number }) {
|
|
const utils = trpc.useUtils();
|
|
const membersQuery = trpc.clinic.listMembers.useQuery(
|
|
{ clinicId },
|
|
{ enabled: clinicId > 0 }
|
|
);
|
|
|
|
const [adding, setAdding] = useState(false);
|
|
const [email, setEmail] = useState("");
|
|
const [displayName, setDisplayName] = useState("");
|
|
const [color, setColor] = useState(PRESET_COLORS[0]);
|
|
|
|
const addMember = trpc.clinic.addMember.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Praticien ajouté");
|
|
utils.clinic.listMembers.invalidate({ clinicId });
|
|
utils.clinic.listMembersPublic.invalidate({ clinicId });
|
|
setEmail("");
|
|
setDisplayName("");
|
|
setColor(PRESET_COLORS[0]);
|
|
setAdding(false);
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
|
|
const removeMember = trpc.clinic.removeMember.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Praticien retiré");
|
|
utils.clinic.listMembers.invalidate({ clinicId });
|
|
utils.clinic.listMembersPublic.invalidate({ clinicId });
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
|
|
const updateMember = trpc.clinic.updateMember.useMutation({
|
|
onSuccess: () => {
|
|
utils.clinic.listMembers.invalidate({ clinicId });
|
|
utils.clinic.listMembersPublic.invalidate({ clinicId });
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
|
|
const handleAdd = () => {
|
|
if (!email.trim()) {
|
|
toast.error("Email requis");
|
|
return;
|
|
}
|
|
addMember.mutate({
|
|
clinicId,
|
|
email: email.trim(),
|
|
displayName: displayName.trim() || undefined,
|
|
color,
|
|
});
|
|
};
|
|
|
|
const members = membersQuery.data ?? [];
|
|
|
|
return (
|
|
<div className="glass-card rounded-2xl p-5 space-y-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
|
|
<Users className="w-4 h-4 text-emerald-700" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-slate-900">Praticiens du cabinet</h3>
|
|
<p className="text-xs text-slate-500">
|
|
Multi-médecins · couleur d'identification
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant={adding ? "ghost" : "outline"}
|
|
onClick={() => setAdding((v) => !v)}
|
|
>
|
|
{adding ? (
|
|
"Annuler"
|
|
) : (
|
|
<>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> Ajouter
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{adding && (
|
|
<div className="rounded-xl border border-emerald-200/70 bg-emerald-50/40 p-4 space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">
|
|
Email du praticien
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="docteur@example.com"
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400"
|
|
/>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Le praticien doit déjà avoir un compte QueueMed.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">
|
|
Nom affiché (optionnel)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
placeholder="Dr. Martin"
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-600 mb-2">
|
|
Couleur d'identification
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{PRESET_COLORS.map((c) => (
|
|
<button
|
|
key={c}
|
|
onClick={() => setColor(c)}
|
|
className={`w-8 h-8 rounded-lg border-2 transition-all ${
|
|
color === c
|
|
? "border-slate-900 scale-110 shadow-md"
|
|
: "border-white"
|
|
}`}
|
|
style={{ background: c }}
|
|
aria-label={`Couleur ${c}`}
|
|
/>
|
|
))}
|
|
<input
|
|
type="color"
|
|
value={color}
|
|
onChange={(e) => setColor(e.target.value)}
|
|
className="w-8 h-8 rounded-lg cursor-pointer"
|
|
aria-label="Couleur personnalisée"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="gradient"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={handleAdd}
|
|
disabled={addMember.isPending}
|
|
>
|
|
{addMember.isPending ? (
|
|
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
|
|
) : (
|
|
<UserPlus className="w-3.5 h-3.5 mr-1" />
|
|
)}
|
|
Ajouter le praticien
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{membersQuery.isLoading ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<Loader2 className="w-5 h-5 text-emerald-500 animate-spin" />
|
|
</div>
|
|
) : members.length === 0 ? (
|
|
<p className="text-center text-sm text-slate-500 py-6">
|
|
Aucun praticien associé. Ajoutez-en un pour activer l'attribution.
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y divide-slate-100">
|
|
{members.map((m) => (
|
|
<li
|
|
key={m.id}
|
|
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
|
|
>
|
|
<input
|
|
type="color"
|
|
value={m.color ?? "#10b981"}
|
|
onChange={(e) =>
|
|
updateMember.mutate({
|
|
clinicId,
|
|
memberId: m.id,
|
|
color: e.target.value,
|
|
})
|
|
}
|
|
className="w-9 h-9 rounded-lg border border-slate-200 cursor-pointer flex-shrink-0"
|
|
aria-label="Modifier la couleur"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-slate-900 text-sm truncate">
|
|
{m.displayName ?? m.name ?? m.email ?? "—"}
|
|
</div>
|
|
<div className="text-xs text-slate-500 truncate">
|
|
{m.email ?? "—"} · {m.role}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() =>
|
|
removeMember.mutate({ clinicId, memberId: m.id })
|
|
}
|
|
disabled={removeMember.isPending || m.role === "owner"}
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-red-600 hover:bg-red-50 disabled:opacity-30"
|
|
title={
|
|
m.role === "owner"
|
|
? "Le propriétaire ne peut être retiré"
|
|
: "Retirer ce praticien"
|
|
}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|