diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..710ca71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +node_modules +dist +.git +.github +.gitignore +.env +.env.* +!.env.example +src_ref +docs_ref +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +.idea +.vscode +coverage +.turbo +.next +*.md +!README.md +backend-prompt.md +MANUS_HANDOFF.md +MODE_OPERATOIRE.md +CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc2e367 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# ─── Database ─────────────────────────────────────────────────────────────── +# Local dev (host MySQL): +# DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed +# Docker compose (uses the "db" service): +DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed + +# ─── Auth ─────────────────────────────────────────────────────────────────── +# Generate a strong random secret, e.g.: openssl rand -hex 64 +JWT_SECRET=change_me_to_a_long_random_string + +# ─── Server ───────────────────────────────────────────────────────────────── +PORT=5000 +NODE_ENV=development + +# Public URL used to build QR code links (e.g. https://queuemed.example.com) +PUBLIC_BASE_URL= + +# ─── Docker compose only ──────────────────────────────────────────────────── +MYSQL_ROOT_PASSWORD=rootpassword +MYSQL_DATABASE=queuemed +MYSQL_USER=queuemed +MYSQL_PASSWORD=queuemed +MYSQL_PORT=3306 +APP_PORT=5000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4534cef --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +dist/ +.env +.env.docker +.env.local +*.log +.DS_Store +Thumbs.db +*.md +!README.md +!docs/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..057855a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.7 + +# ─── Stage 1 — install deps ───────────────────────────────────────────────── +FROM node:22-alpine AS deps +WORKDIR /app +RUN apk add --no-cache git python3 make g++ +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install + +# ─── Stage 2 — build client + server ───────────────────────────────────────── +FROM node:22-alpine AS builder +WORKDIR /app +RUN apk add --no-cache git +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +# ─── Stage 3 — runtime ────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=5000 + +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy production deps + built assets +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY package.json tsconfig.json drizzle.config.ts ./ +COPY server ./server +COPY shared ./shared + +# WhatsApp auth sessions dir +RUN mkdir -p /tmp/whatsapp-sessions && chown -R 1000:1000 /tmp/whatsapp-sessions + +RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app +USER app + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://127.0.0.1:5000/api/health || exit 1 + +CMD ["pnpm", "start"] diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..e558bdf --- /dev/null +++ b/client/index.html @@ -0,0 +1,24 @@ + + + + + + + + + QueueMed — Salle d'attente virtuelle + + + + + +
+ + + diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..31afe57 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,116 @@ +import { Route, Switch, Redirect } from "wouter"; +import { Toaster } from "@/components/ui/toast"; +import { useAuth } from "@/_core/hooks/useAuth"; +import Layout from "@/components/Layout"; +import { Loader2 } from "lucide-react"; + +import Home from "@/pages/Home"; +import Login from "@/pages/Login"; +import Dashboard from "@/pages/Dashboard"; +import DoctorClinics from "@/pages/DoctorClinics"; +import QueueManagement from "@/pages/QueueManagement"; +import Analytics from "@/pages/Analytics"; +import PatientQueue from "@/pages/PatientQueue"; +import DisplayScreen from "@/pages/DisplayScreen"; +import SubscriptionPage from "@/pages/SubscriptionPage"; +import PrintTicket from "@/pages/PrintTicket"; +import Onboarding from "@/pages/Onboarding"; +import Help from "@/pages/Help"; +import QrPoster from "@/pages/QrPoster"; +import ClinicSettings from "@/pages/ClinicSettings"; +import ConsultationHistory from "@/pages/ConsultationHistory"; +import WhatsAppSetup from "@/pages/WhatsAppSetup"; +import SubscriptionBlocked from "@/pages/SubscriptionBlocked"; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + if (!isAuthenticated) return ; + return {children}; +} + +export default function App() { + return ( + <> + + + {/* Public marketing & auth */} + + + + + {/* Public patient/display routes */} + + + + + + {/* Authenticated routes (wrapped in Layout) */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Fallback */} + + + + + + ); +} + +function NotFound() { + return ( +
+
+
404
+

Cette page n'existe pas ou a été déplacée.

+ + Retour à l'accueil + +
+
+ ); +} diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts new file mode 100644 index 0000000..8811715 --- /dev/null +++ b/client/src/_core/hooks/useAuth.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { useLocation } from "wouter"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; + +export function useAuth() { + const [, navigate] = useLocation(); + const queryClient = useQueryClient(); + + const meQuery = trpc.auth.me.useQuery(undefined, { + retry: false, + staleTime: 60_000, + }); + + const loginMutation = trpc.auth.login.useMutation({ + onSuccess: async () => { + await meQuery.refetch(); + await queryClient.invalidateQueries(); + toast.success("Connecté avec succès"); + }, + onError: (e) => toast.error(e.message), + }); + + const registerMutation = trpc.auth.register.useMutation({ + onSuccess: async () => { + await meQuery.refetch(); + await queryClient.invalidateQueries(); + toast.success("Compte créé — bienvenue sur QueueMed !"); + }, + onError: (e) => toast.error(e.message), + }); + + const logoutMutation = trpc.auth.logout.useMutation({ + onSuccess: async () => { + await queryClient.clear(); + await meQuery.refetch(); + navigate("/"); + toast.success("Déconnecté"); + }, + }); + + const login = useCallback( + (email: string, password: string) => loginMutation.mutateAsync({ email, password }), + [loginMutation] + ); + + const register = useCallback( + (email: string, password: string, name?: string) => + registerMutation.mutateAsync({ email, password, name }), + [registerMutation] + ); + + const logout = useCallback(() => logoutMutation.mutate(), [logoutMutation]); + + return { + user: meQuery.data ?? null, + isAuthenticated: !!meQuery.data, + loading: meQuery.isLoading, + login, + register, + logout, + isLoggingIn: loginMutation.isPending, + isRegistering: registerMutation.isPending, + }; +} diff --git a/client/src/components/CountryCodeManager.tsx b/client/src/components/CountryCodeManager.tsx new file mode 100644 index 0000000..2d5f805 --- /dev/null +++ b/client/src/components/CountryCodeManager.tsx @@ -0,0 +1,291 @@ +import { useState, useMemo } from "react"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + Globe, + Search, + CheckCircle, + Circle, + Loader2, + ChevronDown, + ChevronUp, + ToggleLeft, + ToggleRight, +} from "lucide-react"; + +type CountryCode = { + id: number; + code: string; + dialCode: string; + nameFr: string; + flag: string; + enabled: boolean; + sortOrder: number; +}; + +// Groupes régionaux pour organiser l'affichage +const REGION_GROUPS: { label: string; codes: string[] }[] = [ + { + label: "France & DOM-TOM", + codes: ["FR", "GP", "MQ", "RE", "GF", "PM", "YT", "NC", "PF", "WF"], + }, + { + label: "Europe", + codes: ["BE", "CH", "LU", "MC", "DE", "ES", "IT", "PT", "GB", "NL", "PL", "SE", "NO", "DK", "FI", "AT", "GR", "RO", "HU", "CZ", "TR"], + }, + { + label: "Afrique francophone", + codes: ["MA", "DZ", "TN", "SN", "CI", "CM", "CD", "CG", "MG", "ML", "BF", "NE", "TD", "GN", "BJ", "TG", "MR", "GA", "GQ", "CF", "KM", "DJ", "MU", "SC", "EG"], + }, + { + label: "Amériques", + codes: ["US", "CA", "MX", "BR", "AR", "CO", "CL", "PE", "VE", "EC", "BO", "PY", "UY", "HT"], + }, + { + label: "Asie & Océanie", + codes: ["IN", "CN", "JP", "AU", "LB"], + }, +]; + +export default function CountryCodeManager() { + const utils = trpc.useUtils(); + const [search, setSearch] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set(["France & DOM-TOM"])); + const [pendingToggles, setPendingToggles] = useState>(new Set()); + + const { data: allCodes = [], isLoading } = trpc.whatsapp.listAllCountryCodes.useQuery(); + + const toggleMut = trpc.whatsapp.toggleCountryCode.useMutation({ + onMutate: ({ code }) => { + setPendingToggles((prev) => new Set(prev).add(code)); + }, + onSuccess: (data) => { + setPendingToggles((prev) => { + const next = new Set(prev); + next.delete(data.code); + return next; + }); + utils.whatsapp.listAllCountryCodes.invalidate(); + utils.whatsapp.listCountryCodes.invalidate(); + toast.success(data.enabled ? "Indicatif activé" : "Indicatif désactivé"); + }, + onError: (err, { code }) => { + setPendingToggles((prev) => { + const next = new Set(prev); + next.delete(code); + return next; + }); + toast.error(err.message); + }, + }); + + const bulkMut = trpc.whatsapp.bulkToggleCountryCodes.useMutation({ + onSuccess: (data, vars) => { + utils.whatsapp.listAllCountryCodes.invalidate(); + utils.whatsapp.listCountryCodes.invalidate(); + toast.success(`${data.count} indicatifs ${vars.enabled ? "activés" : "désactivés"}`); + }, + onError: (err) => toast.error(err.message), + }); + + // Map code → entry for quick lookup + const codeMap = useMemo(() => { + const m = new Map(); + allCodes.forEach((c) => m.set(c.code, c)); + return m; + }, [allCodes]); + + // Filtered list for search mode + const searchResults = useMemo(() => { + if (!search.trim()) return []; + const q = search.toLowerCase(); + return allCodes.filter( + (c) => + c.nameFr.toLowerCase().includes(q) || + c.code.toLowerCase().includes(q) || + c.dialCode.includes(q) + ); + }, [allCodes, search]); + + const toggleGroup = (label: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(label)) next.delete(label); + else next.add(label); + return next; + }); + }; + + const handleToggle = (code: string, currentEnabled: boolean) => { + toggleMut.mutate({ code, enabled: !currentEnabled }); + }; + + const handleBulkGroup = (codes: string[], enabled: boolean) => { + const validCodes = codes.filter((c) => codeMap.has(c)); + bulkMut.mutate({ codes: validCodes, enabled }); + }; + + const enabledCount = allCodes.filter((c) => c.enabled).length; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const renderCountryRow = (c: CountryCode) => { + const isPending = pendingToggles.has(c.code); + return ( +
+
+ {c.flag} +
+ {c.nameFr} + +{c.dialCode} · {c.code} +
+
+ +
+ ); + }; + + return ( + + +
+
+ + + Indicatifs pays disponibles + + + Choisissez les pays affichés aux patients dans le formulaire WhatsApp.{" "} + + {enabledCount} activé{enabledCount > 1 ? "s" : ""} + + +
+
+ + {/* Search */} +
+ + setSearch(e.target.value)} + className="pl-9 bg-background/50" + /> +
+
+ + + {/* Search results */} + {search.trim() ? ( +
+ {searchResults.length === 0 ? ( +

Aucun résultat pour « {search} »

+ ) : ( + searchResults.map((c) => renderCountryRow(c)) + )} +
+ ) : ( + /* Grouped view */ + REGION_GROUPS.map((group) => { + const groupCodes = group.codes + .map((code) => codeMap.get(code)) + .filter(Boolean) as CountryCode[]; + if (groupCodes.length === 0) return null; + + const isExpanded = expandedGroups.has(group.label); + const groupEnabled = groupCodes.filter((c) => c.enabled).length; + + return ( +
+ {/* Group header */} +
toggleGroup(group.label)} + > +
+ {group.label} + + {groupEnabled}/{groupCodes.length} + +
+
+ {/* Bulk actions */} + + + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Group rows */} + {isExpanded && ( +
+ {groupCodes.map((c) => renderCountryRow(c))} +
+ )} +
+ ); + }) + )} +
+
+ ); +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..7e62aec --- /dev/null +++ b/client/src/components/Layout.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { Link, useLocation } from "wouter"; +import { + LayoutDashboard, Building2, BarChart3, CreditCard, + HelpCircle, LogOut, Stethoscope, Menu, X, +} from "lucide-react"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { cn } from "@/lib/utils"; + +const NAV = [ + { href: "/dashboard", label: "Tableau de bord", icon: LayoutDashboard }, + { href: "/dashboard/clinics", label: "Cabinets", icon: Building2 }, + { href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 }, + { href: "/dashboard/subscription", label: "Abonnement", icon: CreditCard }, + { href: "/help", label: "Aide", icon: HelpCircle }, +]; + +export default function Layout({ children }: { children: React.ReactNode }) { + const [location] = useLocation(); + const { user, logout } = useAuth(); + const [mobileOpen, setMobileOpen] = useState(false); + + const isActive = (href: string) => + href === "/dashboard" + ? location === "/dashboard" + : location === href || location.startsWith(href + "/"); + + return ( +
+ {/* ─── Sidebar (desktop) ──────────────────────────────────────────── */} + + + {/* ─── Main column ───────────────────────────────────────────────── */} +
+ {/* Top bar (mobile) */} +
+ + +
+ +
+ QueueMed +
+ + +
+ + {/* Mobile drawer */} + {mobileOpen && ( +
+
setMobileOpen(false)} + /> +
+
+
+
+ +
+ QueueMed +
+ +
+ +
+
+
{user?.name ?? user?.email ?? "—"}
+
+ +
+
+
+ )} + +
{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 */} + + + {/* 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) => ( + + )) + )} +
+
+ )} +
+ + {/* 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 && ( + + )} +
+
+ + {/* Variables reference */} +
+

+ + Variables disponibles +

+
+ {TEMPLATE_VARIABLES.map((v) => ( + + ))} +
+
+ + {/* Template tabs */} +
+ {templateTypes.map((type) => { + const label = TEMPLATE_LABELS[type]; + return ( + + ); + })} +
+ + {/* Active template editor */} +
+
+
+

{activeLabel.icon} {activeLabel.title}

+

{activeLabel.description}

+
+
+ + +
+
+ + {showPreview[activeTab] ? ( + /* Preview mode – WhatsApp-style bubble */ +
+
+
+ {renderPreview(localTemplates[activeTab])} +
+

+ Aperçu avec données fictives +

+
+
+ ) : ( + /* Edit mode */ +
+