+ {/* ─── Sidebar (desktop) ──────────────────────────────────────────── */}
+
+
+
+
+
+
+ QueueMed
+
+
+
+
+ {NAV.map((item) => {
+ const Icon = item.icon;
+ const active = isActive(item.href);
+ return (
+
+
+
+ {item.label}
+
+
+ );
+ })}
+
+
+ {/* User card */}
+
+
+
+ Connecté
+
+
+ {user?.name ?? user?.email ?? "—"}
+
+ {user?.email && user?.name && (
+
{user.email}
+ )}
+
logout()}
+ className="mt-3 w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-600 bg-white hover:bg-red-50 hover:text-red-600 border border-slate-200 transition-colors"
+ >
+ Déconnexion
+
+
+
+
+
+ {/* ─── Main column ───────────────────────────────────────────────── */}
+
+ {/* Top bar (mobile) */}
+
+
+
+
+
+
+ QueueMed
+
+
+ setMobileOpen(true)}
+ className="p-2 rounded-lg text-slate-600 hover:bg-emerald-50"
+ aria-label="Menu"
+ >
+
+
+
+
+ {/* Mobile drawer */}
+ {mobileOpen && (
+
+
setMobileOpen(false)}
+ />
+
+
+ )}
+
+
{children}
+
+
+ );
+}
diff --git a/client/src/components/PhoneDialCodePicker.tsx b/client/src/components/PhoneDialCodePicker.tsx
new file mode 100644
index 0000000..f5a8a94
--- /dev/null
+++ b/client/src/components/PhoneDialCodePicker.tsx
@@ -0,0 +1,242 @@
+/**
+ * PhoneDialCodePicker
+ * Sélecteur d'indicatif pays + champ numéro local pour les notifications WhatsApp.
+ * - Affiche uniquement les pays activés par l'admin (trpc.whatsapp.listCountryCodes)
+ * - Valide la longueur et le format du numéro selon les règles par pays
+ * - Expose onValidationChange pour que le parent puisse bloquer la soumission
+ */
+import { useState, useRef, useEffect, useMemo } from "react";
+import { trpc } from "@/lib/trpc";
+import { Loader2, ChevronDown, Search, AlertCircle, CheckCircle2 } from "lucide-react";
+import {
+ validateLocalPhone,
+ getPhonePlaceholder,
+ getPhoneHint,
+} from "@shared/phoneValidation";
+
+type CountryCode = {
+ id: number;
+ code: string;
+ dialCode: string;
+ nameFr: string;
+ flag: string;
+ enabled: boolean;
+ sortOrder: number;
+};
+
+type Props = {
+ /** Numéro complet au format international sans + (ex: "33612345678") */
+ value: string;
+ onChange: (fullNumber: string) => void;
+ /** Appelé avec null si valide, message d'erreur sinon */
+ onValidationChange?: (error: string | null) => void;
+ className?: string;
+};
+
+export default function PhoneDialCodePicker({
+ value,
+ onChange,
+ onValidationChange,
+ className = "",
+}: Props) {
+ const { data: countries = [], isLoading } = trpc.whatsapp.listCountryCodes.useQuery();
+
+ const [selectedCode, setSelectedCode] = useState
(null);
+ const [localNumber, setLocalNumber] = useState("");
+ const [touched, setTouched] = useState(false);
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const dropdownRef = useRef(null);
+
+ // Auto-select first country once loaded
+ useEffect(() => {
+ if (countries.length > 0 && !selectedCode) {
+ setSelectedCode(countries[0]);
+ }
+ }, [countries, selectedCode]);
+
+ // Sync outgoing value + validation
+ useEffect(() => {
+ if (!selectedCode) return;
+ const cleaned = localNumber.replace(/\D/g, "").replace(/^0+/, "");
+ const full = cleaned ? `${selectedCode.dialCode}${cleaned}` : "";
+ onChange(full);
+
+ if (onValidationChange) {
+ if (!cleaned) {
+ // Empty = not yet filled, no error shown until touched
+ onValidationChange(touched ? "Veuillez saisir votre numéro." : null);
+ } else {
+ const err = validateLocalPhone(selectedCode.dialCode, cleaned);
+ onValidationChange(err);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedCode, localNumber, touched]);
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setDropdownOpen(false);
+ setSearch("");
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
+ const filtered = useMemo(() => {
+ if (!search.trim()) return countries;
+ const q = search.toLowerCase();
+ return countries.filter(
+ (c) =>
+ c.nameFr.toLowerCase().includes(q) ||
+ c.code.toLowerCase().includes(q) ||
+ c.dialCode.includes(q)
+ );
+ }, [countries, search]);
+
+ // Compute validation state for display
+ const cleaned = localNumber.replace(/\D/g, "").replace(/^0+/, "");
+ const validationError = selectedCode && cleaned
+ ? validateLocalPhone(selectedCode.dialCode, cleaned)
+ : null;
+ const isValid = cleaned.length > 0 && validationError === null;
+ const showError = touched && cleaned.length > 0 && validationError !== null;
+
+ const placeholder = selectedCode ? getPhonePlaceholder(selectedCode.dialCode) : "123456789";
+ const hint = selectedCode ? getPhoneHint(selectedCode.dialCode) : "";
+
+ if (isLoading) {
+ return (
+
+
+ Chargement…
+
+ );
+ }
+
+ if (countries.length === 0) {
+ return (
+ setLocalNumber(e.target.value)}
+ placeholder="Numéro international (ex: 33612345678)"
+ className={`w-full bg-background/50 border border-emerald-500/40 rounded-xl px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-emerald-500/70 ${className}`}
+ />
+ );
+ }
+
+ return (
+
+
+ {/* Dial code selector */}
+
setDropdownOpen((o) => !o)}
+ className={`flex items-center gap-1.5 px-3 py-2.5 bg-background/50 border rounded-xl text-sm text-foreground hover:border-emerald-500/70 transition-colors shrink-0 min-w-[96px] ${
+ showError ? "border-red-500/60" : "border-emerald-500/40"
+ }`}
+ >
+ {selectedCode ? (
+ <>
+ {selectedCode.flag}
+ +{selectedCode.dialCode}
+ >
+ ) : (
+ Pays
+ )}
+
+
+
+ {/* Local number input */}
+
+
setLocalNumber(e.target.value)}
+ onBlur={() => setTouched(true)}
+ placeholder={placeholder}
+ className={`w-full bg-background/50 border rounded-xl px-4 py-2.5 pr-9 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none transition-colors ${
+ showError
+ ? "border-red-500/60 focus:border-red-500/80"
+ : isValid
+ ? "border-emerald-500/60 focus:border-emerald-500/80"
+ : "border-emerald-500/40 focus:border-emerald-500/70"
+ }`}
+ />
+ {/* Validation icon */}
+ {cleaned.length > 0 && (
+
+ {isValid ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {/* Dropdown */}
+ {dropdownOpen && (
+
+ {/* Search */}
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Rechercher…"
+ className="w-full pl-8 pr-3 py-1.5 text-sm bg-background/50 border border-border/50 rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary/60"
+ />
+
+
+
+ {/* Country list */}
+
+ {filtered.length === 0 ? (
+
Aucun résultat
+ ) : (
+ filtered.map((c) => (
+
{
+ setSelectedCode(c);
+ setDropdownOpen(false);
+ setSearch("");
+ setTouched(false); // reset validation on country change
+ setLocalNumber(""); // reset number on country change
+ }}
+ className={`w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-accent transition-colors ${
+ selectedCode?.code === c.code ? "bg-emerald-500/10" : ""
+ }`}
+ >
+ {c.flag}
+ {c.nameFr}
+ +{c.dialCode}
+
+ ))
+ )}
+
+
+ )}
+
+
+ {/* Validation message or hint */}
+ {showError ? (
+
+
+ {validationError}
+
+ ) : (
+
{hint}
+ )}
+
+ );
+}
diff --git a/client/src/components/WhatsAppTemplateEditor.tsx b/client/src/components/WhatsAppTemplateEditor.tsx
new file mode 100644
index 0000000..63920bd
--- /dev/null
+++ b/client/src/components/WhatsAppTemplateEditor.tsx
@@ -0,0 +1,352 @@
+import { useState, useRef, useCallback, useMemo, useEffect } from "react";
+import { trpc } from "@/lib/trpc";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import {
+ DEFAULT_TEMPLATES,
+ TEMPLATE_VARIABLES,
+ TEMPLATE_LABELS,
+ SAMPLE_CONTEXT,
+ interpolateTemplate,
+ type TemplateType,
+} from "../../../shared/whatsappTemplates";
+import { Save, RotateCcw, Eye, EyeOff, MessageSquare, Sparkles } from "lucide-react";
+
+interface WhatsAppTemplateEditorProps {
+ clinicId: number;
+}
+
+export default function WhatsAppTemplateEditor({ clinicId }: WhatsAppTemplateEditorProps) {
+ const { data: templates, isLoading, refetch } = trpc.clinicSettings.getTemplates.useQuery({ clinicId });
+ const updateMutation = trpc.clinicSettings.updateTemplates.useMutation({
+ onSuccess: () => {
+ toast.success("Templates sauvegardés", { description: "Les modèles de messages ont été mis à jour." });
+ refetch();
+ },
+ onError: (err) => {
+ toast.error("Erreur", { description: err.message });
+ },
+ });
+
+ const templateTypes: TemplateType[] = ["joined", "soon", "called", "withdrawn"];
+
+ // Local state for each template
+ const [localTemplates, setLocalTemplates] = useState>({
+ joined: "",
+ soon: "",
+ called: "",
+ withdrawn: "",
+ });
+
+ // Track which templates have been modified
+ const [modified, setModified] = useState>({
+ joined: false,
+ soon: false,
+ called: false,
+ withdrawn: false,
+ });
+
+ // Preview toggle per template
+ const [showPreview, setShowPreview] = useState>({
+ joined: false,
+ soon: false,
+ called: false,
+ withdrawn: false,
+ });
+
+ // Active tab
+ const [activeTab, setActiveTab] = useState("joined");
+
+ // Refs for textareas
+ const textareaRefs = useRef>({
+ joined: null,
+ soon: null,
+ called: null,
+ withdrawn: null,
+ });
+
+ // Initialize local state from server data
+ useEffect(() => {
+ if (templates) {
+ setLocalTemplates({
+ joined: templates.joined ?? DEFAULT_TEMPLATES.joined,
+ soon: templates.soon ?? DEFAULT_TEMPLATES.soon,
+ called: templates.called ?? DEFAULT_TEMPLATES.called,
+ withdrawn: templates.withdrawn ?? DEFAULT_TEMPLATES.withdrawn,
+ });
+ setModified({ joined: false, soon: false, called: false, withdrawn: false });
+ }
+ }, [templates]);
+
+ const handleChange = useCallback((type: TemplateType, value: string) => {
+ setLocalTemplates((prev) => ({ ...prev, [type]: value }));
+ setModified((prev) => ({ ...prev, [type]: true }));
+ }, []);
+
+ const insertVariable = useCallback((type: TemplateType, variable: string) => {
+ const textarea = textareaRefs.current[type];
+ if (textarea) {
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const current = localTemplates[type];
+ const newValue = current.substring(0, start) + variable + current.substring(end);
+ setLocalTemplates((prev) => ({ ...prev, [type]: newValue }));
+ setModified((prev) => ({ ...prev, [type]: true }));
+ // Restore cursor position after variable
+ setTimeout(() => {
+ textarea.focus();
+ textarea.selectionStart = start + variable.length;
+ textarea.selectionEnd = start + variable.length;
+ }, 0);
+ } else {
+ // Fallback: append at end
+ setLocalTemplates((prev) => ({ ...prev, [type]: prev[type] + variable }));
+ setModified((prev) => ({ ...prev, [type]: true }));
+ }
+ }, [localTemplates]);
+
+ const resetToDefault = useCallback((type: TemplateType) => {
+ setLocalTemplates((prev) => ({ ...prev, [type]: DEFAULT_TEMPLATES[type] }));
+ setModified((prev) => ({ ...prev, [type]: true }));
+ }, []);
+
+ const togglePreview = useCallback((type: TemplateType) => {
+ setShowPreview((prev) => ({ ...prev, [type]: !prev[type] }));
+ }, []);
+
+ const handleSave = useCallback(() => {
+ const payload: Record = { clinicId: clinicId as any };
+ for (const type of templateTypes) {
+ if (modified[type]) {
+ // If it's the same as default, save null (use default)
+ if (localTemplates[type] === DEFAULT_TEMPLATES[type]) {
+ (payload as any)[type] = null;
+ } else {
+ (payload as any)[type] = localTemplates[type];
+ }
+ }
+ }
+ updateMutation.mutate(payload as any);
+ setModified({ joined: false, soon: false, called: false, withdrawn: false });
+ }, [localTemplates, modified, clinicId, updateMutation]);
+
+ const hasAnyModification = Object.values(modified).some(Boolean);
+
+ // Preview rendering with highlighted variables
+ const renderPreview = useCallback((template: string) => {
+ const rendered = interpolateTemplate(template, SAMPLE_CONTEXT);
+ return rendered;
+ }, []);
+
+ // Highlight variables in template text
+ const highlightVariables = useCallback((text: string) => {
+ const parts = text.split(/(\{\{[a-z]+\}\})/g);
+ return parts.map((part, i) => {
+ if (part.match(/^\{\{[a-z]+\}\}$/)) {
+ return (
+
+ {part}
+
+ );
+ }
+ return {part} ;
+ });
+ }, []);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const activeLabel = TEMPLATE_LABELS[activeTab];
+
+ return (
+
+
+
+
+
+
+ Modèles de messages WhatsApp
+
+
+ Personnalisez les messages envoyés automatiquement aux patients. Cliquez sur une variable pour l'insérer.
+
+
+ {hasAnyModification && (
+
+
+ {updateMutation.isPending ? "Sauvegarde..." : "Sauvegarder"}
+
+ )}
+
+
+
+ {/* Variables reference */}
+
+
+
+ Variables disponibles
+
+
+ {TEMPLATE_VARIABLES.map((v) => (
+ insertVariable(activeTab, v.key)}
+ className="group relative inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-background/80 border border-border/50 hover:border-teal-500/50 hover:bg-teal-500/10 transition-all cursor-pointer"
+ title={v.description}
+ >
+ {v.key}
+ {v.label}
+
+ ))}
+
+
+
+ {/* Template tabs */}
+
+ {templateTypes.map((type) => {
+ const label = TEMPLATE_LABELS[type];
+ return (
+ setActiveTab(type)}
+ className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-all ${
+ activeTab === type
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground hover:bg-background/50"
+ }`}
+ >
+ {label.icon}
+ {label.title}
+ {modified[type] && (
+
+ )}
+
+ );
+ })}
+
+
+ {/* Active template editor */}
+
+
+
+
{activeLabel.icon} {activeLabel.title}
+
{activeLabel.description}
+
+
+ togglePreview(activeTab)}
+ className="text-xs"
+ >
+ {showPreview[activeTab] ? (
+ <> Éditer>
+ ) : (
+ <> Aperçu>
+ )}
+
+ resetToDefault(activeTab)}
+ className="text-xs"
+ >
+ Par défaut
+
+
+
+
+ {showPreview[activeTab] ? (
+ /* Preview mode – WhatsApp-style bubble */
+
+
+
+ {renderPreview(localTemplates[activeTab])}
+
+
+ Aperçu avec données fictives
+
+
+
+ ) : (
+ /* Edit mode */
+
+ )}
+
+
+ {/* Quick preview of all templates */}
+
+
Aperçu rapide de tous les messages
+
+ {templateTypes.map((type) => {
+ const label = TEMPLATE_LABELS[type];
+ const isCustom = templates && templates[type] !== null;
+ return (
+
setActiveTab(type)}
+ className={`p-3 rounded-lg border cursor-pointer transition-all ${
+ activeTab === type
+ ? "border-teal-500/50 bg-teal-500/5"
+ : "border-border/30 bg-background/30 hover:border-border/60"
+ }`}
+ >
+
+
+ {label.icon} {label.title}
+
+ {isCustom ? (
+
+ Personnalisé
+
+ ) : (
+
+ Par défaut
+
+ )}
+
+
+ {localTemplates[type].substring(0, 80)}...
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..62705e3
--- /dev/null
+++ b/client/src/components/ui/accordion.tsx
@@ -0,0 +1,64 @@
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDownIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..6949979
--- /dev/null
+++ b/client/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,155 @@
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx
new file mode 100644
index 0000000..5b1a0b5
--- /dev/null
+++ b/client/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..01d045d
--- /dev/null
+++ b/client/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,9 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { AspectRatio };
diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..02305fd
--- /dev/null
+++ b/client/src/components/ui/avatar.tsx
@@ -0,0 +1,51 @@
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx
new file mode 100644
index 0000000..83750ed
--- /dev/null
+++ b/client/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..9d88a37
--- /dev/null
+++ b/client/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/client/src/components/ui/button-group.tsx b/client/src/components/ui/button-group.tsx
new file mode 100644
index 0000000..30139ec
--- /dev/null
+++ b/client/src/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+import { Separator } from "@/components/ui/separator";
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+
+ );
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+};
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
new file mode 100644
index 0000000..6d74f9a
--- /dev/null
+++ b/client/src/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-transparent shadow-xs hover:bg-accent dark:bg-transparent dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/client/src/components/ui/calendar.tsx b/client/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..48d4543
--- /dev/null
+++ b/client/src/components/ui/calendar.tsx
@@ -0,0 +1,211 @@
+import * as React from "react";
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react";
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
+
+import { cn } from "@/lib/utils";
+import { Button, buttonVariants } from "@/components/ui/button";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: date =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "flex gap-4 flex-col md:flex-row relative",
+ defaultClassNames.months
+ ),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute bg-popover inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn(
+ "select-none w-(--cell-size)",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] select-none text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "rounded-l-md bg-accent",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ );
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ );
+ }
+
+ if (orientation === "right") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+
+ );
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+ span]:text-xs [&>span]:opacity-70",
+ defaultClassNames.day,
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Calendar, CalendarDayButton };
diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx
new file mode 100644
index 0000000..e8c0939
--- /dev/null
+++ b/client/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/client/src/components/ui/carousel.tsx b/client/src/components/ui/carousel.tsx
new file mode 100644
index 0000000..03a9617
--- /dev/null
+++ b/client/src/components/ui/carousel.tsx
@@ -0,0 +1,239 @@
+import * as React from "react";
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext]
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+
+ Previous slide
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+
+ Next slide
+
+ );
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
diff --git a/client/src/components/ui/chart.tsx b/client/src/components/ui/chart.tsx
new file mode 100644
index 0000000..f93f6c4
--- /dev/null
+++ b/client/src/components/ui/chart.tsx
@@ -0,0 +1,355 @@
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ );
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"];
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+
+
+ );
+}
diff --git a/client/src/pages/QrPoster.tsx b/client/src/pages/QrPoster.tsx
new file mode 100644
index 0000000..e230372
--- /dev/null
+++ b/client/src/pages/QrPoster.tsx
@@ -0,0 +1,302 @@
+import { useParams, useLocation } from "wouter";
+import {
+ Stethoscope, Printer, ChevronLeft, Loader2, QrCode,
+ RefreshCw, MapPin, Phone, Smartphone, Hand, Bell,
+} from "lucide-react";
+import { trpc } from "@/lib/trpc";
+import { Button } from "@/components/ui/button";
+
+export default function QrPoster() {
+ const params = useParams<{ clinicId: string }>();
+ const [, navigate] = useLocation();
+ const clinicId = parseInt(params.clinicId ?? "0", 10);
+
+ const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
+
+ const clinicQuery = trpc.clinic.getById.useQuery(
+ { id: clinicId },
+ { enabled: clinicId > 0 }
+ );
+ const qrQuery = trpc.clinic.qrDataUrl.useQuery(
+ { id: clinicId, baseUrl },
+ { enabled: clinicId > 0 }
+ );
+
+ const clinic = clinicQuery.data;
+ const qrDataUrl = qrQuery.data?.dataUrl;
+ const qrUrl = qrQuery.data?.url;
+
+ if (clinicQuery.isLoading || qrQuery.isLoading) {
+ return (
+