From 1dbb131d24f677b0725d17d01b88be297175bc01 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 25 Apr 2026 12:52:35 +0000 Subject: [PATCH] =?UTF-8?q?initial:=20QueueMed=20v1.0=20MVP=20=E2=80=94=20?= =?UTF-8?q?file=20d'attente,=20WhatsApp,=20auth,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 27 + .env.example | 24 + .gitignore | 17 + Dockerfile | 46 + client/index.html | 24 + client/src/App.tsx | 116 + client/src/_core/hooks/useAuth.ts | 66 + client/src/components/CountryCodeManager.tsx | 291 + client/src/components/Layout.tsx | 166 + client/src/components/PhoneDialCodePicker.tsx | 242 + .../src/components/WhatsAppTemplateEditor.tsx | 352 + client/src/components/ui/accordion.tsx | 64 + client/src/components/ui/alert-dialog.tsx | 155 + client/src/components/ui/alert.tsx | 66 + client/src/components/ui/aspect-ratio.tsx | 9 + client/src/components/ui/avatar.tsx | 51 + client/src/components/ui/badge.tsx | 46 + client/src/components/ui/breadcrumb.tsx | 109 + client/src/components/ui/button-group.tsx | 83 + client/src/components/ui/button.tsx | 60 + client/src/components/ui/calendar.tsx | 211 + client/src/components/ui/card.tsx | 92 + client/src/components/ui/carousel.tsx | 239 + client/src/components/ui/chart.tsx | 355 + client/src/components/ui/checkbox.tsx | 30 + client/src/components/ui/collapsible.tsx | 31 + client/src/components/ui/command.tsx | 184 + client/src/components/ui/context-menu.tsx | 250 + client/src/components/ui/dialog.tsx | 209 + client/src/components/ui/drawer.tsx | 133 + client/src/components/ui/dropdown-menu.tsx | 255 + client/src/components/ui/empty.tsx | 104 + client/src/components/ui/field.tsx | 242 + client/src/components/ui/form.tsx | 168 + client/src/components/ui/hover-card.tsx | 42 + client/src/components/ui/input-group.tsx | 168 + client/src/components/ui/input-otp.tsx | 75 + client/src/components/ui/input.tsx | 70 + client/src/components/ui/item.tsx | 193 + client/src/components/ui/kbd.tsx | 28 + client/src/components/ui/label.tsx | 22 + client/src/components/ui/menubar.tsx | 274 + client/src/components/ui/navigation-menu.tsx | 168 + client/src/components/ui/pagination.tsx | 127 + client/src/components/ui/popover.tsx | 46 + client/src/components/ui/progress.tsx | 29 + client/src/components/ui/radio-group.tsx | 43 + client/src/components/ui/resizable.tsx | 54 + client/src/components/ui/scroll-area.tsx | 56 + client/src/components/ui/select.tsx | 185 + client/src/components/ui/separator.tsx | 26 + client/src/components/ui/sheet.tsx | 139 + client/src/components/ui/sidebar.tsx | 734 ++ client/src/components/ui/skeleton.tsx | 13 + client/src/components/ui/slider.tsx | 61 + client/src/components/ui/sonner.tsx | 23 + client/src/components/ui/spinner.tsx | 16 + client/src/components/ui/switch.tsx | 29 + client/src/components/ui/table.tsx | 114 + client/src/components/ui/tabs.tsx | 64 + client/src/components/ui/textarea.tsx | 67 + client/src/components/ui/toast.tsx | 25 + client/src/components/ui/toggle-group.tsx | 73 + client/src/components/ui/toggle.tsx | 45 + client/src/components/ui/tooltip.tsx | 59 + client/src/hooks/useComposition.ts | 81 + client/src/hooks/useMobile.tsx | 21 + client/src/hooks/usePersistFn.ts | 20 + client/src/lib/socket.ts | 22 + client/src/lib/trpc.ts | 20 + client/src/lib/utils.ts | 26 + client/src/main.tsx | 35 + client/src/pages/Analytics.tsx | 273 + client/src/pages/ClinicSettings.tsx | 340 + client/src/pages/ConsultationHistory.tsx | 355 + client/src/pages/Dashboard.tsx | 199 + client/src/pages/DisplayScreen.tsx | 252 + client/src/pages/DoctorClinics.tsx | 422 + client/src/pages/Help.tsx | 369 + client/src/pages/Home.tsx | 452 + client/src/pages/Login.tsx | 197 + client/src/pages/Onboarding.tsx | 415 + client/src/pages/PatientQueue.tsx | 272 + client/src/pages/PrintTicket.tsx | 222 + client/src/pages/QrPoster.tsx | 302 + client/src/pages/QueueManagement.tsx | 394 + client/src/pages/SubscriptionBlocked.tsx | 37 + client/src/pages/SubscriptionPage.tsx | 251 + client/src/pages/WhatsAppSetup.tsx | 419 + client/src/styles.css | 238 + docker-compose.yml | 49 + drizzle.config.ts | 17 + package-lock.json | 10718 ++++++++++++++++ package.json | 92 + server/_core/context.ts | 18 + server/_core/index.ts | 148 + server/_core/trpc.ts | 43 + server/auth.ts | 113 + server/db.ts | 659 + server/routers.ts | 1358 ++ server/schema.ts | 251 + server/services/autoAbsent.ts | 151 + server/services/whatsapp.ts | 285 + shared/_core/errors.ts | 19 + shared/const.ts | 5 + shared/countryCodes.ts | 104 + shared/openingHours.ts | 189 + shared/phoneValidation.ts | 150 + shared/types.ts | 105 + shared/whatsappTemplates.ts | 150 + tsconfig.json | 30 + vite.config.ts | 38 + 112 files changed, 27911 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 client/index.html create mode 100644 client/src/App.tsx create mode 100644 client/src/_core/hooks/useAuth.ts create mode 100644 client/src/components/CountryCodeManager.tsx create mode 100644 client/src/components/Layout.tsx create mode 100644 client/src/components/PhoneDialCodePicker.tsx create mode 100644 client/src/components/WhatsAppTemplateEditor.tsx create mode 100644 client/src/components/ui/accordion.tsx create mode 100644 client/src/components/ui/alert-dialog.tsx create mode 100644 client/src/components/ui/alert.tsx create mode 100644 client/src/components/ui/aspect-ratio.tsx create mode 100644 client/src/components/ui/avatar.tsx create mode 100644 client/src/components/ui/badge.tsx create mode 100644 client/src/components/ui/breadcrumb.tsx create mode 100644 client/src/components/ui/button-group.tsx create mode 100644 client/src/components/ui/button.tsx create mode 100644 client/src/components/ui/calendar.tsx create mode 100644 client/src/components/ui/card.tsx create mode 100644 client/src/components/ui/carousel.tsx create mode 100644 client/src/components/ui/chart.tsx create mode 100644 client/src/components/ui/checkbox.tsx create mode 100644 client/src/components/ui/collapsible.tsx create mode 100644 client/src/components/ui/command.tsx create mode 100644 client/src/components/ui/context-menu.tsx create mode 100644 client/src/components/ui/dialog.tsx create mode 100644 client/src/components/ui/drawer.tsx create mode 100644 client/src/components/ui/dropdown-menu.tsx create mode 100644 client/src/components/ui/empty.tsx create mode 100644 client/src/components/ui/field.tsx create mode 100644 client/src/components/ui/form.tsx create mode 100644 client/src/components/ui/hover-card.tsx create mode 100644 client/src/components/ui/input-group.tsx create mode 100644 client/src/components/ui/input-otp.tsx create mode 100644 client/src/components/ui/input.tsx create mode 100644 client/src/components/ui/item.tsx create mode 100644 client/src/components/ui/kbd.tsx create mode 100644 client/src/components/ui/label.tsx create mode 100644 client/src/components/ui/menubar.tsx create mode 100644 client/src/components/ui/navigation-menu.tsx create mode 100644 client/src/components/ui/pagination.tsx create mode 100644 client/src/components/ui/popover.tsx create mode 100644 client/src/components/ui/progress.tsx create mode 100644 client/src/components/ui/radio-group.tsx create mode 100644 client/src/components/ui/resizable.tsx create mode 100644 client/src/components/ui/scroll-area.tsx create mode 100644 client/src/components/ui/select.tsx create mode 100644 client/src/components/ui/separator.tsx create mode 100644 client/src/components/ui/sheet.tsx create mode 100644 client/src/components/ui/sidebar.tsx create mode 100644 client/src/components/ui/skeleton.tsx create mode 100644 client/src/components/ui/slider.tsx create mode 100644 client/src/components/ui/sonner.tsx create mode 100644 client/src/components/ui/spinner.tsx create mode 100644 client/src/components/ui/switch.tsx create mode 100644 client/src/components/ui/table.tsx create mode 100644 client/src/components/ui/tabs.tsx create mode 100644 client/src/components/ui/textarea.tsx create mode 100644 client/src/components/ui/toast.tsx create mode 100644 client/src/components/ui/toggle-group.tsx create mode 100644 client/src/components/ui/toggle.tsx create mode 100644 client/src/components/ui/tooltip.tsx create mode 100644 client/src/hooks/useComposition.ts create mode 100644 client/src/hooks/useMobile.tsx create mode 100644 client/src/hooks/usePersistFn.ts create mode 100644 client/src/lib/socket.ts create mode 100644 client/src/lib/trpc.ts create mode 100644 client/src/lib/utils.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/Analytics.tsx create mode 100644 client/src/pages/ClinicSettings.tsx create mode 100644 client/src/pages/ConsultationHistory.tsx create mode 100644 client/src/pages/Dashboard.tsx create mode 100644 client/src/pages/DisplayScreen.tsx create mode 100644 client/src/pages/DoctorClinics.tsx create mode 100644 client/src/pages/Help.tsx create mode 100644 client/src/pages/Home.tsx create mode 100644 client/src/pages/Login.tsx create mode 100644 client/src/pages/Onboarding.tsx create mode 100644 client/src/pages/PatientQueue.tsx create mode 100644 client/src/pages/PrintTicket.tsx create mode 100644 client/src/pages/QrPoster.tsx create mode 100644 client/src/pages/QueueManagement.tsx create mode 100644 client/src/pages/SubscriptionBlocked.tsx create mode 100644 client/src/pages/SubscriptionPage.tsx create mode 100644 client/src/pages/WhatsAppSetup.tsx create mode 100644 client/src/styles.css create mode 100644 docker-compose.yml create mode 100644 drizzle.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/_core/context.ts create mode 100644 server/_core/index.ts create mode 100644 server/_core/trpc.ts create mode 100644 server/auth.ts create mode 100644 server/db.ts create mode 100644 server/routers.ts create mode 100644 server/schema.ts create mode 100644 server/services/autoAbsent.ts create mode 100644 server/services/whatsapp.ts create mode 100644 shared/_core/errors.ts create mode 100644 shared/const.ts create mode 100644 shared/countryCodes.ts create mode 100644 shared/openingHours.ts create mode 100644 shared/phoneValidation.ts create mode 100644 shared/types.ts create mode 100644 shared/whatsappTemplates.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts 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 */ +
+