initial: QueueMed v1.0 MVP — file d'attente, WhatsApp, auth, dashboard
This commit is contained in:
parent
d24d0c3e70
commit
1dbb131d24
112 changed files with 27911 additions and 0 deletions
27
.dockerignore
Normal file
27
.dockerignore
Normal file
|
|
@ -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
|
||||
24
.env.example
Normal file
24
.env.example
Normal file
|
|
@ -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
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
|
|
@ -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"]
|
||||
24
client/index.html
Normal file
24
client/index.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
<meta
|
||||
name="description"
|
||||
content="QueueMed — la salle d'attente virtuelle pour les cabinets médicaux. Vos patients scannent un QR code, suivent leur tour en temps réel, sans application à installer."
|
||||
/>
|
||||
<title>QueueMed — Salle d'attente virtuelle</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="antialiased font-sans bg-white text-slate-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
116
client/src/App.tsx
Normal file
116
client/src/App.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isAuthenticated) return <Redirect to="/login" />;
|
||||
return <Layout>{children}</Layout>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
<Switch>
|
||||
{/* Public marketing & auth */}
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/help" component={Help} />
|
||||
|
||||
{/* Public patient/display routes */}
|
||||
<Route path="/queue/:token" component={PatientQueue} />
|
||||
<Route path="/display/:clinicId" component={DisplayScreen} />
|
||||
<Route path="/ticket/:entryId" component={PrintTicket} />
|
||||
<Route path="/q/:clinicId/:qrToken" component={PatientQueue} />
|
||||
|
||||
{/* Authenticated routes (wrapped in Layout) */}
|
||||
<Route path="/dashboard">
|
||||
<ProtectedRoute><Dashboard /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/clinics">
|
||||
<ProtectedRoute><DoctorClinics /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/queue/:clinicId">
|
||||
<ProtectedRoute><QueueManagement /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/analytics">
|
||||
<ProtectedRoute><Analytics /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/subscription">
|
||||
<ProtectedRoute><SubscriptionPage /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/poster/:clinicId">
|
||||
<ProtectedRoute><QrPoster /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/onboarding">
|
||||
<ProtectedRoute><Onboarding /></ProtectedRoute>
|
||||
</Route>
|
||||
|
||||
|
||||
<Route path="/dashboard/clinic/:clinicId">
|
||||
<ProtectedRoute><ClinicSettings /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/history/:clinicId">
|
||||
<ProtectedRoute><ConsultationHistory /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/whatsapp">
|
||||
<ProtectedRoute><WhatsAppSetup /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/dashboard/subscription-blocked">
|
||||
<ProtectedRoute><SubscriptionBlocked /></ProtectedRoute>
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route>
|
||||
<NotFound />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<div className="text-7xl font-black gradient-text mb-3">404</div>
|
||||
<p className="text-slate-600 mb-6">Cette page n'existe pas ou a été déplacée.</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block px-5 py-3 rounded-xl bg-teal-600 text-white font-semibold hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
client/src/_core/hooks/useAuth.ts
Normal file
66
client/src/_core/hooks/useAuth.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
291
client/src/components/CountryCodeManager.tsx
Normal file
291
client/src/components/CountryCodeManager.tsx
Normal file
|
|
@ -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<Set<string>>(new Set(["France & DOM-TOM"]));
|
||||
const [pendingToggles, setPendingToggles] = useState<Set<string>>(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<string, CountryCode>();
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCountryRow = (c: CountryCode) => {
|
||||
const isPending = pendingToggles.has(c.code);
|
||||
return (
|
||||
<div
|
||||
key={c.code}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
|
||||
c.enabled ? "bg-emerald-500/5 border border-emerald-500/20" : "hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="text-xl shrink-0">{c.flag}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground truncate block">{c.nameFr}</span>
|
||||
<span className="text-xs text-muted-foreground">+{c.dialCode} · {c.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(c.code, c.enabled)}
|
||||
disabled={isPending}
|
||||
className={`shrink-0 ml-3 transition-colors ${
|
||||
c.enabled ? "text-emerald-400 hover:text-emerald-300" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title={c.enabled ? "Désactiver" : "Activer"}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : c.enabled ? (
|
||||
<ToggleRight className="w-6 h-6" />
|
||||
) : (
|
||||
<ToggleLeft className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Indicatifs pays disponibles
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Choisissez les pays affichés aux patients dans le formulaire WhatsApp.{" "}
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs">
|
||||
{enabledCount} activé{enabledCount > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mt-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher un pays, un indicatif…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 bg-background/50"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* Search results */}
|
||||
{search.trim() ? (
|
||||
<div className="space-y-1">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Aucun résultat pour « {search} »</p>
|
||||
) : (
|
||||
searchResults.map((c) => renderCountryRow(c))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* 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 (
|
||||
<div key={group.label} className="border border-border/40 rounded-xl overflow-hidden">
|
||||
{/* Group header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 bg-muted/20 cursor-pointer hover:bg-muted/30 transition-colors"
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{group.label}</span>
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
{groupEnabled}/{groupCodes.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Bulk actions */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleBulkGroup(group.codes, true);
|
||||
}}
|
||||
disabled={bulkMut.isPending}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Tout activer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleBulkGroup(group.codes, false);
|
||||
}}
|
||||
disabled={bulkMut.isPending}
|
||||
>
|
||||
<Circle className="w-3 h-3 mr-1" />
|
||||
Tout désactiver
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group rows */}
|
||||
{isExpanded && (
|
||||
<div className="p-2 space-y-1">
|
||||
{groupCodes.map((c) => renderCountryRow(c))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
166
client/src/components/Layout.tsx
Normal file
166
client/src/components/Layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex bg-gradient-to-br from-emerald-50/40 via-white to-cyan-50/40">
|
||||
{/* ─── Sidebar (desktop) ──────────────────────────────────────────── */}
|
||||
<aside className="hidden lg:flex lg:w-64 flex-shrink-0 flex-col border-r border-emerald-100/60 bg-white/70 backdrop-blur-xl">
|
||||
<Link href="/dashboard">
|
||||
<a className="flex items-center gap-3 px-6 h-16 border-b border-emerald-100/60">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
|
||||
<Stethoscope className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="font-bold text-lg gradient-text tracking-tight">QueueMed</div>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 px-3 py-6 space-y-1">
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<a
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
|
||||
active
|
||||
? "bg-gradient-to-r from-emerald-500 to-cyan-500 text-white shadow-md"
|
||||
: "text-slate-600 hover:bg-emerald-50 hover:text-emerald-700"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User card */}
|
||||
<div className="p-3 border-t border-emerald-100/60">
|
||||
<div className="px-3 py-3 rounded-xl bg-gradient-to-br from-emerald-50 to-cyan-50 border border-emerald-100/80">
|
||||
<div className="text-xs uppercase tracking-wider text-emerald-700 font-semibold mb-1">
|
||||
Connecté
|
||||
</div>
|
||||
<div className="font-semibold text-slate-900 text-sm truncate">
|
||||
{user?.name ?? user?.email ?? "—"}
|
||||
</div>
|
||||
{user?.email && user?.name && (
|
||||
<div className="text-xs text-slate-500 truncate">{user.email}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" /> Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ─── Main column ───────────────────────────────────────────────── */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top bar (mobile) */}
|
||||
<header className="lg:hidden sticky top-0 z-40 h-16 border-b border-emerald-100/60 bg-white/80 backdrop-blur-xl flex items-center justify-between px-4">
|
||||
<Link href="/dashboard">
|
||||
<a className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
|
||||
<Stethoscope className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold gradient-text">QueueMed</span>
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="p-2 rounded-lg text-slate-600 hover:bg-emerald-50"
|
||||
aria-label="Menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-72 bg-white shadow-2xl flex flex-col">
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-emerald-100/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
|
||||
<Stethoscope className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold gradient-text">QueueMed</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg text-slate-600 hover:bg-slate-100">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<a
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
|
||||
active
|
||||
? "bg-gradient-to-r from-emerald-500 to-cyan-500 text-white shadow-md"
|
||||
: "text-slate-600 hover:bg-emerald-50 hover:text-emerald-700"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-3 border-t border-emerald-100/60">
|
||||
<div className="px-3 py-2 mb-2 text-sm">
|
||||
<div className="font-semibold text-slate-900 truncate">{user?.name ?? user?.email ?? "—"}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setMobileOpen(false); logout(); }}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-red-50 hover:text-red-600 border border-slate-200"
|
||||
>
|
||||
<LogOut className="w-4 h-4" /> Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
client/src/components/PhoneDialCodePicker.tsx
Normal file
242
client/src/components/PhoneDialCodePicker.tsx
Normal file
|
|
@ -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<CountryCode | null>(null);
|
||||
const [localNumber, setLocalNumber] = useState("");
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const dropdownRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Chargement…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (countries.length === 0) {
|
||||
return (
|
||||
<input
|
||||
type="tel"
|
||||
value={localNumber}
|
||||
onChange={(e) => 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 (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
<div className="relative flex gap-2" ref={dropdownRef}>
|
||||
{/* Dial code selector */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 ? (
|
||||
<>
|
||||
<span className="text-base">{selectedCode.flag}</span>
|
||||
<span className="font-medium">+{selectedCode.dialCode}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Pays</span>
|
||||
)}
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground ml-auto transition-transform ${dropdownOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{/* Local number input */}
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="tel"
|
||||
value={localNumber}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{isValid ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{dropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 w-72 bg-popover border border-border rounded-xl shadow-xl overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-border/50">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country list */}
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Aucun résultat</p>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<button
|
||||
key={c.code}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl shrink-0">{c.flag}</span>
|
||||
<span className="text-sm text-foreground flex-1 truncate">{c.nameFr}</span>
|
||||
<span className="text-sm text-muted-foreground shrink-0">+{c.dialCode}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation message or hint */}
|
||||
{showError ? (
|
||||
<p className="text-xs text-red-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
{validationError}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
client/src/components/WhatsAppTemplateEditor.tsx
Normal file
352
client/src/components/WhatsAppTemplateEditor.tsx
Normal file
|
|
@ -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<Record<TemplateType, string>>({
|
||||
joined: "",
|
||||
soon: "",
|
||||
called: "",
|
||||
withdrawn: "",
|
||||
});
|
||||
|
||||
// Track which templates have been modified
|
||||
const [modified, setModified] = useState<Record<TemplateType, boolean>>({
|
||||
joined: false,
|
||||
soon: false,
|
||||
called: false,
|
||||
withdrawn: false,
|
||||
});
|
||||
|
||||
// Preview toggle per template
|
||||
const [showPreview, setShowPreview] = useState<Record<TemplateType, boolean>>({
|
||||
joined: false,
|
||||
soon: false,
|
||||
called: false,
|
||||
withdrawn: false,
|
||||
});
|
||||
|
||||
// Active tab
|
||||
const [activeTab, setActiveTab] = useState<TemplateType>("joined");
|
||||
|
||||
// Refs for textareas
|
||||
const textareaRefs = useRef<Record<TemplateType, HTMLTextAreaElement | null>>({
|
||||
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<string, string | null> = { 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 (
|
||||
<span key={i} className="bg-teal-500/20 text-teal-400 rounded px-1 font-mono text-sm">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-1/3" />
|
||||
<div className="h-32 bg-muted rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const activeLabel = TEMPLATE_LABELS[activeTab];
|
||||
|
||||
return (
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<MessageSquare className="h-5 w-5 text-teal-400" />
|
||||
Modèles de messages WhatsApp
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Personnalisez les messages envoyés automatiquement aux patients. Cliquez sur une variable pour l'insérer.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{hasAnyModification && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="bg-teal-600 hover:bg-teal-700"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{updateMutation.isPending ? "Sauvegarde..." : "Sauvegarder"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Variables reference */}
|
||||
<div className="bg-muted/30 rounded-lg p-4 border border-border/30">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||
<Sparkles className="h-4 w-4 text-amber-400" />
|
||||
Variables disponibles
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
onClick={() => 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}
|
||||
>
|
||||
<code className="text-xs font-mono text-teal-400">{v.key}</code>
|
||||
<span className="text-xs text-muted-foreground">{v.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template tabs */}
|
||||
<div className="flex gap-1 p-1 bg-muted/30 rounded-lg border border-border/30">
|
||||
{templateTypes.map((type) => {
|
||||
const label = TEMPLATE_LABELS[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<span>{label.icon}</span>
|
||||
<span className="hidden sm:inline">{label.title}</span>
|
||||
{modified[type] && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active template editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{activeLabel.icon} {activeLabel.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{activeLabel.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => togglePreview(activeTab)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showPreview[activeTab] ? (
|
||||
<><EyeOff className="h-3.5 w-3.5 mr-1" /> Éditer</>
|
||||
) : (
|
||||
<><Eye className="h-3.5 w-3.5 mr-1" /> Aperçu</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetToDefault(activeTab)}
|
||||
className="text-xs"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" /> Par défaut
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPreview[activeTab] ? (
|
||||
/* Preview mode – WhatsApp-style bubble */
|
||||
<div className="bg-[#0b141a] rounded-xl p-4 border border-border/30">
|
||||
<div className="max-w-sm ml-auto">
|
||||
<div className="bg-[#005c4b] rounded-lg rounded-tr-none p-3 text-sm text-white/90 whitespace-pre-wrap leading-relaxed">
|
||||
{renderPreview(localTemplates[activeTab])}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1 text-right">
|
||||
Aperçu avec données fictives
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Edit mode */
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={(el) => { textareaRefs.current[activeTab] = el; }}
|
||||
value={localTemplates[activeTab]}
|
||||
onChange={(e) => handleChange(activeTab, e.target.value)}
|
||||
className="min-h-[200px] font-mono text-sm bg-background/50 border-border/50 focus:border-teal-500/50 resize-y"
|
||||
placeholder="Saisissez votre message..."
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{localTemplates[activeTab].length} / 1000 caractères</span>
|
||||
{modified[activeTab] && (
|
||||
<Badge variant="outline" className="text-amber-400 border-amber-400/30">
|
||||
Modifié
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick preview of all templates */}
|
||||
<div className="border-t border-border/30 pt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Aperçu rapide de tous les messages</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{templateTypes.map((type) => {
|
||||
const label = TEMPLATE_LABELS[type];
|
||||
const isCustom = templates && templates[type] !== null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
{label.icon} {label.title}
|
||||
</span>
|
||||
{isCustom ? (
|
||||
<Badge variant="outline" className="text-teal-400 border-teal-400/30 text-[10px]">
|
||||
Personnalisé
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground text-[10px]">
|
||||
Par défaut
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 whitespace-pre-wrap">
|
||||
{localTemplates[type].substring(0, 80)}...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
64
client/src/components/ui/accordion.tsx
Normal file
64
client/src/components/ui/accordion.tsx
Normal file
|
|
@ -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<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
155
client/src/components/ui/alert-dialog.tsx
Normal file
155
client/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -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<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
66
client/src/components/ui/alert.tsx
Normal file
66
client/src/components/ui/alert.tsx
Normal file
|
|
@ -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<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
9
client/src/components/ui/aspect-ratio.tsx
Normal file
9
client/src/components/ui/aspect-ratio.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
51
client/src/components/ui/avatar.tsx
Normal file
51
client/src/components/ui/avatar.tsx
Normal file
|
|
@ -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<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
46
client/src/components/ui/badge.tsx
Normal file
46
client/src/components/ui/badge.tsx
Normal file
|
|
@ -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<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
109
client/src/components/ui/breadcrumb.tsx
Normal file
109
client/src/components/ui/breadcrumb.tsx
Normal file
|
|
@ -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 <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
83
client/src/components/ui/button-group.tsx
Normal file
83
client/src/components/ui/button-group.tsx
Normal file
|
|
@ -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<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
};
|
||||
60
client/src/components/ui/button.tsx
Normal file
60
client/src/components/ui/button.tsx
Normal file
|
|
@ -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<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
211
client/src/components/ui/calendar.tsx
Normal file
211
client/src/components/ui/calendar.tsx
Normal file
|
|
@ -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<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>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 (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
92
client/src/components/ui/card.tsx
Normal file
92
client/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
239
client/src/components/ui/carousel.tsx
Normal file
239
client/src/components/ui/carousel.tsx
Normal file
|
|
@ -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<typeof useEmblaCarousel>;
|
||||
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<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
355
client/src/components/ui/chart.tsx
Normal file
355
client/src/components/ui/chart.tsx
Normal file
|
|
@ -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<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
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 (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter(item => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter(item => item.type !== "none")
|
||||
.map(item => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
30
client/src/components/ui/checkbox.tsx
Normal file
30
client/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
31
client/src/components/ui/collapsible.tsx
Normal file
31
client/src/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
184
client/src/components/ui/command.tsx
Normal file
184
client/src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
250
client/src/components/ui/context-menu.tsx
Normal file
250
client/src/components/ui/context-menu.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
209
client/src/components/ui/dialog.tsx
Normal file
209
client/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
// Context to track composition state across dialog children
|
||||
const DialogCompositionContext = React.createContext<{
|
||||
isComposing: () => boolean;
|
||||
setComposing: (composing: boolean) => void;
|
||||
justEndedComposing: () => boolean;
|
||||
markCompositionEnd: () => void;
|
||||
}>({
|
||||
isComposing: () => false,
|
||||
setComposing: () => {},
|
||||
justEndedComposing: () => false,
|
||||
markCompositionEnd: () => {},
|
||||
});
|
||||
|
||||
export const useDialogComposition = () =>
|
||||
React.useContext(DialogCompositionContext);
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
const composingRef = React.useRef(false);
|
||||
const justEndedRef = React.useRef(false);
|
||||
const endTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
isComposing: () => composingRef.current,
|
||||
setComposing: (composing: boolean) => {
|
||||
composingRef.current = composing;
|
||||
},
|
||||
justEndedComposing: () => justEndedRef.current,
|
||||
markCompositionEnd: () => {
|
||||
justEndedRef.current = true;
|
||||
if (endTimerRef.current) {
|
||||
clearTimeout(endTimerRef.current);
|
||||
}
|
||||
endTimerRef.current = setTimeout(() => {
|
||||
justEndedRef.current = false;
|
||||
}, 150);
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogCompositionContext.Provider value={contextValue}>
|
||||
<DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
</DialogCompositionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
onEscapeKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { isComposing } = useDialogComposition();
|
||||
|
||||
const handleEscapeKeyDown = React.useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Check both the native isComposing property and our context state
|
||||
// This handles Safari's timing issues with composition events
|
||||
const isCurrentlyComposing = (e as any).isComposing || isComposing();
|
||||
|
||||
// If IME is composing, prevent dialog from closing
|
||||
if (isCurrentlyComposing) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Call user's onEscapeKeyDown if provided
|
||||
onEscapeKeyDown?.(e);
|
||||
},
|
||||
[isComposing, onEscapeKeyDown]
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
};
|
||||
|
||||
133
client/src/components/ui/drawer.tsx
Normal file
133
client/src/components/ui/drawer.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
255
client/src/components/ui/dropdown-menu.tsx
Normal file
255
client/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
104
client/src/components/ui/empty.tsx
Normal file
104
client/src/components/ui/empty.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
};
|
||||
242
client/src/components/ui/field.tsx
Normal file
242
client/src/components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { useMemo } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (errors?.length === 1 && errors[0]?.message) {
|
||||
return errors[0].message;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
};
|
||||
168
client/src/components/ui/form.tsx
Normal file
168
client/src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
42
client/src/components/ui/hover-card.tsx
Normal file
42
client/src/components/ui/hover-card.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
168
client/src/components/ui/input-group.tsx
Normal file
168
client/src/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
};
|
||||
75
client/src/components/ui/input-otp.tsx
Normal file
75
client/src/components/ui/input-otp.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
70
client/src/components/ui/input.tsx
Normal file
70
client/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useDialogComposition } from "@/components/ui/dialog";
|
||||
import { useComposition } from "@/hooks/useComposition";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
// Get dialog composition context if available (will be no-op if not inside Dialog)
|
||||
const dialogComposition = useDialogComposition();
|
||||
|
||||
// Add composition event handlers to support input method editor (IME) for CJK languages.
|
||||
const {
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
onKeyDown: handleKeyDown,
|
||||
} = useComposition<HTMLInputElement>({
|
||||
onKeyDown: (e) => {
|
||||
// Check if this is an Enter key that should be blocked
|
||||
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
|
||||
|
||||
// If Enter key is pressed while composing or just after composition ended,
|
||||
// don't call the user's onKeyDown (this blocks the business logic)
|
||||
if (e.key === "Enter" && isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, call the user's onKeyDown
|
||||
onKeyDown?.(e);
|
||||
},
|
||||
onCompositionStart: e => {
|
||||
dialogComposition.setComposing(true);
|
||||
onCompositionStart?.(e);
|
||||
},
|
||||
onCompositionEnd: e => {
|
||||
// Mark that composition just ended - this helps handle the Enter key that confirms input
|
||||
dialogComposition.markCompositionEnd();
|
||||
// Delay setting composing to false to handle Safari's event order
|
||||
// In Safari, compositionEnd fires before the ESC keydown event
|
||||
setTimeout(() => {
|
||||
dialogComposition.setComposing(false);
|
||||
}, 100);
|
||||
onCompositionEnd?.(e);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
193
client/src/components/ui/item.tsx
Normal file
193
client/src/components/ui/item.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "p-4 gap-4 ",
|
||||
sm: "py-3 px-4 gap-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
};
|
||||
28
client/src/components/ui/kbd.tsx
Normal file
28
client/src/components/ui/kbd.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
22
client/src/components/ui/label.tsx
Normal file
22
client/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
274
client/src/components/ui/menubar.tsx
Normal file
274
client/src/components/ui/menubar.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
};
|
||||
168
client/src/components/ui/navigation-menu.tsx
Normal file
168
client/src/components/ui/navigation-menu.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
127
client/src/components/ui/pagination.tsx
Normal file
127
client/src/components/ui/pagination.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
46
client/src/components/ui/popover.tsx
Normal file
46
client/src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
29
client/src/components/ui/progress.tsx
Normal file
29
client/src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
43
client/src/components/ui/radio-group.tsx
Normal file
43
client/src/components/ui/radio-group.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
54
client/src/components/ui/resizable.tsx
Normal file
54
client/src/components/ui/resizable.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
56
client/src/components/ui/scroll-area.tsx
Normal file
56
client/src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
185
client/src/components/ui/select.tsx
Normal file
185
client/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
26
client/src/components/ui/separator.tsx
Normal file
26
client/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
139
client/src/components/ui/sheet.tsx
Normal file
139
client/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
734
client/src/components/ui/sidebar.tsx
Normal file
734
client/src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
disableTransition = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
disableTransition?: boolean;
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent",
|
||||
disableTransition
|
||||
? "transition-none"
|
||||
: "transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||
disableTransition
|
||||
? "transition-none"
|
||||
: "transition-[left,right,width] duration-200 ease-linear",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={event => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
};
|
||||
|
||||
13
client/src/components/ui/skeleton.tsx
Normal file
13
client/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
61
client/src/components/ui/slider.tsx
Normal file
61
client/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
23
client/src/components/ui/sonner.tsx
Normal file
23
client/src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
16
client/src/components/ui/spinner.tsx
Normal file
16
client/src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
29
client/src/components/ui/switch.tsx
Normal file
29
client/src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
114
client/src/components/ui/table.tsx
Normal file
114
client/src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
64
client/src/components/ui/tabs.tsx
Normal file
64
client/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
67
client/src/components/ui/textarea.tsx
Normal file
67
client/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useDialogComposition } from "@/components/ui/dialog";
|
||||
import { useComposition } from "@/hooks/useComposition";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
|
||||
function Textarea({
|
||||
className,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
// Get dialog composition context if available (will be no-op if not inside Dialog)
|
||||
const dialogComposition = useDialogComposition();
|
||||
|
||||
// Add composition event handlers to support input method editor (IME) for CJK languages.
|
||||
const {
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
onKeyDown: handleKeyDown,
|
||||
} = useComposition<HTMLTextAreaElement>({
|
||||
onKeyDown: (e) => {
|
||||
// Check if this is an Enter key that should be blocked
|
||||
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
|
||||
|
||||
// If Enter key is pressed while composing or just after composition ended,
|
||||
// don't call the user's onKeyDown (this blocks the business logic)
|
||||
// Note: For textarea, Shift+Enter should still work for newlines
|
||||
if (e.key === "Enter" && !e.shiftKey && isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, call the user's onKeyDown
|
||||
onKeyDown?.(e);
|
||||
},
|
||||
onCompositionStart: e => {
|
||||
dialogComposition.setComposing(true);
|
||||
onCompositionStart?.(e);
|
||||
},
|
||||
onCompositionEnd: e => {
|
||||
// Mark that composition just ended - this helps handle the Enter key that confirms input
|
||||
dialogComposition.markCompositionEnd();
|
||||
// Delay setting composing to false to handle Safari's event order
|
||||
// In Safari, compositionEnd fires before the ESC keydown event
|
||||
setTimeout(() => {
|
||||
dialogComposition.setComposing(false);
|
||||
}, 100);
|
||||
onCompositionEnd?.(e);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
25
client/src/components/ui/toast.tsx
Normal file
25
client/src/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Toaster as SonnerToaster } from "sonner";
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-right"
|
||||
richColors
|
||||
closeButton
|
||||
expand
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-white/95 group-[.toaster]:backdrop-blur-md group-[.toaster]:text-slate-900 group-[.toaster]:border-slate-200 group-[.toaster]:shadow-xl group-[.toaster]:rounded-xl",
|
||||
description: "group-[.toast]:text-slate-500",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-teal-600 group-[.toast]:text-white",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-slate-100 group-[.toast]:text-slate-700",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { toast } from "sonner";
|
||||
73
client/src/components/ui/toggle-group.tsx
Normal file
73
client/src/components/ui/toggle-group.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
45
client/src/components/ui/toggle.tsx
Normal file
45
client/src/components/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
59
client/src/components/ui/tooltip.tsx
Normal file
59
client/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
81
client/src/hooks/useComposition.ts
Normal file
81
client/src/hooks/useComposition.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useRef } from "react";
|
||||
import { usePersistFn } from "./usePersistFn";
|
||||
|
||||
export interface UseCompositionReturn<
|
||||
T extends HTMLInputElement | HTMLTextAreaElement,
|
||||
> {
|
||||
onCompositionStart: React.CompositionEventHandler<T>;
|
||||
onCompositionEnd: React.CompositionEventHandler<T>;
|
||||
onKeyDown: React.KeyboardEventHandler<T>;
|
||||
isComposing: () => boolean;
|
||||
}
|
||||
|
||||
export interface UseCompositionOptions<
|
||||
T extends HTMLInputElement | HTMLTextAreaElement,
|
||||
> {
|
||||
onKeyDown?: React.KeyboardEventHandler<T>;
|
||||
onCompositionStart?: React.CompositionEventHandler<T>;
|
||||
onCompositionEnd?: React.CompositionEventHandler<T>;
|
||||
}
|
||||
|
||||
type TimerResponse = ReturnType<typeof setTimeout>;
|
||||
|
||||
export function useComposition<
|
||||
T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement,
|
||||
>(options: UseCompositionOptions<T> = {}): UseCompositionReturn<T> {
|
||||
const {
|
||||
onKeyDown: originalOnKeyDown,
|
||||
onCompositionStart: originalOnCompositionStart,
|
||||
onCompositionEnd: originalOnCompositionEnd,
|
||||
} = options;
|
||||
|
||||
const c = useRef(false);
|
||||
const timer = useRef<TimerResponse | null>(null);
|
||||
const timer2 = useRef<TimerResponse | null>(null);
|
||||
|
||||
const onCompositionStart = usePersistFn((e: React.CompositionEvent<T>) => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
if (timer2.current) {
|
||||
clearTimeout(timer2.current);
|
||||
timer2.current = null;
|
||||
}
|
||||
c.current = true;
|
||||
originalOnCompositionStart?.(e);
|
||||
});
|
||||
|
||||
const onCompositionEnd = usePersistFn((e: React.CompositionEvent<T>) => {
|
||||
// 使用两层 setTimeout 来处理 Safari 浏览器中 compositionEnd 先于 onKeyDown 触发的问题
|
||||
timer.current = setTimeout(() => {
|
||||
timer2.current = setTimeout(() => {
|
||||
c.current = false;
|
||||
});
|
||||
});
|
||||
originalOnCompositionEnd?.(e);
|
||||
});
|
||||
|
||||
const onKeyDown = usePersistFn((e: React.KeyboardEvent<T>) => {
|
||||
// 在 composition 状态下,阻止 ESC 和 Enter(非 shift+Enter)事件的冒泡
|
||||
if (
|
||||
c.current &&
|
||||
(e.key === "Escape" || (e.key === "Enter" && !e.shiftKey))
|
||||
) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
originalOnKeyDown?.(e);
|
||||
});
|
||||
|
||||
const isComposing = usePersistFn(() => {
|
||||
return c.current;
|
||||
});
|
||||
|
||||
return {
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
onKeyDown,
|
||||
isComposing,
|
||||
};
|
||||
}
|
||||
21
client/src/hooks/useMobile.tsx
Normal file
21
client/src/hooks/useMobile.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
20
client/src/hooks/usePersistFn.ts
Normal file
20
client/src/hooks/usePersistFn.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useRef } from "react";
|
||||
|
||||
type noop = (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* usePersistFn instead of useCallback to reduce cognitive load
|
||||
*/
|
||||
export function usePersistFn<T extends noop>(fn: T) {
|
||||
const fnRef = useRef<T>(fn);
|
||||
fnRef.current = fn;
|
||||
|
||||
const persistFn = useRef<T>(null);
|
||||
if (!persistFn.current) {
|
||||
persistFn.current = function (this: unknown, ...args) {
|
||||
return fnRef.current!.apply(this, args);
|
||||
} as T;
|
||||
}
|
||||
|
||||
return persistFn.current!;
|
||||
}
|
||||
22
client/src/lib/socket.ts
Normal file
22
client/src/lib/socket.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { io, type Socket } from "socket.io-client";
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io("/", {
|
||||
path: "/socket.io",
|
||||
transports: ["websocket", "polling"],
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function disconnectSocket(): void {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
20
client/src/lib/trpc.ts
Normal file
20
client/src/lib/trpc.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createTRPCReact, httpBatchLink } from "@trpc/react-query";
|
||||
import type { AppRouter } from "../../../server/routers";
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
|
||||
export function getBaseUrl(): string {
|
||||
if (typeof window !== "undefined") return "";
|
||||
return `http://localhost:${process.env.PORT ?? 5000}`;
|
||||
}
|
||||
|
||||
export const trpcClientConfig = {
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
fetch(url, options) {
|
||||
return fetch(url, { ...options, credentials: "include" });
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
26
client/src/lib/utils.ts
Normal file
26
client/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatTime(date: Date | string | null | undefined): string {
|
||||
if (!date) return "—";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "—";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
export function formatTicket(n: number): string {
|
||||
return String(n).padStart(3, "0");
|
||||
}
|
||||
|
||||
export function pluralize(n: number, singular: string, plural?: string): string {
|
||||
return n === 1 ? singular : plural ?? `${singular}s`;
|
||||
}
|
||||
35
client/src/main.tsx
Normal file
35
client/src/main.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { trpc, trpcClientConfig } from "@/lib/trpc";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
// Eagerly initialize the socket connection so it's ready when pages mount.
|
||||
getSocket();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const trpcClient = trpc.createClient(trpcClientConfig);
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Root element #root not found");
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
273
client/src/pages/Analytics.tsx
Normal file
273
client/src/pages/Analytics.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
BarChart3, Users, Clock, Activity, Sparkles, Download, Loader2,
|
||||
TrendingUp, Calendar,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
|
||||
LineChart, Line, AreaChart, Area, Cell, PieChart, Pie,
|
||||
} from "recharts";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const DAY_NAMES = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
|
||||
const PIE_COLORS = ["#10b981", "#06b6d4", "#0d9488", "#22d3ee", "#34d399", "#0891b2", "#14b8a6"];
|
||||
|
||||
export default function Analytics() {
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const [clinicId, setClinicId] = useState<number | undefined>(undefined);
|
||||
|
||||
const clinicsQuery = trpc.clinic.list.useQuery();
|
||||
const summaryQuery = trpc.analytics.summary.useQuery({ days, clinicId });
|
||||
|
||||
const clinics = clinicsQuery.data ?? [];
|
||||
const summary = summaryQuery.data;
|
||||
|
||||
const exportCsv = trpc.analytics.exportCsv.useQuery(
|
||||
{ clinicId: clinicId ?? clinics[0]?.id ?? 0, days },
|
||||
{ enabled: false }
|
||||
);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!clinicId && !clinics[0]) {
|
||||
toast.error("Aucun cabinet sélectionné");
|
||||
return;
|
||||
}
|
||||
const result = await exportCsv.refetch();
|
||||
if (!result.data) {
|
||||
toast.error("Export échoué");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([result.data.csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = result.data.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("CSV exporté");
|
||||
};
|
||||
|
||||
const hourData = (summary?.byHour ?? []).map((count, hour) => ({ hour: `${hour}h`, count }));
|
||||
const dayData = (summary?.byDay ?? []).map((count, dow) => ({ day: DAY_NAMES[dow], count }));
|
||||
|
||||
const flowData = [
|
||||
{ name: "Joints", value: summary?.totalJoined ?? 0 },
|
||||
{ name: "Servis", value: summary?.totalServed ?? 0 },
|
||||
{ name: "Absents", value: summary?.totalAbsent ?? 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="font-bold text-3xl mb-1">Analytics</h1>
|
||||
<p className="text-slate-600">Affluence, temps d'attente et recommandations IA.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleExport} disabled={exportCsv.isFetching}>
|
||||
{exportCsv.isFetching ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Download className="w-4 h-4 mr-2" />}
|
||||
Exporter CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="glass-card rounded-2xl p-4 mb-6 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">Période</span>
|
||||
{[7, 30, 90, 365].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
||||
days === d
|
||||
? "bg-teal-600 text-white border-teal-600"
|
||||
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{d} jours
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest">Cabinet</span>
|
||||
<select
|
||||
value={clinicId ?? ""}
|
||||
onChange={(e) => setClinicId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm focus:outline-none focus:border-emerald-400"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
{clinics.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summaryQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
{[
|
||||
{ label: "Patients joints", value: summary?.totalJoined ?? 0, icon: Users, color: "from-emerald-500 to-teal-500" },
|
||||
{ label: "Servis", value: summary?.totalServed ?? 0, icon: Activity, color: "from-cyan-500 to-blue-500" },
|
||||
{ label: "Absents", value: summary?.totalAbsent ?? 0, icon: Calendar, color: "from-orange-500 to-amber-500" },
|
||||
{ label: "Attente moy.", value: `${summary?.avgWaitMinutes ?? 0} min`, icon: Clock, color: "from-violet-500 to-purple-500" },
|
||||
{ label: "Cons. moy.", value: `${summary?.avgConsultationMinutes ?? 0} min`, icon: TrendingUp, color: "from-pink-500 to-rose-500" },
|
||||
].map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<div key={s.label} className="glass-card rounded-2xl p-4">
|
||||
<div className={`w-9 h-9 rounded-xl bg-gradient-to-br ${s.color} flex items-center justify-center mb-3 shadow-md`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="font-bold text-xl text-slate-900">{s.value}</div>
|
||||
<div className="text-xs text-slate-500">{s.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{summary && summary.recommendations.length > 0 && (
|
||||
<div className="glass-card rounded-2xl p-6 mb-6 border-2 border-emerald-200/60">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md flex-shrink-0">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-lg">Recommandations IA</h2>
|
||||
<p className="text-slate-500 text-sm">Optimisations identifiées sur la période sélectionnée.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{summary.recommendations.map((r, i) => (
|
||||
<li key={i} className="flex items-start gap-3 p-3 rounded-xl bg-emerald-50/60 border border-emerald-100">
|
||||
<span className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">{i + 1}</span>
|
||||
<span className="text-sm text-slate-700">{r}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-emerald-600" />
|
||||
Affluence par heure
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={hourData}>
|
||||
<defs>
|
||||
<linearGradient id="barGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#10b981" stopOpacity={0.95} />
|
||||
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0.85} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||
<XAxis dataKey="hour" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)", backdropFilter: "blur(8px)" }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[6, 6, 0, 0]} fill="url(#barGrad)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
{summary && summary.peakHour >= 0 && (
|
||||
<p className="text-xs text-slate-500 mt-3">
|
||||
Pic d'affluence : <strong className="text-emerald-700">{summary.peakHour}h</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-cyan-600" />
|
||||
Affluence par jour
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={dayData}>
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||
<XAxis dataKey="day" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="count" stroke="#06b6d4" strokeWidth={2.5} fill="url(#areaGrad)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
{summary && summary.peakDay >= 0 && (
|
||||
<p className="text-xs text-slate-500 mt-3">
|
||||
Jour le plus chargé : <strong className="text-cyan-700">{DAY_NAMES[summary.peakDay]}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-violet-600" />
|
||||
Flux patients
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={flowData}
|
||||
cx="50%" cy="50%"
|
||||
innerRadius={60} outerRadius={100}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
label={(entry) => entry.name}
|
||||
>
|
||||
{flowData.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-orange-600" />
|
||||
Temps d'attente moyen
|
||||
</h3>
|
||||
<div className="flex items-center justify-center h-[280px]">
|
||||
<div className="text-center">
|
||||
<div className="font-black text-7xl gradient-text mb-2">{summary?.avgWaitMinutes ?? 0}</div>
|
||||
<div className="text-slate-500 text-sm">minutes en moyenne</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-xl bg-emerald-50 border border-emerald-100">
|
||||
<div className="text-xs text-emerald-700 uppercase font-bold">Consultation</div>
|
||||
<div className="font-bold text-emerald-900 text-2xl mt-1">{summary?.avgConsultationMinutes ?? 0} min</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-cyan-50 border border-cyan-100">
|
||||
<div className="text-xs text-cyan-700 uppercase font-bold">Total</div>
|
||||
<div className="font-bold text-cyan-900 text-2xl mt-1">{(summary?.avgWaitMinutes ?? 0) + (summary?.avgConsultationMinutes ?? 0)} min</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
client/src/pages/ClinicSettings.tsx
Normal file
340
client/src/pages/ClinicSettings.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
/**
|
||||
* ClinicSettings — Paramètres enrichis du cabinet
|
||||
* Message de bienvenue, horaires d'ouverture, langue patient, timer absent, etc.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Settings, Clock, Globe, MessageSquare, Timer, Users, Save,
|
||||
Loader2, ChevronDown, ChevronUp, Stethoscope
|
||||
} from "lucide-react";
|
||||
|
||||
const DAYS = [
|
||||
{ key: "mon", label: "Lundi" },
|
||||
{ key: "tue", label: "Mardi" },
|
||||
{ key: "wed", label: "Mercredi" },
|
||||
{ key: "thu", label: "Jeudi" },
|
||||
{ key: "fri", label: "Vendredi" },
|
||||
{ key: "sat", label: "Samedi" },
|
||||
{ key: "sun", label: "Dimanche" },
|
||||
];
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "fr", label: "Français", flag: "🇫🇷" },
|
||||
{ code: "en", label: "English", flag: "🇬🇧" },
|
||||
{ code: "pt", label: "Português", flag: "🇧🇷" },
|
||||
{ code: "es", label: "Español", flag: "🇪🇸" },
|
||||
{ code: "ar", label: "العربية", flag: "🇸🇦" },
|
||||
];
|
||||
|
||||
type DaySchedule = { open: string; close: string; closed: boolean };
|
||||
type OpeningHours = Record<string, DaySchedule>;
|
||||
|
||||
const DEFAULT_HOURS: OpeningHours = {
|
||||
mon: { open: "08:00", close: "18:00", closed: false },
|
||||
tue: { open: "08:00", close: "18:00", closed: false },
|
||||
wed: { open: "08:00", close: "18:00", closed: false },
|
||||
thu: { open: "08:00", close: "18:00", closed: false },
|
||||
fri: { open: "08:00", close: "18:00", closed: false },
|
||||
sat: { open: "09:00", close: "12:00", closed: true },
|
||||
sun: { open: "09:00", close: "12:00", closed: true },
|
||||
};
|
||||
|
||||
export default function ClinicSettings() {
|
||||
const { user } = useAuth();
|
||||
const [selectedClinicId, setSelectedClinicId] = useState<number | null>(null);
|
||||
const [welcomeMessage, setWelcomeMessage] = useState("");
|
||||
const [openingHours, setOpeningHours] = useState<OpeningHours>(DEFAULT_HOURS);
|
||||
const [patientLanguage, setPatientLanguage] = useState("fr");
|
||||
const [autoAbsentMinutes, setAutoAbsentMinutes] = useState(0);
|
||||
const [avgConsultationMinutes, setAvgConsultationMinutes] = useState(15);
|
||||
const [maxQueueSize, setMaxQueueSize] = useState(50);
|
||||
const [showHours, setShowHours] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const { data: clinicsList = [] } = trpc.clinic.list.useQuery(undefined, { enabled: !!user });
|
||||
const clinicId = selectedClinicId ?? clinicsList[0]?.id ?? 0;
|
||||
|
||||
const { data: settings, isLoading } = trpc.clinicSettings.get.useQuery(
|
||||
{ clinicId },
|
||||
{ enabled: clinicId > 0 }
|
||||
);
|
||||
|
||||
const updateMutation = trpc.clinicSettings.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Paramètres sauvegardés");
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// Load settings into form when data arrives
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setWelcomeMessage(settings.welcomeMessage ?? "");
|
||||
setOpeningHours(settings.openingHours ?? DEFAULT_HOURS);
|
||||
setPatientLanguage(settings.patientLanguage ?? "fr");
|
||||
setAutoAbsentMinutes(settings.autoAbsentMinutes ?? 0);
|
||||
setAvgConsultationMinutes(settings.avgConsultationMinutes ?? 15);
|
||||
setMaxQueueSize(settings.maxQueueSize ?? 50);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (clinicId <= 0) return;
|
||||
updateMutation.mutate({
|
||||
clinicId,
|
||||
welcomeMessage,
|
||||
openingHours,
|
||||
patientLanguage: patientLanguage as "fr" | "en" | "ar" | "pt" | "es",
|
||||
autoAbsentMinutes,
|
||||
avgConsultationMinutes,
|
||||
maxQueueSize,
|
||||
});
|
||||
};
|
||||
|
||||
const updateHours = (dayKey: string, field: keyof DaySchedule, value: string | boolean) => {
|
||||
setOpeningHours((prev) => ({
|
||||
...prev,
|
||||
[dayKey]: { ...prev[dayKey], [field]: value },
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-emerald-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-display font-bold text-foreground">Paramètres du cabinet</h1>
|
||||
<p className="text-sm text-muted-foreground">Personnalisez l'expérience patient et la gestion de la file</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clinicsList.length > 1 && (
|
||||
<select
|
||||
value={clinicId}
|
||||
onChange={(e) => setSelectedClinicId(Number(e.target.value))}
|
||||
className="bg-background/50 border border-border rounded-xl px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
{clinicsList.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-emerald-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Message de bienvenue */}
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-emerald-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Message de bienvenue</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Affiché aux patients lorsqu'ils rejoignent la file d'attente. Laissez vide pour ne pas afficher de message.
|
||||
</p>
|
||||
<textarea
|
||||
value={welcomeMessage}
|
||||
onChange={(e) => { setWelcomeMessage(e.target.value); setHasChanges(true); }}
|
||||
placeholder="Ex: Bienvenue au cabinet du Dr Martin. Merci de patienter, nous vous appellerons dès que possible."
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 resize-none focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">{welcomeMessage.length}/500</p>
|
||||
</div>
|
||||
|
||||
{/* Langue patient */}
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-blue-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Langue de l'interface patient</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Langue affichée sur l'écran du patient et dans les messages WhatsApp.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => { setPatientLanguage(lang.code); setHasChanges(true); }}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl border text-sm transition-all ${
|
||||
patientLanguage === lang.code
|
||||
? "border-emerald-500/50 bg-emerald-500/10 text-emerald-300"
|
||||
: "border-border/50 bg-background/30 text-muted-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horaires d'ouverture */}
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-3">
|
||||
<button
|
||||
onClick={() => setShowHours(!showHours)}
|
||||
className="flex items-center justify-between w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Horaires d'ouverture</h2>
|
||||
</div>
|
||||
{showHours ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Affichés aux patients sur la page de la file d'attente.
|
||||
</p>
|
||||
|
||||
{showHours && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{DAYS.map(({ key, label }) => {
|
||||
const day = openingHours[key] ?? { open: "08:00", close: "18:00", closed: false };
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 py-1.5">
|
||||
<div className="w-24 text-sm text-foreground">{label}</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!day.closed}
|
||||
onChange={(e) => updateHours(key, "closed", !e.target.checked)}
|
||||
className="rounded border-border accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{day.closed ? "Fermé" : "Ouvert"}</span>
|
||||
</label>
|
||||
{!day.closed && (
|
||||
<>
|
||||
<input
|
||||
type="time"
|
||||
value={day.open}
|
||||
onChange={(e) => updateHours(key, "open", e.target.value)}
|
||||
className="bg-background/50 border border-border/50 rounded px-2 py-1 text-sm text-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">à</span>
|
||||
<input
|
||||
type="time"
|
||||
value={day.close}
|
||||
onChange={(e) => updateHours(key, "close", e.target.value)}
|
||||
className="bg-background/50 border border-border/50 rounded px-2 py-1 text-sm text-foreground"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paramètres file d'attente */}
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Paramètres de la file d'attente</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Durée moyenne consultation */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Stethoscope className="w-3 h-3" /> Durée moy. consultation
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={avgConsultationMinutes}
|
||||
onChange={(e) => { setAvgConsultationMinutes(Number(e.target.value)); setHasChanges(true); }}
|
||||
className="w-20 bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taille max file */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="w-3 h-3" /> Taille max. file
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={maxQueueSize}
|
||||
onChange={(e) => { setMaxQueueSize(Number(e.target.value)); setHasChanges(true); }}
|
||||
className="w-20 bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">patients</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer absent automatique */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Timer className="w-3 h-3" /> Timer absent auto
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={autoAbsentMinutes}
|
||||
onChange={(e) => { setAutoAbsentMinutes(Number(e.target.value)); setHasChanges(true); }}
|
||||
className="w-20 bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">min</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{autoAbsentMinutes === 0
|
||||
? "Désactivé — le médecin marque manuellement les absents"
|
||||
: `Le patient est marqué absent après ${autoAbsentMinutes} min sans réponse`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateMutation.isPending}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Sauvegarder les paramètres
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
355
client/src/pages/ConsultationHistory.tsx
Normal file
355
client/src/pages/ConsultationHistory.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
/**
|
||||
* ConsultationHistory — Historique des consultations par cabinet
|
||||
* Tableau paginé + filtres date/motif + stats résumées
|
||||
*/
|
||||
import { useState, useMemo } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
History, Calendar, Filter, ChevronLeft, ChevronRight,
|
||||
Users, Clock, CheckCircle2, AlertTriangle, Loader2,
|
||||
Stethoscope, FileText, Briefcase, ClipboardList, HelpCircle, Heart, XCircle
|
||||
} from "lucide-react";
|
||||
|
||||
const VISIT_REASONS: Record<string, { label: string; icon: typeof Stethoscope; color: string }> = {
|
||||
consultation: { label: "Consultation", icon: Stethoscope, color: "text-emerald-400" },
|
||||
urgence: { label: "Urgence", icon: AlertTriangle, color: "text-red-400" },
|
||||
certificat_scolaire: { label: "Certificat scolaire", icon: FileText, color: "text-blue-400" },
|
||||
certificat_sportif: { label: "Certificat sportif", icon: Heart, color: "text-pink-400" },
|
||||
arret_travail: { label: "Arrêt de travail", icon: Briefcase, color: "text-amber-400" },
|
||||
administratif: { label: "Administratif", icon: ClipboardList, color: "text-purple-400" },
|
||||
autre: { label: "Autre", icon: HelpCircle, color: "text-gray-400" },
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
done: { label: "Terminé", color: "bg-emerald-500/20 text-emerald-300" },
|
||||
absent: { label: "Absent", color: "bg-red-500/20 text-red-300" },
|
||||
canceled: { label: "Annulé", color: "bg-gray-500/20 text-gray-300" },
|
||||
};
|
||||
|
||||
export default function ConsultationHistory() {
|
||||
const { user } = useAuth();
|
||||
const [selectedClinicId, setSelectedClinicId] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [filterReason, setFilterReason] = useState("");
|
||||
const [statsDays, setStatsDays] = useState(30);
|
||||
const perPage = 15;
|
||||
|
||||
const { data: clinicsList = [] } = trpc.clinic.list.useQuery(undefined, { enabled: !!user });
|
||||
|
||||
// Auto-select first clinic
|
||||
const clinicId = selectedClinicId ?? clinicsList[0]?.id ?? 0;
|
||||
|
||||
const { data: historyData, isLoading: historyLoading } = trpc.history.list.useQuery(
|
||||
{
|
||||
clinicId,
|
||||
page,
|
||||
perPage,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
visitReason: filterReason || undefined,
|
||||
},
|
||||
{ enabled: clinicId > 0 }
|
||||
);
|
||||
|
||||
const { data: statsData, isLoading: statsLoading } = trpc.history.stats.useQuery(
|
||||
{ clinicId, days: statsDays },
|
||||
{ enabled: clinicId > 0 }
|
||||
);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (!historyData) return 1;
|
||||
return Math.max(1, Math.ceil(Number(historyData.total) / perPage));
|
||||
}, [historyData]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
setFilterReason("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-emerald-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 flex items-center justify-center">
|
||||
<History className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-display font-bold text-foreground">Historique des consultations</h1>
|
||||
<p className="text-sm text-muted-foreground">Consultez l'historique complet de vos patients</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clinic selector */}
|
||||
{clinicsList.length > 1 && (
|
||||
<select
|
||||
value={clinicId}
|
||||
onChange={(e) => { setSelectedClinicId(Number(e.target.value)); setPage(1); }}
|
||||
className="bg-background/50 border border-border rounded-xl px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
{clinicsList.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs text-muted-foreground">Total consultations</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? "—" : statsData?.totalConsultations ?? 0}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<select
|
||||
value={statsDays}
|
||||
onChange={(e) => setStatsDays(Number(e.target.value))}
|
||||
className="text-xs bg-background/50 border border-border/50 rounded px-1.5 py-0.5 text-muted-foreground"
|
||||
>
|
||||
<option value={7}>7 jours</option>
|
||||
<option value={30}>30 jours</option>
|
||||
<option value={90}>90 jours</option>
|
||||
<option value={365}>1 an</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-xs text-muted-foreground">Durée moyenne</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? "—" : `${statsData?.avgDurationMinutes ?? 0} min`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">par consultation</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
<span className="text-xs text-muted-foreground">Taux de présence</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? "—" : `${statsData?.presenceRate ?? 100}%`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">patients présents</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Stethoscope className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-xs text-muted-foreground">Top motif</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground truncate">
|
||||
{statsLoading ? "—" : (
|
||||
statsData?.topReasons?.[0]
|
||||
? VISIT_REASONS[statsData.topReasons[0].reason]?.label ?? statsData.topReasons[0].reason
|
||||
: "—"
|
||||
)}
|
||||
</p>
|
||||
{statsData?.topReasons?.[0] && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{statsData.topReasons[0].count} consultations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top reasons breakdown */}
|
||||
{statsData?.topReasons && statsData.topReasons.length > 1 && (
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Répartition des motifs</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statsData.topReasons.map((r) => {
|
||||
const info = VISIT_REASONS[r.reason];
|
||||
const pct = statsData.totalConsultations > 0
|
||||
? Math.round((r.count / statsData.totalConsultations) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div key={r.reason} className="flex items-center gap-2 bg-background/50 rounded-lg px-3 py-1.5">
|
||||
{info && <info.icon className={`w-3.5 h-3.5 ${info.color}`} />}
|
||||
<span className="text-xs text-foreground">{info?.label ?? r.reason}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.count} ({pct}%)</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">
|
||||
<Calendar className="w-3 h-3 inline mr-1" />Du
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
|
||||
className="bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">
|
||||
<Calendar className="w-3 h-3 inline mr-1" />Au
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
|
||||
className="bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">
|
||||
<Filter className="w-3 h-3 inline mr-1" />Motif
|
||||
</label>
|
||||
<select
|
||||
value={filterReason}
|
||||
onChange={(e) => { setFilterReason(e.target.value); setPage(1); }}
|
||||
className="bg-background/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="">Tous les motifs</option>
|
||||
{Object.entries(VISIT_REASONS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{(dateFrom || dateTo || filterReason) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-muted-foreground">
|
||||
<XCircle className="w-4 h-4 mr-1" /> Effacer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-card/50 border border-border/50 rounded-xl overflow-hidden">
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-emerald-400" />
|
||||
</div>
|
||||
) : !historyData?.entries?.length ? (
|
||||
<div className="text-center py-12">
|
||||
<History className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Aucune consultation trouvée</p>
|
||||
{(dateFrom || dateTo || filterReason) && (
|
||||
<p className="text-xs text-muted-foreground mt-1">Essayez de modifier vos filtres</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-background/30">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Ticket</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Patient</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Motif</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Date</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Attente</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Durée</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyData.entries.map((entry) => {
|
||||
const reasonInfo = VISIT_REASONS[entry.visitReason ?? "consultation"];
|
||||
const statusInfo = STATUS_LABELS[entry.status] ?? { label: entry.status, color: "bg-gray-500/20 text-gray-300" };
|
||||
const waitMin = entry.calledAt && entry.joinedAt
|
||||
? Math.round((new Date(entry.calledAt).getTime() - new Date(entry.joinedAt).getTime()) / 60000)
|
||||
: null;
|
||||
const durationMin = entry.consultationStartedAt && entry.consultationEndAt
|
||||
? Math.round((new Date(entry.consultationEndAt).getTime() - new Date(entry.consultationStartedAt).getTime()) / 60000)
|
||||
: null;
|
||||
const ReasonIcon = reasonInfo?.icon ?? HelpCircle;
|
||||
|
||||
return (
|
||||
<tr key={entry.id} className="border-b border-border/30 hover:bg-background/20 transition-colors">
|
||||
<td className="px-4 py-3 font-mono font-bold text-foreground">#{entry.ticketNumber}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-foreground">{entry.patientName || "Anonyme"}</span>
|
||||
{entry.visitNote && (
|
||||
<p className="text-xs text-muted-foreground italic truncate max-w-[200px]">{entry.visitNote}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs ${reasonInfo?.color ?? "text-gray-400"}`}>
|
||||
<ReasonIcon className="w-3.5 h-3.5" />
|
||||
{reasonInfo?.label ?? entry.visitReason}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(entry.joinedAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
<br />
|
||||
<span className="text-xs">{new Date(entry.joinedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{waitMin !== null ? `${waitMin} min` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{durationMin !== null ? `${durationMin} min` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} sur {totalPages} — {Number(historyData?.total ?? 0)} résultats
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
client/src/pages/Dashboard.tsx
Normal file
199
client/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { useLocation } from "wouter";
|
||||
import {
|
||||
Building2, Users, Clock, CreditCard, ChevronRight, Plus,
|
||||
Sparkles, Activity, BarChart3, HelpCircle, Loader2, TrendingUp,
|
||||
Calendar, AlertTriangle, QrCode,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [, navigate] = useLocation();
|
||||
const { user } = useAuth();
|
||||
|
||||
const clinicsQuery = trpc.clinic.list.useQuery();
|
||||
const subQuery = trpc.subscription.check.useQuery();
|
||||
const summaryQuery = trpc.analytics.summary.useQuery({ days: 7 });
|
||||
|
||||
const clinics = clinicsQuery.data ?? [];
|
||||
const sub = subQuery.data;
|
||||
const summary = summaryQuery.data;
|
||||
|
||||
const isTrialing = sub?.status === "trialing";
|
||||
const trialDaysLeft = sub?.daysRemaining ?? 0;
|
||||
const subExpired = !sub?.active;
|
||||
const greeting = (user?.name?.split(" ")[0] ?? "Docteur").trim();
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
{/* Welcome */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="font-bold text-3xl mb-1">
|
||||
Bonjour, <span className="gradient-text">{greeting}</span>
|
||||
</h1>
|
||||
<p className="text-slate-600">Votre journée commence ici.</p>
|
||||
</div>
|
||||
{isTrialing && (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-xl border text-sm font-semibold flex items-center gap-2",
|
||||
trialDaysLeft > 7
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
||||
: "bg-orange-50 border-orange-200 text-orange-700"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{trialDaysLeft > 0
|
||||
? `Essai gratuit — ${trialDaysLeft} jour${trialDaysLeft > 1 ? "s" : ""} restant${trialDaysLeft > 1 ? "s" : ""}`
|
||||
: "Essai expiré"}
|
||||
{trialDaysLeft <= 7 && (
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/subscription")}
|
||||
className="ml-1 underline underline-offset-2"
|
||||
>
|
||||
S'abonner
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subExpired && !isTrialing && (
|
||||
<div className="px-4 py-2 rounded-xl border border-red-200 bg-red-50 text-red-700 text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Abonnement expiré
|
||||
<button onClick={() => navigate("/dashboard/subscription")} className="ml-1 underline">Renouveler</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{ label: "Cabinets actifs", value: clinics.length, icon: Building2, color: "from-emerald-500 to-teal-500" },
|
||||
{ label: "Patients (7j)", value: summary?.totalServed ?? 0, icon: Users, color: "from-cyan-500 to-blue-500" },
|
||||
{ label: "Attente moy.", value: summary ? `${summary.avgWaitMinutes} min` : "—", icon: Clock, color: "from-orange-500 to-amber-500" },
|
||||
{ label: "Plan", value: sub?.plan ?? "—", icon: CreditCard, color: "from-violet-500 to-fuchsia-500" },
|
||||
].map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.label} className="glass-card rounded-2xl p-5">
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center mb-4 shadow-md`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="font-bold text-2xl text-slate-900 capitalize">{stat.value}</div>
|
||||
<div className="text-slate-500 text-xs mt-1">{stat.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clinics */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-bold text-xl">Vos cabinets</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/dashboard/clinics")}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Gérer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{clinicsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
) : clinics.length === 0 ? (
|
||||
<div className="glass-card rounded-3xl p-10 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center mx-auto mb-4 shadow-lg glow-emerald">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl mb-2">Bienvenue sur QueueMed !</h3>
|
||||
<p className="text-slate-600 text-sm mb-6 max-w-sm mx-auto">
|
||||
Configurez votre premier cabinet en 2 minutes avec notre assistant.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<Button variant="gradient" onClick={() => navigate("/onboarding")}>
|
||||
<Sparkles className="w-4 h-4 mr-2" /> Démarrer la configuration
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate("/dashboard/clinics")}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Créer manuellement
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{clinics.map((clinic) => (
|
||||
<button
|
||||
key={clinic.id}
|
||||
onClick={() => navigate(`/dashboard/queue/${clinic.id}`)}
|
||||
className="glass-card rounded-2xl p-5 text-left hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div
|
||||
className="w-11 h-11 rounded-xl flex items-center justify-center shadow-sm"
|
||||
style={{
|
||||
backgroundColor: `${clinic.color ?? "#10b981"}20`,
|
||||
border: `1px solid ${clinic.color ?? "#10b981"}50`,
|
||||
}}
|
||||
>
|
||||
<Building2 className="w-5 h-5" style={{ color: clinic.color ?? "#0d9488" }} />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2.5 py-0.5 rounded-full text-xs font-semibold border",
|
||||
clinic.isQueueOpen
|
||||
? "bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||
: "bg-slate-100 text-slate-500 border-slate-200"
|
||||
)}
|
||||
>
|
||||
{clinic.isQueueOpen ? "Ouvert" : "Fermé"}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold mb-1 text-slate-900">{clinic.name}</h3>
|
||||
{clinic.address && (
|
||||
<p className="text-slate-500 text-xs mb-3 truncate">{clinic.address}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 text-xs">~{clinic.avgConsultationMinutes ?? 15} min/patient</span>
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-emerald-600 transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div>
|
||||
<h2 className="font-bold text-xl mb-4">Accès rapide</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ icon: BarChart3, label: "Analytics", desc: "Statistiques & IA", path: "/dashboard/analytics", color: "from-pink-500 to-rose-500" },
|
||||
{ icon: TrendingUp, label: "Abonnement", desc: "Gérer votre plan", path: "/dashboard/subscription", color: "from-violet-500 to-purple-500" },
|
||||
{ icon: Activity, label: "Affichage", desc: "Écran salle d'attente", path: clinics[0] ? `/display/${clinics[0].id}` : "/dashboard/clinics", color: "from-cyan-500 to-blue-500" },
|
||||
{ icon: HelpCircle, label: "Aide", desc: "Centre d'aide & FAQ", path: "/help", color: "from-amber-500 to-orange-500" },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="glass-card rounded-2xl p-5 text-left hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${item.color} flex items-center justify-center mb-3 shadow-md`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="font-bold text-sm mb-1 text-slate-900">{item.label}</div>
|
||||
<div className="text-slate-500 text-xs">{item.desc}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
client/src/pages/DisplayScreen.tsx
Normal file
252
client/src/pages/DisplayScreen.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "wouter";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Stethoscope, Wifi, WifiOff, Loader2, Clock, Users } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { formatTicket } from "@/lib/utils";
|
||||
|
||||
export default function DisplayScreen() {
|
||||
const params = useParams<{ clinicId: string }>();
|
||||
const clinicId = Number(params.clinicId ?? 0);
|
||||
const utils = trpc.useUtils();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
const queueQuery = trpc.queue.getPublic.useQuery(
|
||||
{ clinicId },
|
||||
{ enabled: !!clinicId, refetchInterval: 30_000 }
|
||||
);
|
||||
|
||||
// Tick clock
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
// Socket
|
||||
useEffect(() => {
|
||||
if (!clinicId) return;
|
||||
const s = getSocket();
|
||||
s.emit("display:subscribe", clinicId);
|
||||
setConnected(s.connected);
|
||||
const onConnect = () => setConnected(true);
|
||||
const onDisconnect = () => setConnected(false);
|
||||
const onUpdate = () => utils.queue.getPublic.invalidate({ clinicId });
|
||||
s.on("connect", onConnect);
|
||||
s.on("disconnect", onDisconnect);
|
||||
s.on("queue:update", onUpdate);
|
||||
return () => {
|
||||
s.emit("display:unsubscribe", clinicId);
|
||||
s.off("connect", onConnect);
|
||||
s.off("disconnect", onDisconnect);
|
||||
s.off("queue:update", onUpdate);
|
||||
};
|
||||
}, [clinicId, utils]);
|
||||
|
||||
if (queueQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-cyan-50">
|
||||
<Loader2 className="w-12 h-12 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (queueQuery.error || !queueQuery.data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8 bg-gradient-to-br from-emerald-50 via-white to-cyan-50">
|
||||
<div className="glass-card-strong rounded-3xl p-12 text-center max-w-md">
|
||||
<h1 className="font-bold text-3xl mb-3">Cabinet introuvable</h1>
|
||||
<p className="text-slate-500">Vérifiez l'URL ou contactez le médecin.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { clinic, queue, callingNow, waitingCount } = queueQuery.data;
|
||||
const upcoming = queue.filter((e) => e.status === "waiting").slice(0, 5);
|
||||
const accent = clinic.color ?? "#10b981";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-white to-cyan-50 relative overflow-hidden">
|
||||
{/* Animated bg blobs */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-40 -left-40 w-[40rem] h-[40rem] rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
|
||||
<div className="absolute -bottom-40 -right-40 w-[40rem] h-[40rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1.5s" }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="relative z-10 flex items-center justify-between px-10 py-6 border-b border-emerald-100/60 backdrop-blur-md bg-white/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-md"
|
||||
style={{ background: `linear-gradient(135deg, ${accent}, #06b6d4)` }}
|
||||
>
|
||||
<Stethoscope className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-2xl text-slate-900">{clinic.name}</h1>
|
||||
<p className="text-sm text-slate-500">QueueMed — File en direct</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-slate-900 tabular-nums">
|
||||
{now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 capitalize">
|
||||
{now.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" })}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold flex items-center gap-1.5 ${
|
||||
connected ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{connected ? "En direct" : "Reconnexion..."}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<main className="relative z-10 grid lg:grid-cols-2 gap-8 p-10 min-h-[calc(100vh-200px)]">
|
||||
{/* Calling now (huge) */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="glass-card-strong rounded-[3rem] p-12 text-center w-full max-w-2xl shadow-2xl">
|
||||
<div className="text-sm uppercase tracking-[0.3em] text-emerald-700 font-bold mb-6">
|
||||
Patient appelé
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{callingNow ? (
|
||||
<motion.div
|
||||
key={callingNow.ticketNumber}
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 1.2, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 15 }}
|
||||
className=""
|
||||
>
|
||||
<div
|
||||
className="font-black leading-none mb-4"
|
||||
style={{
|
||||
fontSize: "clamp(8rem, 22vw, 18rem)",
|
||||
background: `linear-gradient(135deg, ${accent}, #06b6d4)`,
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{formatTicket(callingNow.ticketNumber)}
|
||||
</div>
|
||||
{callingNow.patientName && (
|
||||
<div className="text-3xl font-bold text-slate-700 mt-4">
|
||||
{callingNow.patientName}
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.05, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="mt-8 inline-flex px-6 py-3 rounded-full bg-emerald-100 border-2 border-emerald-300 text-emerald-700 font-bold uppercase tracking-widest"
|
||||
>
|
||||
Salle de consultation
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="py-20"
|
||||
>
|
||||
<Users className="w-32 h-32 text-slate-200 mx-auto mb-6" />
|
||||
<div className="text-3xl font-bold text-slate-400">
|
||||
{clinic.isQueueOpen ? "Aucun patient appelé" : "File fermée"}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="glass-card-strong rounded-3xl p-6 flex-1 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-bold text-2xl text-slate-900">Prochains</h2>
|
||||
<p className="text-sm text-slate-500">{waitingCount} en attente</p>
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 rounded-full text-sm font-bold ${
|
||||
clinic.isQueueOpen ? "bg-emerald-500 text-white" : "bg-slate-200 text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{clinic.isQueueOpen ? "OUVERT" : "FERMÉ"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
Aucun patient en attente
|
||||
</div>
|
||||
) : (
|
||||
upcoming.map((e, i) => (
|
||||
<motion.div
|
||||
key={e.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl border-2 ${
|
||||
i === 0
|
||||
? "bg-gradient-to-r from-emerald-50 to-cyan-50 border-emerald-300"
|
||||
: "bg-white/50 border-slate-100"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-16 h-16 rounded-xl flex items-center justify-center font-bold text-2xl flex-shrink-0 ${
|
||||
i === 0
|
||||
? "bg-gradient-to-br from-emerald-500 to-cyan-500 text-white shadow-md"
|
||||
: "bg-slate-100 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{formatTicket(e.ticketNumber)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-slate-900 truncate">
|
||||
{e.patientName ?? "Patient anonyme"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 mt-0.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
~{e.estimatedWaitMinutes ?? "?"} min
|
||||
<span>·</span>
|
||||
<span>Position {e.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<div className="text-xs uppercase tracking-wider font-bold text-emerald-700">
|
||||
SUIVANT
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Ticker */}
|
||||
<footer className="relative z-10 border-t border-emerald-100/60 backdrop-blur-md bg-white/40 overflow-hidden h-12 flex items-center">
|
||||
<div className="whitespace-nowrap text-emerald-700 font-medium text-sm animate-ticker px-8">
|
||||
✨ Bienvenue au {clinic.name} — Scannez le QR code à l'accueil pour rejoindre la file en ligne — Suivez votre position en temps réel sur votre téléphone — Vous serez notifié quand votre tour approche
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
422
client/src/pages/DoctorClinics.tsx
Normal file
422
client/src/pages/DoctorClinics.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
Building2, Plus, Edit, Trash2, QrCode, Loader2, Power,
|
||||
PowerOff, RefreshCw, Monitor, Printer, Settings,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ClinicForm {
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
color: string;
|
||||
avgConsultationMinutes: number;
|
||||
maxQueueSize: number;
|
||||
qrRotationMinutes: number;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ClinicForm = {
|
||||
name: "",
|
||||
address: "",
|
||||
phone: "",
|
||||
color: "#10b981",
|
||||
avgConsultationMinutes: 15,
|
||||
maxQueueSize: 50,
|
||||
qrRotationMinutes: 30,
|
||||
};
|
||||
|
||||
export default function DoctorClinics() {
|
||||
const [, navigate] = useLocation();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const clinicsQuery = trpc.clinic.list.useQuery();
|
||||
|
||||
const [editing, setEditing] = useState<{ id: number | null; form: ClinicForm } | null>(null);
|
||||
const [qrFor, setQrFor] = useState<number | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
|
||||
const createMutation = trpc.clinic.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Cabinet créé !");
|
||||
utils.clinic.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.clinic.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Cabinet mis à jour");
|
||||
utils.clinic.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.clinic.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Cabinet supprimé");
|
||||
utils.clinic.list.invalidate();
|
||||
setConfirmDelete(null);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const regenMutation = trpc.clinic.regenerateQr.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Nouveau QR code généré");
|
||||
utils.clinic.qrDataUrl.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const toggleQueueMutation = trpc.clinic.update.useMutation({
|
||||
onSuccess: () => utils.clinic.list.invalidate(),
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const clinics = clinicsQuery.data ?? [];
|
||||
|
||||
const submit = () => {
|
||||
if (!editing) return;
|
||||
const f = editing.form;
|
||||
if (!f.name.trim() || f.name.trim().length < 2) {
|
||||
toast.error("Le nom du cabinet est requis (≥ 2 caractères).");
|
||||
return;
|
||||
}
|
||||
if (editing.id) {
|
||||
updateMutation.mutate({
|
||||
id: editing.id,
|
||||
name: f.name.trim(),
|
||||
address: f.address.trim() || null,
|
||||
phone: f.phone.trim() || null,
|
||||
color: f.color,
|
||||
avgConsultationMinutes: f.avgConsultationMinutes,
|
||||
maxQueueSize: f.maxQueueSize,
|
||||
qrRotationMinutes: f.qrRotationMinutes,
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
name: f.name.trim(),
|
||||
address: f.address.trim() || undefined,
|
||||
phone: f.phone.trim() || undefined,
|
||||
color: f.color,
|
||||
avgConsultationMinutes: f.avgConsultationMinutes,
|
||||
maxQueueSize: f.maxQueueSize,
|
||||
qrRotationMinutes: f.qrRotationMinutes,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="font-bold text-3xl mb-1">Mes cabinets</h1>
|
||||
<p className="text-slate-600">Gérez vos cabinets, leurs QR codes et leurs paramètres.</p>
|
||||
</div>
|
||||
<Button variant="gradient" onClick={() => setEditing({ id: null, form: { ...EMPTY_FORM } })}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Nouveau cabinet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{clinicsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
) : clinics.length === 0 ? (
|
||||
<div className="glass-card rounded-3xl p-12 text-center">
|
||||
<Building2 className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h3 className="font-bold text-xl mb-2">Aucun cabinet pour le moment</h3>
|
||||
<p className="text-slate-500 text-sm mb-6">Créez votre premier cabinet pour démarrer.</p>
|
||||
<Button variant="gradient" onClick={() => setEditing({ id: null, form: { ...EMPTY_FORM } })}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Créer un cabinet
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
{clinics.map((c) => (
|
||||
<div key={c.id} className="glass-card rounded-2xl p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center shadow-sm"
|
||||
style={{
|
||||
backgroundColor: `${c.color ?? "#10b981"}20`,
|
||||
border: `1px solid ${c.color ?? "#10b981"}50`,
|
||||
}}
|
||||
>
|
||||
<Building2 className="w-6 h-6" style={{ color: c.color ?? "#0d9488" }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold">{c.name}</h3>
|
||||
{c.address && <div className="text-xs text-slate-500">{c.address}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${
|
||||
c.isQueueOpen
|
||||
? "bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||
: "bg-slate-100 text-slate-500 border-slate-200"
|
||||
}`}
|
||||
>
|
||||
{c.isQueueOpen ? "Ouvert" : "Fermé"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center mb-4">
|
||||
<div className="p-2 rounded-lg bg-slate-50 border border-slate-100">
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">Cons.</div>
|
||||
<div className="font-bold text-sm">{c.avgConsultationMinutes} min</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-50 border border-slate-100">
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">Max</div>
|
||||
<div className="font-bold text-sm">{c.maxQueueSize}</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-50 border border-slate-100">
|
||||
<div className="text-[10px] uppercase text-slate-500 font-semibold">QR rot.</div>
|
||||
<div className="font-bold text-sm">{c.qrRotationMinutes ? `${c.qrRotationMinutes}m` : "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="default" onClick={() => navigate(`/dashboard/queue/${c.id}`)}>
|
||||
<Settings className="w-3.5 h-3.5 mr-1" /> Gérer la file
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
toggleQueueMutation.mutate({ id: c.id, isQueueOpen: !c.isQueueOpen })
|
||||
}
|
||||
disabled={toggleQueueMutation.isPending}
|
||||
>
|
||||
{c.isQueueOpen ? <><PowerOff className="w-3.5 h-3.5 mr-1" /> Fermer</> : <><Power className="w-3.5 h-3.5 mr-1" /> Ouvrir</>}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setQrFor(c.id)}>
|
||||
<QrCode className="w-3.5 h-3.5 mr-1" /> QR
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => window.open(`/display/${c.id}`, "_blank")}>
|
||||
<Monitor className="w-3.5 h-3.5 mr-1" /> Écran
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
id: c.id,
|
||||
form: {
|
||||
name: c.name,
|
||||
address: c.address ?? "",
|
||||
phone: c.phone ?? "",
|
||||
color: c.color ?? "#10b981",
|
||||
avgConsultationMinutes: c.avgConsultationMinutes ?? 15,
|
||||
maxQueueSize: c.maxQueueSize ?? 50,
|
||||
qrRotationMinutes: c.qrRotationMinutes ?? 30,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5 mr-1" /> Éditer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDelete(c.id)}
|
||||
className="text-red-600 border-red-200 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Edit / Create dialog ─────────────────────────────── */}
|
||||
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing?.id ? "Modifier le cabinet" : "Nouveau cabinet"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configurez les informations et paramètres de la salle d'attente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Nom du cabinet *</Label>
|
||||
<Input
|
||||
placeholder="Ex: Cabinet Dr. Martin"
|
||||
value={editing.form.name}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, name: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Adresse</Label>
|
||||
<Input
|
||||
placeholder="Ex: 12 rue de la Paix, Paris"
|
||||
value={editing.form.address}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, address: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Téléphone</Label>
|
||||
<Input
|
||||
placeholder="01 23 45 67 89"
|
||||
value={editing.form.phone}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, phone: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Couleur</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={editing.form.color}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, color: e.target.value } })}
|
||||
className="w-12 h-10 rounded-lg border border-slate-200 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-slate-500">{editing.form.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Durée moyenne consultation: <span className="text-emerald-700 font-bold">{editing.form.avgConsultationMinutes} min</span></Label>
|
||||
<input
|
||||
type="range" min={5} max={60} step={5}
|
||||
value={editing.form.avgConsultationMinutes}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, avgConsultationMinutes: Number(e.target.value) } })}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Taille max file: <span className="text-emerald-700 font-bold">{editing.form.maxQueueSize}</span></Label>
|
||||
<input
|
||||
type="range" min={5} max={200} step={5}
|
||||
value={editing.form.maxQueueSize}
|
||||
onChange={(e) => setEditing({ ...editing, form: { ...editing.form, maxQueueSize: Number(e.target.value) } })}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Rotation QR (anti-triche)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[0, 30, 60, 120, 240].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setEditing({ ...editing, form: { ...editing.form, qrRotationMinutes: v } })}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
||||
editing.form.qrRotationMinutes === v
|
||||
? "bg-teal-600 text-white border-teal-600"
|
||||
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{v === 0 ? "Désactivé" : v < 60 ? `${v} min` : `${v / 60}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Annuler</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={submit}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{(createMutation.isPending || updateMutation.isPending) && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{editing?.id ? "Enregistrer" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── QR dialog ────────────────────────────────────────── */}
|
||||
<Dialog open={qrFor !== null} onOpenChange={(open) => !open && setQrFor(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>QR Code du cabinet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Affichez ce QR à l'accueil. Vos patients le scannent pour rejoindre la file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{qrFor && <QrPreview clinicId={qrFor} onRegenerate={() => regenMutation.mutate({ id: qrFor })} regenLoading={regenMutation.isPending} />}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => navigate(`/dashboard/poster/${qrFor}`)}>
|
||||
<Printer className="w-4 h-4 mr-2" /> Affiche A4
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => setQrFor(null)}>Fermer</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── Delete confirm ───────────────────────────────────── */}
|
||||
<Dialog open={confirmDelete !== null} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Supprimer ce cabinet ?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cette action est irréversible. Toute la file et l'historique seront perdus.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmDelete(null)}>Annuler</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => confirmDelete && deleteMutation.mutate({ id: confirmDelete })}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Supprimer définitivement
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QrPreview({ clinicId, onRegenerate, regenLoading }: { clinicId: number; onRegenerate: () => void; regenLoading: boolean }) {
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : undefined;
|
||||
const qrQuery = trpc.clinic.qrDataUrl.useQuery({ id: clinicId, baseUrl }, { enabled: !!clinicId });
|
||||
|
||||
if (qrQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!qrQuery.data) return null;
|
||||
|
||||
return (
|
||||
<div className="text-center py-2">
|
||||
<img
|
||||
src={qrQuery.data.dataUrl}
|
||||
alt="QR code"
|
||||
className="w-56 h-56 mx-auto rounded-xl border border-slate-200 shadow-md mb-4"
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mb-3 break-all max-w-xs mx-auto">{qrQuery.data.url}</div>
|
||||
{qrQuery.data.qrTokenExpiresAt && (
|
||||
<div className="text-xs text-slate-500 mb-4">
|
||||
Expire le {new Date(qrQuery.data.qrTokenExpiresAt).toLocaleString("fr-FR")}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onRegenerate} disabled={regenLoading}>
|
||||
{regenLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5 mr-1" />}
|
||||
Régénérer
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
client/src/pages/Help.tsx
Normal file
369
client/src/pages/Help.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
ChevronLeft, ChevronDown, ChevronUp, Search,
|
||||
Stethoscope, Users, Wifi, CreditCard, Monitor,
|
||||
HelpCircle, AlertCircle, Sparkles, BookOpen, QrCode,
|
||||
Smartphone, Mail,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FaqItem {
|
||||
q: string;
|
||||
a: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const FAQ: FaqItem[] = [
|
||||
// Getting Started
|
||||
{
|
||||
category: "Démarrage",
|
||||
q: "Comment créer mon premier cabinet ?",
|
||||
a: "Lors de votre première connexion, suivez l'assistant de configuration en cliquant sur 'Démarrer la configuration' depuis le tableau de bord. Renseignez le nom, l'adresse optionnelle et les paramètres de la file (durée de consultation moyenne, taille maximale). Un QR code unique est généré automatiquement.",
|
||||
},
|
||||
{
|
||||
category: "Démarrage",
|
||||
q: "Comment imprimer mon affiche QR code ?",
|
||||
a: "Depuis la page de gestion d'un cabinet, cliquez sur 'Affiche QR'. La page affiche un poster A4 prêt à imprimer. Utilisez du papier couleur si possible, plastifiez l'affiche et placez-la à hauteur des yeux à l'entrée du cabinet.",
|
||||
},
|
||||
{
|
||||
category: "Démarrage",
|
||||
q: "Combien de temps faut-il pour configurer QueueMed ?",
|
||||
a: "Environ 2 minutes : créez votre compte, configurez votre premier cabinet et imprimez le QR code. Vous pouvez accueillir vos premiers patients en moins de 5 minutes au total.",
|
||||
},
|
||||
|
||||
// Managing Queue
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Comment ouvrir et fermer la file d'attente ?",
|
||||
a: "Dans la page 'Gestion de la file', sélectionnez votre cabinet et cliquez sur 'Ouvrir la file'. Les patients pourront alors rejoindre. En fin de journée, cliquez sur 'Fermer la file' puis 'Réinitialiser' pour repartir à zéro le lendemain.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Comment appeler le prochain patient ?",
|
||||
a: "Cliquez sur 'Appeler le suivant' dans l'interface de gestion. Le numéro s'affiche automatiquement sur l'écran d'affichage en salle d'attente et le patient reçoit une notification push sur son téléphone.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Que faire si un patient ne se présente pas ?",
|
||||
a: "Cliquez sur 'Absent' à côté du nom du patient. Il est retiré de la file et les positions des autres patients se mettent à jour automatiquement. Le patient devra rescanner le QR code pour rejoindre à nouveau.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Puis-je réorganiser l'ordre des patients ?",
|
||||
a: "Oui. Dans la liste de la file, glissez-déposez les patients pour modifier leur ordre. Les positions et temps d'attente se recalculent en direct, et chaque patient reçoit la mise à jour sur son téléphone.",
|
||||
},
|
||||
{
|
||||
category: "Gestion de la file",
|
||||
q: "Comment imprimer un ticket pour un patient sans smartphone ?",
|
||||
a: "Dans l'interface de gestion, cliquez sur 'Ajouter un patient' puis cochez 'Sans smartphone'. Un ticket imprimable s'ouvre dans un nouvel onglet avec le numéro et la position. Donnez-le au patient — il suivra son tour à l'écran d'affichage.",
|
||||
},
|
||||
|
||||
// Patient Experience
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Comment un patient rejoint-il la file ?",
|
||||
a: "Le patient ouvre l'appareil photo de son smartphone et scanne le QR code affiché à l'accueil. Un lien s'ouvre automatiquement — il appuie dessus pour rejoindre la file. Aucune application à installer.",
|
||||
},
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Le patient peut-il quitter la salle d'attente physique ?",
|
||||
a: "Oui, c'est l'avantage principal de QueueMed. Le patient garde la page ouverte sur son téléphone et peut s'éloigner. Il reçoit une notification push lorsque son tour approche. Recommandez-lui de rester à moins de 5 minutes du cabinet.",
|
||||
},
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Pourquoi le QR code ne fonctionne plus parfois ?",
|
||||
a: "Le QR code se renouvelle automatiquement à intervalles réguliers (système anti-triche) pour éviter le partage frauduleux du lien hors du cabinet. Si un patient obtient une erreur, il lui suffit de rescanner le QR code à l'accueil.",
|
||||
},
|
||||
{
|
||||
category: "Expérience patient",
|
||||
q: "Le patient reçoit-il bien la notification ?",
|
||||
a: "Lors du premier accès, le navigateur lui demande l'autorisation des notifications. S'il accepte, il recevra une notification push + vibration quand son tour est appelé. Sinon, la page reste à jour en temps réel tant qu'elle est ouverte.",
|
||||
},
|
||||
|
||||
// Display Screen
|
||||
{
|
||||
category: "Écran d'affichage",
|
||||
q: "Comment configurer l'écran d'affichage ?",
|
||||
a: "Dans la fiche de votre cabinet, copiez le 'Lien écran d'affichage' (/display/:clinicId). Ouvrez ce lien sur votre tablette ou moniteur de salle d'attente, puis activez le mode plein écran (F11 sur PC). L'écran se met à jour automatiquement via WebSocket.",
|
||||
},
|
||||
{
|
||||
category: "Écran d'affichage",
|
||||
q: "Quel matériel utiliser pour l'écran ?",
|
||||
a: "N'importe quelle tablette, moniteur ou TV connectée à internet et dotée d'un navigateur moderne (Chrome, Safari, Edge). Une simple tablette Android à 80 € fait parfaitement l'affaire.",
|
||||
},
|
||||
{
|
||||
category: "Écran d'affichage",
|
||||
q: "Que se passe-t-il en cas de coupure internet ?",
|
||||
a: "L'écran d'affichage affiche un indicateur 'Reconnexion...' en orange. Les patients déjà dans la file conservent leur position. Dès que la connexion est rétablie, la synchronisation reprend automatiquement.",
|
||||
},
|
||||
|
||||
// Subscription
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Combien de temps dure l'essai gratuit ?",
|
||||
a: "L'essai gratuit dure 30 jours à compter de votre première connexion. Toutes les fonctionnalités sont disponibles sans restriction pendant cette période, et vous pouvez créer plusieurs cabinets et accueillir un nombre illimité de patients.",
|
||||
},
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Que se passe-t-il après l'essai gratuit ?",
|
||||
a: "L'accès aux fonctionnalités de gestion (ouvrir la file, appeler des patients, créer des cabinets) est bloqué jusqu'à la souscription d'un plan payant. Vos données sont conservées et les patients peuvent toujours voir leur position dans les files actives.",
|
||||
},
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Puis-je annuler mon abonnement ?",
|
||||
a: "Oui, vous pouvez annuler à tout moment depuis la page 'Abonnement' de votre tableau de bord. L'accès aux fonctionnalités payantes reste actif jusqu'à la fin de la période déjà payée.",
|
||||
},
|
||||
{
|
||||
category: "Abonnement",
|
||||
q: "Combien de cabinets puis-je gérer ?",
|
||||
a: "Le plan Solo inclut 1 cabinet. Le plan Pro permet de créer jusqu'à 5 cabinets. Le plan Cabinet inclut un nombre illimité de cabinets et donne accès à des statistiques avancées avec recommandations IA.",
|
||||
},
|
||||
|
||||
// Technical
|
||||
{
|
||||
category: "Technique",
|
||||
q: "Sur quels appareils fonctionne QueueMed ?",
|
||||
a: "QueueMed fonctionne sur tous les appareils dotés d'un navigateur moderne : smartphones iOS et Android, tablettes, ordinateurs Windows / Mac / Linux. Aucune application à installer. Recommandé : Chrome ou Safari à jour.",
|
||||
},
|
||||
{
|
||||
category: "Technique",
|
||||
q: "Mes données patients sont-elles sécurisées ?",
|
||||
a: "Oui. Les noms et numéros de ticket sont chiffrés en transit (HTTPS) et stockés sur des serveurs hébergés en France. Aucune donnée médicale n'est collectée. Les patients sont identifiés uniquement par un nom optionnel.",
|
||||
},
|
||||
{
|
||||
category: "Technique",
|
||||
q: "Puis-je exporter mes statistiques ?",
|
||||
a: "Oui. Depuis la page 'Analytics', cliquez sur 'Exporter en CSV' pour télécharger l'historique complet des consultations. Le fichier inclut les heures, durées d'attente et durées de consultation pour chaque patient.",
|
||||
},
|
||||
{
|
||||
category: "Technique",
|
||||
q: "QueueMed fonctionne-t-il hors ligne ?",
|
||||
a: "Non, une connexion internet est nécessaire pour la synchronisation en temps réel entre le médecin, l'écran d'affichage et les patients. En cas de coupure, l'application reprend automatiquement dès le retour de la connexion.",
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
"Tous",
|
||||
"Démarrage",
|
||||
"Gestion de la file",
|
||||
"Expérience patient",
|
||||
"Écran d'affichage",
|
||||
"Abonnement",
|
||||
"Technique",
|
||||
];
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||
Démarrage: Sparkles,
|
||||
"Gestion de la file": Stethoscope,
|
||||
"Expérience patient": Users,
|
||||
"Écran d'affichage": Monitor,
|
||||
Abonnement: CreditCard,
|
||||
Technique: Wifi,
|
||||
};
|
||||
|
||||
export default function Help() {
|
||||
const [, navigate] = useLocation();
|
||||
const [activeCategory, setActiveCategory] = useState("Tous");
|
||||
const [search, setSearch] = useState("");
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return FAQ.filter((item) => {
|
||||
const matchCat = activeCategory === "Tous" || item.category === activeCategory;
|
||||
const matchTerm =
|
||||
term === "" ||
|
||||
item.q.toLowerCase().includes(term) ||
|
||||
item.a.toLowerCase().includes(term);
|
||||
return matchCat && matchTerm;
|
||||
});
|
||||
}, [activeCategory, search]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
{/* Background blobs */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/20 blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-cyan-300/20 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-3xl mx-auto px-4 py-12">
|
||||
{/* Back */}
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? window.history.back() : navigate("/")}
|
||||
className="flex items-center gap-2 text-slate-500 hover:text-emerald-700 transition-colors mb-8 text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center mx-auto mb-4 shadow-lg glow-emerald">
|
||||
<HelpCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="font-bold text-4xl mb-3">
|
||||
Centre <span className="gradient-text">d'aide</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-lg">
|
||||
Trouvez rapidement les réponses à vos questions sur QueueMed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Rechercher une question..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOpenIndex(null);
|
||||
}}
|
||||
className="pl-12 h-12 text-base bg-white/80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
{[
|
||||
{ icon: QrCode, label: "Démarrage", cat: "Démarrage" },
|
||||
{ icon: Smartphone, label: "Patients", cat: "Expérience patient" },
|
||||
{ icon: Monitor, label: "Écran", cat: "Écran d'affichage" },
|
||||
{ icon: CreditCard, label: "Abonnement", cat: "Abonnement" },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeCategory === item.cat;
|
||||
return (
|
||||
<button
|
||||
key={item.cat}
|
||||
onClick={() => {
|
||||
setActiveCategory(item.cat);
|
||||
setOpenIndex(null);
|
||||
}}
|
||||
className={cn(
|
||||
"glass-card rounded-2xl p-4 flex flex-col items-center gap-2 transition-all hover:shadow-md",
|
||||
isActive && "border-emerald-400 shadow-md ring-2 ring-emerald-200"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-6 h-6", isActive ? "text-emerald-600" : "text-slate-500")} />
|
||||
<span className={cn("text-xs font-semibold", isActive ? "text-emerald-700" : "text-slate-700")}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<div className="flex gap-2 flex-wrap mb-6">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
setActiveCategory(cat);
|
||||
setOpenIndex(null);
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-xs font-semibold border transition-all",
|
||||
activeCategory === cat
|
||||
? "bg-teal-600 text-white border-teal-600 shadow-md"
|
||||
: "bg-white/80 border-slate-200 text-slate-600 hover:border-emerald-400 hover:text-emerald-700"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="glass-card rounded-3xl p-10 text-center">
|
||||
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-slate-500">
|
||||
Aucune question ne correspond à votre recherche.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((item, i) => {
|
||||
const CatIcon = CATEGORY_ICONS[item.category] ?? BookOpen;
|
||||
const isOpen = openIndex === i;
|
||||
return (
|
||||
<div
|
||||
key={`${item.category}-${item.q}`}
|
||||
className={cn(
|
||||
"glass-card rounded-2xl overflow-hidden transition-all duration-200",
|
||||
isOpen && "shadow-md ring-1 ring-emerald-200"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(isOpen ? null : i)}
|
||||
className="w-full flex items-center gap-4 p-5 text-left hover:bg-emerald-50/40 transition-colors"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<CatIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-emerald-700 font-bold mb-0.5">
|
||||
{item.category}
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-slate-900 leading-snug block">
|
||||
{item.q}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-5 pb-5">
|
||||
<div className="ml-13 pl-1 text-sm text-slate-600 leading-relaxed border-t border-slate-200 pt-4">
|
||||
{item.a}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact CTA */}
|
||||
<div className="mt-12 glass-card-strong rounded-3xl p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-orange-400 to-amber-500 flex items-center justify-center mx-auto mb-4 shadow-md">
|
||||
<AlertCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl mb-2 text-slate-900">
|
||||
Vous ne trouvez pas votre réponse ?
|
||||
</h3>
|
||||
<p className="text-slate-500 text-sm mb-6 max-w-md mx-auto">
|
||||
Notre équipe est disponible pour vous aider à configurer et utiliser
|
||||
QueueMed dans votre cabinet.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4 mr-2" />
|
||||
Tableau de bord
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => window.open("mailto:support@queuemed.fr", "_blank")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Contacter le support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
452
client/src/pages/Home.tsx
Normal file
452
client/src/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { Link } from "wouter";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Stethoscope, QrCode, Smartphone, Bell, Monitor, BarChart3,
|
||||
Clock, Shield, Sparkles, ChevronRight, Check, Star, Users,
|
||||
Zap, Heart, Activity,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: QrCode,
|
||||
title: "QR code rotatif",
|
||||
description: "Vos patients scannent un QR à l'entrée — token tournant anti-triche, aucune appli à installer.",
|
||||
color: "from-emerald-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
icon: Smartphone,
|
||||
title: "Position en temps réel",
|
||||
description: "Chaque patient voit sa position et son temps d'attente estimé, mis à jour en direct via WebSocket.",
|
||||
color: "from-cyan-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
title: "Alertes intelligentes",
|
||||
description: "Notification push quand le tour approche — vos patients peuvent quitter la salle d'attente.",
|
||||
color: "from-teal-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
icon: Monitor,
|
||||
title: "Écran de salle",
|
||||
description: "Affichage plein écran sur tablette avec ticker, numéro appelé géant, file en direct.",
|
||||
color: "from-emerald-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "Statistiques précises",
|
||||
description: "Affluence par heure, jour, durée moyenne. Recommandations IA pour optimiser votre cabinet.",
|
||||
color: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "RGPD & souverain",
|
||||
description: "Données hébergées en France. Aucun tracking patient. Sécurité bancaire (TLS, JWT, bcrypt).",
|
||||
color: "from-teal-500 to-emerald-500",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ num: "01", title: "Configurez votre cabinet", desc: "2 minutes pour créer votre file. Imprimez le QR code et placez-le à l'accueil." },
|
||||
{ num: "02", title: "Vos patients scannent", desc: "Ils ouvrent l'appareil photo, scannent et rejoignent la file en un clic." },
|
||||
{ num: "03", title: "Vous appelez le suivant", desc: "Un clic depuis votre tableau, le patient est notifié, l'écran s'actualise." },
|
||||
];
|
||||
|
||||
const PRICES = [
|
||||
{
|
||||
name: "Essai",
|
||||
price: "Gratuit",
|
||||
period: "30 jours",
|
||||
description: "Toutes les fonctionnalités, sans carte bancaire.",
|
||||
features: ["1 cabinet", "Patients illimités", "Statistiques de base", "Support email"],
|
||||
cta: "Démarrer l'essai",
|
||||
href: "/login",
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Basic",
|
||||
price: "29€",
|
||||
period: "/ mois",
|
||||
description: "Pour un cabinet individuel.",
|
||||
features: ["1 cabinet", "Patients illimités", "Écran d'affichage", "Statistiques avancées", "Support prioritaire"],
|
||||
cta: "S'abonner",
|
||||
href: "/login",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "79€",
|
||||
period: "/ mois",
|
||||
description: "Pour les centres médicaux.",
|
||||
features: ["Cabinets illimités", "Multi-praticiens", "Recommandations IA", "Export CSV avancé", "Support téléphonique"],
|
||||
cta: "S'abonner",
|
||||
href: "/login",
|
||||
highlighted: false,
|
||||
},
|
||||
];
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
name: "Dr. Marie Dubois",
|
||||
role: "Médecin généraliste, Lyon",
|
||||
quote: "Mes patients adorent. Plus de salle d'attente bondée, plus de stress. Je gagne 1h par jour facilement.",
|
||||
avatar: "MD",
|
||||
},
|
||||
{
|
||||
name: "Dr. Karim Benali",
|
||||
role: "Pédiatre, Marseille",
|
||||
quote: "Setup en 5 minutes. Le QR rotatif évite les abus et l'écran d'affichage est parfait pour ma salle.",
|
||||
avatar: "KB",
|
||||
},
|
||||
{
|
||||
name: "Dr. Sophie Lefèvre",
|
||||
role: "Dentiste, Bordeaux",
|
||||
quote: "Les statistiques m'ont permis d'optimiser mes plages horaires. ROI évident dès le premier mois.",
|
||||
avatar: "SL",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 overflow-hidden">
|
||||
{/* ─── Nav ────────────────────────────────────────────────────── */}
|
||||
<nav className="sticky top-0 z-50 backdrop-blur-xl bg-white/70 border-b border-emerald-100/60">
|
||||
<div className="container flex items-center justify-between h-16">
|
||||
<Link href="/">
|
||||
<a className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
|
||||
<Stethoscope className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-lg gradient-text">QueueMed</span>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm text-slate-600 font-medium">
|
||||
<a href="#features" className="hover:text-emerald-700 transition-colors">Fonctionnalités</a>
|
||||
<a href="#how" className="hover:text-emerald-700 transition-colors">Fonctionnement</a>
|
||||
<a href="#pricing" className="hover:text-emerald-700 transition-colors">Tarifs</a>
|
||||
<Link href="/help"><a className="hover:text-emerald-700 transition-colors">Aide</a></Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" size="sm">Connexion</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button variant="gradient" size="sm" className="hidden sm:inline-flex">
|
||||
Essai gratuit <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ─── Hero ───────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-20 pb-32 overflow-hidden">
|
||||
{/* Animated gradient blobs */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-10 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
|
||||
<div className="absolute top-32 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
|
||||
<div className="absolute bottom-0 left-1/3 w-80 h-80 rounded-full bg-teal-300/20 blur-3xl animate-pulse-glow" style={{ animationDelay: "2s" }} />
|
||||
</div>
|
||||
|
||||
<div className="container relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="max-w-3xl mx-auto text-center"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-emerald-100/80 backdrop-blur-sm border border-emerald-200/80 text-emerald-700 text-sm font-semibold mb-8">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Salle d'attente virtuelle nouvelle génération
|
||||
</div>
|
||||
|
||||
<h1 className="font-bold text-5xl md:text-7xl tracking-tight leading-[1.05] mb-6">
|
||||
<span className="gradient-text">Vos patients</span>
|
||||
<br />
|
||||
n'attendent plus,
|
||||
<br />
|
||||
ils <span className="gradient-text">vivent</span>.
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-slate-600 mb-10 max-w-2xl mx-auto leading-relaxed">
|
||||
QueueMed transforme votre cabinet médical en une expérience fluide.
|
||||
QR code, suivi en temps réel, notifications, écran d'affichage — sans application à installer.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
|
||||
<Link href="/login">
|
||||
<Button variant="gradient" size="xl" className="w-full sm:w-auto shadow-xl">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Démarrer l'essai gratuit (30j)
|
||||
</Button>
|
||||
</Link>
|
||||
<a href="#how">
|
||||
<Button variant="outline" size="xl" className="w-full sm:w-auto">
|
||||
Voir comment ça marche
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-wrap justify-center gap-6 text-sm text-slate-500">
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> Aucune carte bancaire</div>
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> Setup en 2 minutes</div>
|
||||
<div className="flex items-center gap-2"><Check className="w-4 h-4 text-emerald-500" /> Données en France</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Hero mock */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="mt-20 max-w-5xl mx-auto"
|
||||
>
|
||||
<div className="glass-card-strong rounded-3xl p-2 shadow-2xl">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-emerald-50 via-white to-cyan-50 p-8 md:p-12">
|
||||
<div className="grid md:grid-cols-2 gap-8 items-center">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-widest text-emerald-700 font-bold mb-2">Patient en cours</div>
|
||||
<div className="font-black text-7xl md:text-8xl gradient-text leading-none mb-3">042</div>
|
||||
<div className="text-slate-600">Salle 2 — Dr. Martin</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">Prochains</div>
|
||||
{[
|
||||
{ n: "043", name: "Patient anonyme", time: "~5 min", active: true },
|
||||
{ n: "044", name: "Patient anonyme", time: "~20 min" },
|
||||
{ n: "045", name: "Patient anonyme", time: "~35 min" },
|
||||
].map((p) => (
|
||||
<div key={p.n} className={`flex items-center justify-between p-3 rounded-xl border ${p.active ? "bg-emerald-50 border-emerald-200" : "bg-white border-slate-100"}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`font-bold text-lg ${p.active ? "text-emerald-700" : "text-slate-700"}`}>{p.n}</div>
|
||||
<div className="text-sm text-slate-600">{p.name}</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">{p.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Features ───────────────────────────────────────────────── */}
|
||||
<section id="features" className="py-24 bg-gradient-to-b from-white to-emerald-50/30">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Fonctionnalités</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
Tout ce dont votre cabinet <span className="gradient-text">a besoin</span>
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600">
|
||||
Une plateforme pensée par et pour les médecins. Élégante, rapide, conforme.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{FEATURES.map((f, i) => {
|
||||
const Icon = f.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={f.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.05 }}
|
||||
className="glass-card rounded-2xl p-6 hover:shadow-xl transition-shadow"
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${f.color} flex items-center justify-center shadow-md mb-4`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg mb-2">{f.title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{f.description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── How it works ──────────────────────────────────────────── */}
|
||||
<section id="how" className="py-24">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Comment ça marche</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
<span className="gradient-text">3 étapes</span> et c'est lancé
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{STEPS.map((s, i) => (
|
||||
<motion.div
|
||||
key={s.num}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="glass-card rounded-2xl p-8 h-full">
|
||||
<div className="font-black text-6xl gradient-text mb-3">{s.num}</div>
|
||||
<h3 className="font-bold text-xl mb-2">{s.title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{s.desc}</p>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<ChevronRight className="hidden md:block absolute top-1/2 -right-5 -translate-y-1/2 w-8 h-8 text-emerald-400" />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Pricing ───────────────────────────────────────────────── */}
|
||||
<section id="pricing" className="py-24 bg-gradient-to-b from-emerald-50/30 to-cyan-50/30">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Tarifs</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
Simples et <span className="gradient-text">transparents</span>
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600">
|
||||
30 jours d'essai gratuit, sans engagement, sans carte bancaire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{PRICES.map((p) => (
|
||||
<motion.div
|
||||
key={p.name}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={`relative rounded-3xl p-8 ${
|
||||
p.highlighted
|
||||
? "bg-gradient-to-br from-emerald-500 to-cyan-500 text-white shadow-2xl scale-105"
|
||||
: "glass-card-strong"
|
||||
}`}
|
||||
>
|
||||
{p.highlighted && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-orange-500 text-white text-xs font-bold uppercase tracking-wider shadow-md">
|
||||
Populaire
|
||||
</div>
|
||||
)}
|
||||
<h3 className={`font-bold text-2xl mb-1 ${p.highlighted ? "text-white" : "text-slate-900"}`}>{p.name}</h3>
|
||||
<p className={`text-sm mb-6 ${p.highlighted ? "text-emerald-50" : "text-slate-500"}`}>{p.description}</p>
|
||||
<div className="flex items-baseline gap-1 mb-8">
|
||||
<span className={`font-black text-5xl ${p.highlighted ? "text-white" : "gradient-text"}`}>{p.price}</span>
|
||||
<span className={`text-sm ${p.highlighted ? "text-emerald-100" : "text-slate-500"}`}>{p.period}</span>
|
||||
</div>
|
||||
<ul className="space-y-3 mb-8">
|
||||
{p.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm">
|
||||
<Check className={`w-4 h-4 flex-shrink-0 mt-0.5 ${p.highlighted ? "text-white" : "text-emerald-500"}`} />
|
||||
<span className={p.highlighted ? "text-white" : "text-slate-700"}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href={p.href}>
|
||||
<Button
|
||||
size="lg"
|
||||
className={`w-full ${
|
||||
p.highlighted
|
||||
? "bg-white text-emerald-700 hover:bg-emerald-50"
|
||||
: "bg-teal-600 hover:bg-teal-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{p.cta}
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Testimonials ──────────────────────────────────────────── */}
|
||||
<section className="py-24">
|
||||
<div className="container">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<div className="text-emerald-700 font-semibold text-sm uppercase tracking-widest mb-3">Témoignages</div>
|
||||
<h2 className="font-bold text-4xl md:text-5xl mb-4 tracking-tight">
|
||||
Approuvé par <span className="gradient-text">200+ médecins</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<div key={t.name} className="glass-card rounded-2xl p-6">
|
||||
<div className="flex gap-1 mb-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-amber-400 text-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-700 text-sm leading-relaxed mb-5 italic">"{t.quote}"</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm">
|
||||
{t.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-slate-900">{t.name}</div>
|
||||
<div className="text-xs text-slate-500">{t.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── CTA ───────────────────────────────────────────────────── */}
|
||||
<section className="py-24">
|
||||
<div className="container">
|
||||
<div className="rounded-3xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 p-12 md:p-16 text-center text-white shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 rounded-full bg-white/20 blur-3xl" />
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 rounded-full bg-white/20 blur-3xl" />
|
||||
</div>
|
||||
<div className="relative z-10 max-w-2xl mx-auto">
|
||||
<Heart className="w-12 h-12 mx-auto mb-6 text-white" />
|
||||
<h2 className="font-bold text-3xl md:text-5xl mb-4 tracking-tight">
|
||||
Prêt à transformer votre cabinet ?
|
||||
</h2>
|
||||
<p className="text-emerald-50 text-lg mb-8">
|
||||
30 jours d'essai gratuit. Aucune carte bancaire. Setup en 2 minutes.
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<Button size="xl" className="bg-white text-emerald-700 hover:bg-emerald-50 shadow-xl">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Démarrer maintenant
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Footer ────────────────────────────────────────────────── */}
|
||||
<footer className="border-t border-emerald-100/60 py-12 bg-white/50 backdrop-blur-sm">
|
||||
<div className="container">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
|
||||
<Stethoscope className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold gradient-text">QueueMed</span>
|
||||
<span className="text-slate-400 text-sm">— Salle d'attente virtuelle</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-slate-500">
|
||||
<Link href="/help"><a className="hover:text-emerald-700">Aide</a></Link>
|
||||
<a href="mailto:contact@queuemed.fr" className="hover:text-emerald-700">Contact</a>
|
||||
<span className="text-slate-400">© {new Date().getFullYear()} QueueMed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
client/src/pages/Login.tsx
Normal file
197
client/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Stethoscope, Mail, Lock, User, Sparkles, Loader2, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
export default function Login() {
|
||||
const [, navigate] = useLocation();
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const { login, register, isLoggingIn, isRegistering } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (mode === "login") {
|
||||
await login(email, password);
|
||||
} else {
|
||||
await register(email, password, name || undefined);
|
||||
}
|
||||
navigate("/dashboard");
|
||||
} catch {
|
||||
// Toast handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoggingIn || isRegistering;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background blobs */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
<Link href="/">
|
||||
<a className="inline-flex items-center gap-2 text-slate-500 hover:text-emerald-700 mb-6 text-sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-lg glow-emerald">
|
||||
<Stethoscope className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="font-bold text-3xl mb-2">
|
||||
{mode === "login" ? (
|
||||
<>Bon retour, <span className="gradient-text">Docteur</span></>
|
||||
) : (
|
||||
<>Bienvenue sur <span className="gradient-text">QueueMed</span></>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{mode === "login"
|
||||
? "Connectez-vous à votre espace médecin."
|
||||
: "Créez votre compte et démarrez 30 jours gratuits."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-strong rounded-3xl p-8">
|
||||
{/* Mode toggle */}
|
||||
<div className="grid grid-cols-2 p-1 mb-6 rounded-xl bg-slate-100">
|
||||
<button
|
||||
onClick={() => setMode("login")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
mode === "login"
|
||||
? "bg-white text-teal-700 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
Connexion
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("register")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
mode === "register"
|
||||
? "bg-white text-teal-700 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
Inscription
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mode === "register" && (
|
||||
<div>
|
||||
<Label htmlFor="name" className="mb-1.5 block">Nom complet <span className="text-slate-400 text-xs">(optionnel)</span></Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Dr. Marie Dubois"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="pl-10"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="mb-1.5 block">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="docteur@cabinet.fr"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="mb-1.5 block">Mot de passe</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Au moins 8 caractères"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="pl-10"
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{mode === "login" ? "Se connecter" : "Créer mon compte"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-slate-500 mt-6">
|
||||
{mode === "login" ? (
|
||||
<>Pas encore de compte ?{" "}
|
||||
<button onClick={() => setMode("register")} className="text-teal-700 font-semibold underline-offset-2 hover:underline">
|
||||
Inscrivez-vous gratuitement
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>Déjà un compte ?{" "}
|
||||
<button onClick={() => setMode("login")} className="text-teal-700 font-semibold underline-offset-2 hover:underline">
|
||||
Connectez-vous
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-3 text-center">
|
||||
{[
|
||||
{ label: "Setup", value: "2 min" },
|
||||
{ label: "Essai", value: "30j" },
|
||||
{ label: "Cabinets", value: "200+" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="glass-card rounded-xl p-3">
|
||||
<div className="font-bold text-emerald-700 text-sm">{s.value}</div>
|
||||
<div className="text-xs text-slate-500">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
415
client/src/pages/Onboarding.tsx
Normal file
415
client/src/pages/Onboarding.tsx
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
Stethoscope, Building2, Clock, QrCode, CheckCircle2,
|
||||
ChevronRight, ChevronLeft, Loader2, Printer, Monitor,
|
||||
LayoutDashboard, Sparkles,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Votre cabinet",
|
||||
description: "Renseignez les informations de base et les paramètres de la file.",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Votre QR code",
|
||||
description: "Imprimez ou prévisualisez l'affiche à apposer à l'accueil.",
|
||||
icon: QrCode,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Tout est prêt !",
|
||||
description: "Voici les prochaines étapes pour démarrer.",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Onboarding() {
|
||||
const [, navigate] = useLocation();
|
||||
const [step, setStep] = useState(1);
|
||||
const [clinicId, setClinicId] = useState<number | null>(null);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [avgConsultation, setAvgConsultation] = useState(15);
|
||||
const [maxQueue, setMaxQueue] = useState(30);
|
||||
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
const createMutation = trpc.clinic.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setClinicId(data.id);
|
||||
setStep(2);
|
||||
toast.success("Cabinet créé avec succès !");
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const qrQuery = trpc.clinic.qrDataUrl.useQuery(
|
||||
{ id: clinicId ?? 0, baseUrl },
|
||||
{ enabled: clinicId !== null && step >= 2 }
|
||||
);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Le nom du cabinet est requis.");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
address: address.trim() || undefined,
|
||||
phone: phone.trim() || undefined,
|
||||
avgConsultationMinutes: avgConsultation,
|
||||
maxQueueSize: maxQueue,
|
||||
});
|
||||
};
|
||||
|
||||
const currentStep = STEPS.find((s) => s.id === step)!;
|
||||
const StepIcon = currentStep.icon;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background blobs */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[28rem] h-[28rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-lg glow-emerald">
|
||||
<Stethoscope className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="font-bold text-3xl mb-2">
|
||||
Configuration <span className="gradient-text">initiale</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Configurez votre premier cabinet en 2 minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300",
|
||||
s.id < step && "bg-emerald-500 text-white shadow-md",
|
||||
s.id === step && "bg-white border-2 border-emerald-500 text-emerald-700 shadow-md",
|
||||
s.id > step && "bg-slate-100 text-slate-400 border border-slate-200"
|
||||
)}
|
||||
>
|
||||
{s.id < step ? <CheckCircle2 className="w-4 h-4" /> : s.id}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-0.5 transition-all duration-300",
|
||||
s.id < step ? "bg-emerald-500" : "bg-slate-200"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="glass-card-strong rounded-3xl p-8">
|
||||
{/* Step header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md glow-emerald flex-shrink-0">
|
||||
<StepIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-xl text-slate-900">{currentStep.title}</h2>
|
||||
<p className="text-slate-500 text-sm">{currentStep.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 — Cabinet info + queue settings */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label htmlFor="name" className="mb-1.5 block">
|
||||
Nom du cabinet <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ex: Cabinet Dr. Martin"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="address" className="mb-1.5 block">
|
||||
Adresse <span className="text-slate-400 text-xs">(optionnel)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="12 rue de la Paix, Paris"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone" className="mb-1.5 block">
|
||||
Téléphone <span className="text-slate-400 text-xs">(optionnel)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="01 23 45 67 89"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-emerald-800 font-semibold text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
Paramètres de la file
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-slate-700">
|
||||
Durée moyenne de consultation
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={5}
|
||||
value={avgConsultation}
|
||||
onChange={(e) => setAvgConsultation(Number(e.target.value))}
|
||||
className="flex-1 accent-emerald-500"
|
||||
/>
|
||||
<span className="text-emerald-700 font-bold w-20 text-right text-sm">
|
||||
{avgConsultation} min
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Utilisé pour estimer le temps d'attente des patients.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-slate-700">
|
||||
Taille maximale de la file
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
value={maxQueue}
|
||||
onChange={(e) => setMaxQueue(Number(e.target.value))}
|
||||
className="flex-1 accent-emerald-500"
|
||||
/>
|
||||
<span className="text-emerald-700 font-bold w-20 text-right text-sm">
|
||||
{maxQueue} pat.
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Au-delà, les nouveaux patients ne pourront plus rejoindre.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — QR Preview */}
|
||||
{step === 2 && (
|
||||
<div className="text-center space-y-5">
|
||||
<p className="text-sm text-slate-600">
|
||||
Voici le QR code de <strong className="text-slate-900">{name}</strong>.
|
||||
Imprimez-le et placez-le à l'entrée du cabinet pour que vos
|
||||
patients puissent rejoindre la file.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="rounded-2xl bg-white p-5 border-2 border-emerald-200 inline-block"
|
||||
style={{ boxShadow: "0 12px 32px rgba(13, 148, 136, 0.15)" }}
|
||||
>
|
||||
{qrQuery.isLoading ? (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
) : qrQuery.data?.dataUrl ? (
|
||||
<img
|
||||
src={qrQuery.data.dataUrl}
|
||||
alt="QR Code"
|
||||
style={{ width: 200, height: 200, display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400 text-sm">
|
||||
QR indisponible
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => clinicId && navigate(`/dashboard/poster/${clinicId}`)}
|
||||
disabled={!clinicId}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Voir / imprimer l'affiche
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => setStep(3)}
|
||||
>
|
||||
Continuer
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Done */}
|
||||
{step === 3 && (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center mx-auto shadow-lg glow-emerald">
|
||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-2xl text-slate-900 mb-2">
|
||||
Cabinet configuré !
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">
|
||||
Le cabinet <strong className="text-slate-900">"{name}"</strong>{" "}
|
||||
est prêt. Voici vos prochaines étapes pour bien démarrer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-left">
|
||||
{[
|
||||
{
|
||||
icon: Printer,
|
||||
text: "Imprimez le QR code et affichez-le à l'accueil",
|
||||
},
|
||||
{
|
||||
icon: Monitor,
|
||||
text: "Configurez l'écran d'affichage sur votre tablette ou moniteur",
|
||||
},
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
text: "Ouvrez la file depuis le tableau de bord en début de journée",
|
||||
},
|
||||
].map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-3 p-3 rounded-xl bg-emerald-50/70 border border-emerald-200"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 border border-emerald-200">
|
||||
<Icon className="w-4 h-4 text-emerald-700" />
|
||||
</div>
|
||||
<span className="text-sm text-slate-700 mt-1">
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-8">
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending}
|
||||
className="w-full font-semibold h-12"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Création...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Créer le cabinet
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep(1)}
|
||||
className="text-slate-500"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Retour
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => clinicId && navigate(`/dashboard/queue/${clinicId}`)}
|
||||
className="flex-1"
|
||||
disabled={!clinicId}
|
||||
>
|
||||
<QrCode className="w-4 h-4 mr-2" />
|
||||
Voir la file
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
className="flex-1 font-semibold"
|
||||
>
|
||||
Tableau de bord
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip link */}
|
||||
{step < 3 && (
|
||||
<p className="text-center mt-4 text-sm text-slate-500">
|
||||
<button
|
||||
onClick={() => navigate("/dashboard")}
|
||||
className="underline underline-offset-2 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
Passer pour l'instant
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
client/src/pages/PatientQueue.tsx
Normal file
272
client/src/pages/PatientQueue.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams, useLocation } from "wouter";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Stethoscope, Bell, BellRing, CheckCircle2, XCircle, Clock,
|
||||
Loader2, MapPin, Building2, ArrowRight, RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { toast } from "sonner";
|
||||
import { formatTicket, formatTime } from "@/lib/utils";
|
||||
|
||||
export default function PatientQueue() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const patientToken = params.token ?? "";
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const ticketQuery = trpc.queue.getByToken.useQuery(
|
||||
{ patientToken },
|
||||
{ enabled: !!patientToken, refetchInterval: 30_000 }
|
||||
);
|
||||
|
||||
const cancelMutation = trpc.queue.cancel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Ticket annulé");
|
||||
utils.queue.getByToken.invalidate({ patientToken });
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const [pulse, setPulse] = useState(false);
|
||||
|
||||
// Socket subscription
|
||||
useEffect(() => {
|
||||
if (!patientToken) return;
|
||||
const s = getSocket();
|
||||
s.emit("patient:subscribe", patientToken);
|
||||
const onUpdate = () => utils.queue.getByToken.invalidate({ patientToken });
|
||||
const onCalled = () => {
|
||||
setPulse(true);
|
||||
utils.queue.getByToken.invalidate({ patientToken });
|
||||
// Best-effort browser notification
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification("C'est votre tour !", {
|
||||
body: "Présentez-vous en salle de consultation.",
|
||||
icon: "/favicon.svg",
|
||||
});
|
||||
}
|
||||
try {
|
||||
navigator.vibrate?.([200, 100, 200, 100, 400]);
|
||||
} catch {}
|
||||
toast.success("C'est votre tour !", { duration: 10_000 });
|
||||
};
|
||||
const onApproaching = () => {
|
||||
utils.queue.getByToken.invalidate({ patientToken });
|
||||
toast("Vous êtes le prochain — préparez-vous !", { duration: 8_000 });
|
||||
};
|
||||
const onAbsent = () => utils.queue.getByToken.invalidate({ patientToken });
|
||||
const onDone = () => utils.queue.getByToken.invalidate({ patientToken });
|
||||
s.on("patient:update", onUpdate);
|
||||
s.on("patient:called", onCalled);
|
||||
s.on("patient:approaching", onApproaching);
|
||||
s.on("patient:absent", onAbsent);
|
||||
s.on("patient:done", onDone);
|
||||
return () => {
|
||||
s.emit("patient:unsubscribe", patientToken);
|
||||
s.off("patient:update", onUpdate);
|
||||
s.off("patient:called", onCalled);
|
||||
s.off("patient:approaching", onApproaching);
|
||||
s.off("patient:absent", onAbsent);
|
||||
s.off("patient:done", onDone);
|
||||
};
|
||||
}, [patientToken, utils]);
|
||||
|
||||
// Ask for notification permission once
|
||||
useEffect(() => {
|
||||
if ("Notification" in window && Notification.permission === "default") {
|
||||
Notification.requestPermission().catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (ticketQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticketQuery.error || !ticketQuery.data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h1 className="font-bold text-2xl mb-2">Ticket introuvable</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Ce lien est invalide ou a expiré. Veuillez rescanner le QR code à l'accueil.
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/")}>Retour à l'accueil</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { entry, clinic, callingNow, waitingCount } = ticketQuery.data;
|
||||
const isCalled = entry.status === "called";
|
||||
const isInConsult = entry.status === "in_consultation";
|
||||
const isDone = entry.status === "done";
|
||||
const isAbsent = entry.status === "absent" || entry.status === "canceled";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 flex items-center justify-center relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
|
||||
<Stethoscope className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-lg gradient-text">QueueMed</span>
|
||||
</div>
|
||||
{clinic?.name && (
|
||||
<h1 className="font-bold text-xl mb-1 flex items-center justify-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-emerald-600" />
|
||||
{clinic.name}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status card */}
|
||||
<AnimatePresence mode="wait">
|
||||
{isCalled ? (
|
||||
<motion.div
|
||||
key="called"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="rounded-3xl p-8 text-center text-white shadow-2xl bg-gradient-to-br from-emerald-500 to-cyan-500"
|
||||
style={{ boxShadow: "0 0 50px rgba(16, 185, 129, 0.5)" }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: pulse ? [0, -15, 15, -15, 0] : 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="inline-flex w-20 h-20 rounded-2xl bg-white/30 items-center justify-center mb-4"
|
||||
>
|
||||
<BellRing className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<div className="text-sm uppercase tracking-widest text-emerald-50 font-bold mb-2">C'est votre tour</div>
|
||||
<div className="font-black text-7xl mb-3 leading-none">{formatTicket(entry.ticketNumber)}</div>
|
||||
<p className="text-emerald-50 mb-2">Présentez-vous immédiatement à la salle de consultation.</p>
|
||||
</motion.div>
|
||||
) : isInConsult ? (
|
||||
<motion.div
|
||||
key="consult"
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-16 h-16 text-emerald-500 mx-auto mb-4" />
|
||||
<h2 className="font-bold text-2xl mb-2">En consultation</h2>
|
||||
<p className="text-slate-600">Vous êtes actuellement avec votre médecin.</p>
|
||||
</motion.div>
|
||||
) : isDone ? (
|
||||
<motion.div
|
||||
key="done"
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-16 h-16 text-emerald-500 mx-auto mb-4" />
|
||||
<h2 className="font-bold text-2xl mb-2">Consultation terminée</h2>
|
||||
<p className="text-slate-600 mb-2">Merci de votre visite.</p>
|
||||
<p className="text-slate-500 text-sm">À bientôt !</p>
|
||||
</motion.div>
|
||||
) : isAbsent ? (
|
||||
<motion.div
|
||||
key="absent"
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<XCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
|
||||
<h2 className="font-bold text-2xl mb-2">Ticket clos</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
{entry.status === "absent"
|
||||
? "Vous avez été marqué absent. Rescannez le QR à l'accueil pour rejoindre à nouveau."
|
||||
: "Votre ticket a été annulé."}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="waiting"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="glass-card-strong rounded-3xl p-8 text-center"
|
||||
>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">Votre ticket</div>
|
||||
<div className="font-black text-7xl gradient-text leading-none mb-2">
|
||||
{formatTicket(entry.ticketNumber)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mb-6">{entry.patientName ?? "Patient anonyme"}</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-emerald-50 to-emerald-100/50 border border-emerald-200">
|
||||
<div className="text-xs uppercase tracking-wider text-emerald-700 font-bold mb-1">Position</div>
|
||||
<div className="font-black text-3xl text-emerald-900">{entry.position}</div>
|
||||
<div className="text-xs text-emerald-700 mt-1">sur {waitingCount}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-cyan-50 to-cyan-100/50 border border-cyan-200">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-700 font-bold mb-1">Attente</div>
|
||||
<div className="font-black text-3xl text-cyan-900">~{entry.estimatedWaitMinutes ?? "?"}</div>
|
||||
<div className="text-xs text-cyan-700 mt-1">minutes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{callingNow && (
|
||||
<div className="p-3 rounded-xl bg-amber-50 border border-amber-200 text-sm flex items-center justify-center gap-2 mb-4">
|
||||
<ArrowRight className="w-4 h-4 text-amber-600" />
|
||||
Patient en cours :{" "}
|
||||
<strong className="text-amber-900">{formatTicket(callingNow.ticketNumber)}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Gardez cette page ouverte. Vous serez notifié quand votre tour approche.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-red-600 border-red-200 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
if (confirm("Annuler votre ticket ? Vous devrez rescanner le QR pour revenir.")) {
|
||||
cancelMutation.mutate({ patientToken });
|
||||
}
|
||||
}}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
Annuler mon ticket
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-6 text-xs text-slate-500">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Rejoint à {formatTime(entry.joinedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCalled && !isDone && !isAbsent && (
|
||||
<button
|
||||
onClick={() => ticketQuery.refetch()}
|
||||
className="mt-4 w-full text-xs text-slate-400 hover:text-emerald-700 flex items-center justify-center gap-1"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${ticketQuery.isFetching ? "animate-spin" : ""}`} />
|
||||
Actualiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
client/src/pages/PrintTicket.tsx
Normal file
222
client/src/pages/PrintTicket.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useEffect } from "react";
|
||||
import { useParams, useLocation } from "wouter";
|
||||
import {
|
||||
Stethoscope, Building2, MapPin, Clock, Hash,
|
||||
Printer, ChevronLeft, Loader2, XCircle,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatTicket, formatTime, formatDate } from "@/lib/utils";
|
||||
|
||||
export default function PrintTicket() {
|
||||
const params = useParams<{ entryId: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const entryId = parseInt(params.entryId ?? "0", 10);
|
||||
|
||||
const ticketQuery = trpc.queue.getEntryById.useQuery(
|
||||
{ id: entryId },
|
||||
{ enabled: entryId > 0 }
|
||||
);
|
||||
|
||||
// Auto-trigger print dialog once data is loaded (helpful when opened from doctor UI)
|
||||
useEffect(() => {
|
||||
if (ticketQuery.data && typeof window !== "undefined") {
|
||||
const t = setTimeout(() => {
|
||||
try { window.print(); } catch {}
|
||||
}, 600);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [ticketQuery.data]);
|
||||
|
||||
if (ticketQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticketQuery.error || !ticketQuery.data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h1 className="font-bold text-2xl mb-2">Ticket introuvable</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Ce ticket n'existe pas ou a été supprimé.
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/")}>
|
||||
Retour à l'accueil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { entry, clinic } = ticketQuery.data;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Controls — hidden when printing */}
|
||||
<div className="print:hidden max-w-2xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate("/dashboard")}
|
||||
className="flex items-center gap-2 text-slate-500 hover:text-emerald-700 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour
|
||||
</button>
|
||||
<Button
|
||||
onClick={() => window.print()}
|
||||
variant="gradient"
|
||||
className="font-semibold"
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Imprimer le ticket
|
||||
</Button>
|
||||
</div>
|
||||
<div className="glass-card rounded-2xl p-4 text-sm text-slate-600 flex items-start gap-3">
|
||||
<Printer className="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-slate-900">Conseil :</strong> imprimez sur du
|
||||
papier A6 ou pliez en deux. Donnez ce ticket au patient ; il pourra
|
||||
suivre son tour à l'écran d'affichage.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printable ticket */}
|
||||
<div className="max-w-md mx-auto px-4 pb-12 print:p-0 print:max-w-none print:mx-0">
|
||||
<div
|
||||
className="bg-white rounded-3xl print:rounded-none overflow-hidden border border-slate-200 print:border-0 shadow-xl print:shadow-none"
|
||||
style={{ fontFamily: "'Inter', system-ui, sans-serif" }}
|
||||
>
|
||||
{/* Header band */}
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
|
||||
padding: "20px 28px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,0.22)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4" />
|
||||
</div>
|
||||
<span style={{ fontSize: 18, fontWeight: 800, letterSpacing: "-0.02em" }}>
|
||||
QueueMed
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, opacity: 0.9, margin: 0 }}>
|
||||
Ticket de file d'attente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Clinic */}
|
||||
<div className="px-7 pt-6 text-center">
|
||||
{clinic?.name && (
|
||||
<h1 className="font-bold text-xl text-slate-900 flex items-center justify-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-emerald-600" />
|
||||
{clinic.name}
|
||||
</h1>
|
||||
)}
|
||||
{clinic?.address && (
|
||||
<p className="text-slate-500 text-xs mt-1 flex items-center justify-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{clinic.address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ticket number */}
|
||||
<div className="px-7 py-8 text-center">
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-2">
|
||||
Votre numéro
|
||||
</div>
|
||||
<div
|
||||
className="font-black leading-none"
|
||||
style={{
|
||||
fontSize: 96,
|
||||
background: "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
color: "transparent",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{formatTicket(entry.ticketNumber)}
|
||||
</div>
|
||||
{entry.patientName && (
|
||||
<div className="text-sm text-slate-600 mt-3">
|
||||
{entry.patientName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="px-7 pb-6 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl p-4 text-center bg-emerald-50 border border-emerald-200">
|
||||
<div className="text-[10px] uppercase tracking-wider text-emerald-700 font-bold flex items-center justify-center gap-1 mb-1">
|
||||
<Hash className="w-3 h-3" />
|
||||
Position
|
||||
</div>
|
||||
<div className="font-black text-2xl text-emerald-900">
|
||||
{entry.position ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl p-4 text-center bg-cyan-50 border border-cyan-200">
|
||||
<div className="text-[10px] uppercase tracking-wider text-cyan-700 font-bold flex items-center justify-center gap-1 mb-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Attente
|
||||
</div>
|
||||
<div className="font-black text-2xl text-cyan-900">
|
||||
~{entry.estimatedWaitMinutes ?? "?"}
|
||||
<span className="text-xs font-bold ml-1">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="px-7 pb-6">
|
||||
<div className="rounded-xl p-4 bg-slate-50 border border-slate-200 text-xs text-slate-600 leading-relaxed">
|
||||
<strong className="text-slate-900 block mb-1">
|
||||
Comment ça marche ?
|
||||
</strong>
|
||||
Surveillez l'écran d'affichage en salle. Lorsque votre numéro
|
||||
s'affiche, présentez-vous immédiatement à la salle de
|
||||
consultation.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="border-t border-slate-200 px-7 py-3 flex items-center justify-between text-[10px] text-slate-400"
|
||||
style={{ background: "#f8fafc" }}
|
||||
>
|
||||
<span>Émis le {formatDate(entry.joinedAt)} à {formatTime(entry.joinedAt)}</span>
|
||||
<span>queuemed.fr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media print {
|
||||
html, body { background: white !important; }
|
||||
.print\\:hidden { display: none !important; }
|
||||
@page { margin: 8mm; size: A6; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
client/src/pages/QrPoster.tsx
Normal file
302
client/src/pages/QrPoster.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (clinicQuery.error || !clinic) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<h1 className="font-bold text-2xl mb-2">Cabinet introuvable</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Ce cabinet n'existe pas ou ne vous appartient pas.
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/dashboard/clinics")}>
|
||||
Retour aux cabinets
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Controls — hidden on print */}
|
||||
<div className="print:hidden max-w-2xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => navigate(`/dashboard/queue/${clinicId}`)}
|
||||
className="flex items-center gap-2 text-slate-500 hover:text-emerald-700 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour à la gestion
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => qrQuery.refetch()}
|
||||
disabled={qrQuery.isFetching}
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${qrQuery.isFetching ? "animate-spin" : ""}`} />
|
||||
Rafraîchir
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => window.print()}
|
||||
className="font-semibold"
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Imprimer l'affiche
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card rounded-2xl p-4 flex items-start gap-3 text-sm">
|
||||
<QrCode className="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-slate-600">
|
||||
<strong className="text-slate-900">Conseils d'impression :</strong>{" "}
|
||||
utilisez du papier A4, en couleur si possible. Plastifiez
|
||||
l'affiche et placez-la à hauteur des yeux à l'entrée du cabinet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printable poster */}
|
||||
<div className="max-w-2xl mx-auto px-4 pb-12 print:p-0 print:max-w-none print:mx-0">
|
||||
<div
|
||||
className="bg-white rounded-3xl print:rounded-none overflow-hidden border border-slate-200 print:border-0 shadow-2xl print:shadow-none"
|
||||
style={{ fontFamily: "'Inter', system-ui, sans-serif" }}
|
||||
>
|
||||
{/* Header band */}
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
|
||||
padding: "32px 40px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background: "rgba(255,255,255,0.22)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stethoscope className="w-6 h-6" />
|
||||
</div>
|
||||
<span style={{ fontSize: 24, fontWeight: 800, letterSpacing: "-0.02em" }}>
|
||||
QueueMed
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, opacity: 0.92, margin: 0 }}>
|
||||
Salle d'attente virtuelle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="px-10 py-10 text-center">
|
||||
<h1 className="font-bold text-3xl text-slate-900 leading-tight">
|
||||
{clinic.name}
|
||||
</h1>
|
||||
{clinic.address && (
|
||||
<p className="text-slate-500 text-sm mt-2 flex items-center justify-center gap-1.5">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{clinic.address}
|
||||
</p>
|
||||
)}
|
||||
{clinic.phone && (
|
||||
<p className="text-slate-500 text-sm mt-1 flex items-center justify-center gap-1.5">
|
||||
<Phone className="w-4 h-4" />
|
||||
{clinic.phone}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-8 mb-2">
|
||||
<p className="text-xl font-bold text-slate-900">
|
||||
Scannez pour rejoindre la file
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Suivez votre position en temps réel sur votre téléphone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: 20,
|
||||
borderRadius: 24,
|
||||
border: "3px solid #d1fae5",
|
||||
background: "white",
|
||||
boxShadow: "0 12px 32px rgba(13, 148, 136, 0.15)",
|
||||
}}
|
||||
>
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code file d'attente"
|
||||
style={{ width: 240, height: 240, display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 240,
|
||||
height: 240,
|
||||
background: "#f1f5f9",
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#94a3b8",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
QR Code non disponible
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="mt-10 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{
|
||||
Icon: Smartphone,
|
||||
num: "1",
|
||||
title: "Scannez",
|
||||
desc: "Pointez votre appareil photo vers le QR code",
|
||||
},
|
||||
{
|
||||
Icon: Hand,
|
||||
num: "2",
|
||||
title: "Rejoignez",
|
||||
desc: "Appuyez sur le lien et entrez dans la file",
|
||||
},
|
||||
{
|
||||
Icon: Bell,
|
||||
num: "3",
|
||||
title: "Patientez",
|
||||
desc: "Vous serez alerté quand votre tour approche",
|
||||
},
|
||||
].map((step) => (
|
||||
<div
|
||||
key={step.num}
|
||||
className="rounded-2xl p-4 text-left"
|
||||
style={{
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-black text-white"
|
||||
style={{ background: "#10b981" }}
|
||||
>
|
||||
{step.num}
|
||||
</div>
|
||||
<step.Icon className="w-4 h-4 text-emerald-700" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-900">
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-600 leading-snug mt-1">
|
||||
{step.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div
|
||||
className="mt-8 rounded-xl p-3 flex items-center gap-3 text-left"
|
||||
style={{
|
||||
background: "#ecfeff",
|
||||
border: "1px solid #a5f3fc",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "#06b6d4", color: "white" }}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-cyan-900">
|
||||
Aucune application à installer
|
||||
</div>
|
||||
<div className="text-[11px] text-cyan-700">
|
||||
Fonctionne dans votre navigateur. Gratuit pour les patients.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-[11px] text-slate-400 border-t border-slate-100 pt-4">
|
||||
Pas de smartphone ? Demandez un ticket imprimé à l'accueil.
|
||||
</p>
|
||||
|
||||
{qrUrl && (
|
||||
<p className="mt-2 text-[10px] text-slate-300 break-all">
|
||||
{qrUrl}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="border-t border-slate-200 px-10 py-3 flex items-center justify-between text-xs text-slate-400"
|
||||
style={{ background: "#f8fafc" }}
|
||||
>
|
||||
<span>Propulsé par QueueMed</span>
|
||||
<span>queuemed.fr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media print {
|
||||
html, body { background: white !important; }
|
||||
.print\\:hidden { display: none !important; }
|
||||
@page { margin: 0; size: A4; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
394
client/src/pages/QueueManagement.tsx
Normal file
394
client/src/pages/QueueManagement.tsx
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams, useLocation } from "wouter";
|
||||
import {
|
||||
ChevronLeft, Play, UserX, CheckCircle2, Trash2, Monitor, Users, Clock,
|
||||
Printer, RefreshCw, Loader2, Power, PowerOff, QrCode, Sparkles,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { toast } from "sonner";
|
||||
import { formatTicket, formatTime } from "@/lib/utils";
|
||||
import type { QueueEntryStatus } from "@shared/types";
|
||||
|
||||
export default function QueueManagement() {
|
||||
const params = useParams<{ clinicId: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const clinicId = Number(params.clinicId ?? 0);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const queueQuery = trpc.queue.getForDoctor.useQuery(
|
||||
{ clinicId },
|
||||
{ enabled: !!clinicId, refetchInterval: 15_000 }
|
||||
);
|
||||
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : undefined;
|
||||
const qrQuery = trpc.clinic.qrDataUrl.useQuery(
|
||||
{ id: clinicId, baseUrl },
|
||||
{ enabled: !!clinicId, refetchInterval: 60_000 }
|
||||
);
|
||||
|
||||
// ─── Socket ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!clinicId) return;
|
||||
const s = getSocket();
|
||||
s.emit("clinic:subscribe", clinicId);
|
||||
const onUpdate = () => utils.queue.getForDoctor.invalidate({ clinicId });
|
||||
const onQr = () => utils.clinic.qrDataUrl.invalidate({ id: clinicId, baseUrl });
|
||||
s.on("queue:update", onUpdate);
|
||||
s.on("qr:rotated", onQr);
|
||||
return () => {
|
||||
s.emit("clinic:unsubscribe", clinicId);
|
||||
s.off("queue:update", onUpdate);
|
||||
s.off("qr:rotated", onQr);
|
||||
};
|
||||
}, [clinicId, utils, baseUrl]);
|
||||
|
||||
// ─── Mutations ───────────────────────────────────────
|
||||
const callNext = trpc.queue.callNext.useMutation({
|
||||
onSuccess: (d) => {
|
||||
if (d.called) toast.success(`Ticket #${formatTicket(d.called.ticketNumber)} appelé`);
|
||||
else toast("Aucun patient en attente");
|
||||
utils.queue.getForDoctor.invalidate({ clinicId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const markAbsent = trpc.queue.markAbsent.useMutation({
|
||||
onSuccess: () => { toast.success("Patient marqué absent"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const markDone = trpc.queue.markDone.useMutation({
|
||||
onSuccess: () => { toast.success("Consultation terminée"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const callSpecific = trpc.queue.callSpecific.useMutation({
|
||||
onSuccess: () => { toast.success("Patient appelé"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const reset = trpc.queue.reset.useMutation({
|
||||
onSuccess: () => { toast.success("File réinitialisée"); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const printTicket = trpc.queue.joinPrinted.useMutation({
|
||||
onSuccess: (d) => {
|
||||
toast.success(`Ticket #${formatTicket(d.ticketNumber)} créé`);
|
||||
window.open(`/ticket/${d.entryId}`, "_blank");
|
||||
utils.queue.getForDoctor.invalidate({ clinicId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const toggleQueue = trpc.clinic.update.useMutation({
|
||||
onSuccess: () => { utils.queue.getForDoctor.invalidate({ clinicId }); utils.clinic.list.invalidate(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const reorder = trpc.queue.reorder.useMutation({
|
||||
onSuccess: () => utils.queue.getForDoctor.invalidate({ clinicId }),
|
||||
onError: (e) => { toast.error(e.message); utils.queue.getForDoctor.invalidate({ clinicId }); },
|
||||
});
|
||||
|
||||
const regenQr = trpc.clinic.regenerateQr.useMutation({
|
||||
onSuccess: () => { toast.success("QR régénéré"); utils.clinic.qrDataUrl.invalidate({ id: clinicId, baseUrl }); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// ─── Derived ─────────────────────────────────────────
|
||||
const data = queueQuery.data;
|
||||
const clinic = data?.clinic;
|
||||
const queue = data?.queue ?? [];
|
||||
const waiting = queue.filter((e) => e.status === "waiting");
|
||||
const called = queue.filter((e) => e.status === "called" || e.status === "in_consultation");
|
||||
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
// ─── Drag & drop reordering ──────────────────────────
|
||||
const [dragId, setDragId] = useState<number | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<number | null>(null);
|
||||
|
||||
const handleDrop = (target: number) => {
|
||||
if (dragId === null || dragId === target) {
|
||||
setDragId(null);
|
||||
setDragOverId(null);
|
||||
return;
|
||||
}
|
||||
const ids = waiting.map((e) => e.id);
|
||||
const fromIdx = ids.indexOf(dragId);
|
||||
const toIdx = ids.indexOf(target);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const reordered = [...ids];
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, moved);
|
||||
reorder.mutate({ clinicId, orderedEntryIds: reordered });
|
||||
setDragId(null);
|
||||
setDragOverId(null);
|
||||
};
|
||||
|
||||
if (!clinicId) {
|
||||
return (
|
||||
<div className="container py-12 text-center">
|
||||
<p className="text-slate-500">Cabinet introuvable.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
{/* ─── Header ───────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between mb-6 gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/dashboard")}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Retour
|
||||
</Button>
|
||||
<div className="text-center min-w-0 flex-1">
|
||||
<h1 className="font-bold text-xl gradient-text truncate">{clinic?.name ?? "Chargement..."}</h1>
|
||||
<p className="text-slate-500 text-xs">
|
||||
{waiting.length} en attente · {called.length} appelé{called.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/display/${clinicId}`, "_blank")}
|
||||
title="Écran d'affichage"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={clinic?.isQueueOpen ? "destructive" : "gradient"}
|
||||
onClick={() => toggleQueue.mutate({ id: clinicId, isQueueOpen: !clinic?.isQueueOpen })}
|
||||
disabled={toggleQueue.isPending}
|
||||
>
|
||||
{clinic?.isQueueOpen ? <><PowerOff className="w-4 h-4 mr-1" />Fermer</> : <><Power className="w-4 h-4 mr-1" />Ouvrir</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* ─── Left: Controls / QR / Stats ─────────────── */}
|
||||
<div className="space-y-4">
|
||||
{/* Actions */}
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Actions</h2>
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="xl"
|
||||
className="w-full mb-3"
|
||||
onClick={() => callNext.mutate({ clinicId })}
|
||||
disabled={callNext.isPending || waiting.length === 0}
|
||||
>
|
||||
{callNext.isPending ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : <Play className="w-5 h-5 mr-2" />}
|
||||
Appeler le suivant
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-3"
|
||||
onClick={() => printTicket.mutate({ clinicId })}
|
||||
disabled={printTicket.isPending || !clinic?.isQueueOpen}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" /> Imprimer un ticket
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-red-600 border-red-200 hover:bg-red-50"
|
||||
onClick={() => setConfirmReset(true)}
|
||||
disabled={reset.isPending}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" /> Réinitialiser la file
|
||||
</Button>
|
||||
{confirmReset && (
|
||||
<div className="mt-3 p-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-800">
|
||||
<p className="mb-3">Êtes-vous sûr ? Toute la file sera effacée.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="destructive" onClick={() => { reset.mutate({ clinicId }); setConfirmReset(false); }} disabled={reset.isPending}>
|
||||
Oui, réinitialiser
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setConfirmReset(false)}>Annuler</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR */}
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">QR Code</h2>
|
||||
{qrQuery.data ? (
|
||||
<div className="text-center">
|
||||
<img src={qrQuery.data.dataUrl} alt="QR" className="w-44 h-44 mx-auto rounded-xl border border-slate-200 mb-3" />
|
||||
<p className="text-slate-500 text-xs mb-3">
|
||||
Expire : {qrQuery.data.qrTokenExpiresAt ? formatTime(qrQuery.data.qrTokenExpiresAt) : "—"}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => regenQr.mutate({ id: clinicId })} disabled={regenQr.isPending}>
|
||||
{regenQr.isPending ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />} Renouveler
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => navigate(`/dashboard/poster/${clinicId}`)}>
|
||||
<Printer className="w-3 h-3 mr-1" /> Affiche
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="glass-card rounded-2xl p-6">
|
||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Statistiques</h2>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: "En attente", value: waiting.length, icon: Users },
|
||||
{ label: "Appelé", value: called.length, icon: Play },
|
||||
{ label: "Cons. moy.", value: `~${clinic?.avgConsultationMinutes ?? 15} min`, icon: Clock },
|
||||
].map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<div key={s.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-600 text-sm">
|
||||
<Icon className="w-4 h-4" /> {s.label}
|
||||
</div>
|
||||
<span className="font-bold text-slate-900">{s.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Right: Queue list ───────────────────────── */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="glass-card rounded-2xl overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 className="font-bold">File d'attente</h2>
|
||||
<span className="text-slate-500 text-sm">{queue.length} patient{queue.length > 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
|
||||
{queueQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
) : queue.length === 0 ? (
|
||||
<div className="text-center py-16 px-6">
|
||||
<Sparkles className="w-12 h-12 text-emerald-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 mb-2">Aucun patient en file</p>
|
||||
{!clinic?.isQueueOpen && (
|
||||
<p className="text-slate-400 text-sm">Ouvrez la file pour commencer à accueillir des patients.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{queue.map((entry) => {
|
||||
const draggable = entry.status === "waiting";
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
draggable={draggable}
|
||||
onDragStart={() => draggable && setDragId(entry.id)}
|
||||
onDragOver={(e) => { if (draggable) { e.preventDefault(); setDragOverId(entry.id); } }}
|
||||
onDragLeave={() => setDragOverId((id) => (id === entry.id ? null : id))}
|
||||
onDrop={() => draggable && handleDrop(entry.id)}
|
||||
className={`flex items-center gap-3 p-4 transition-all ${
|
||||
entry.status === "called"
|
||||
? "bg-emerald-50/60"
|
||||
: entry.status === "in_consultation"
|
||||
? "bg-cyan-50/40"
|
||||
: "hover:bg-emerald-50/30"
|
||||
} ${dragOverId === entry.id ? "ring-2 ring-emerald-400" : ""} ${draggable ? "cursor-move" : ""}`}
|
||||
>
|
||||
{/* Ticket */}
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center font-bold text-lg flex-shrink-0 ${
|
||||
entry.status === "called"
|
||||
? "bg-emerald-500 text-white shadow-md"
|
||||
: entry.status === "in_consultation"
|
||||
? "bg-cyan-500 text-white shadow-md"
|
||||
: "bg-white border border-slate-200 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{formatTicket(entry.ticketNumber)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{entry.patientName ?? `Patient #${entry.ticketNumber}`}
|
||||
</span>
|
||||
{entry.isPrinted && (
|
||||
<span className="text-[10px] text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">Imprimé</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>Pos. {entry.position}</span>
|
||||
<span>·</span>
|
||||
<span>~{entry.estimatedWaitMinutes ?? "?"} min</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(entry.joinedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<StatusBadge status={entry.status} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{entry.status === "waiting" && (
|
||||
<button
|
||||
onClick={() => callSpecific.mutate({ entryId: entry.id })}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-emerald-600 hover:bg-emerald-100"
|
||||
title="Appeler ce patient"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{(entry.status === "called" || entry.status === "in_consultation") && (
|
||||
<button
|
||||
onClick={() => markDone.mutate({ entryId: entry.id })}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-emerald-600 hover:bg-emerald-100"
|
||||
title="Terminer la consultation"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{(entry.status === "waiting" || entry.status === "called") && (
|
||||
<button
|
||||
onClick={() => markAbsent.mutate({ entryId: entry.id })}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-orange-600 hover:bg-orange-100"
|
||||
title="Marquer absent"
|
||||
>
|
||||
<UserX className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: QueueEntryStatus }) {
|
||||
const map: Record<QueueEntryStatus, { label: string; className: string }> = {
|
||||
waiting: { label: "En attente", className: "badge-waiting" },
|
||||
called: { label: "Appelé", className: "badge-called" },
|
||||
in_consultation: { label: "En consult.", className: "badge-called" },
|
||||
done: { label: "Terminé", className: "badge-done" },
|
||||
absent: { label: "Absent", className: "badge-absent" },
|
||||
canceled: { label: "Annulé", className: "badge-absent" },
|
||||
};
|
||||
const m = map[status];
|
||||
return <span className={m.className}>{m.label}</span>;
|
||||
}
|
||||
37
client/src/pages/SubscriptionBlocked.tsx
Normal file
37
client/src/pages/SubscriptionBlocked.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { useLocation } from "wouter";
|
||||
import { Lock, CreditCard, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export default function SubscriptionBlocked() {
|
||||
const [, navigate] = useLocation();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-destructive/10 blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-secondary/10 blur-3xl" />
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="glass-card rounded-3xl p-10 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-destructive/20 border border-destructive/40 flex items-center justify-center mx-auto mb-6">
|
||||
<Lock className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold mb-3">Abonnement expiré</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8 leading-relaxed">
|
||||
Votre période d'essai gratuit est terminée. Choisissez un abonnement pour continuer à utiliser Salle d'attente.
|
||||
</p>
|
||||
<div className="space-y-3 mb-8 text-left">
|
||||
{["File d'attente illimitée", "QR code anti-triche", "Suivi temps réel", "Analytics avancés"].map(f => (
|
||||
<div key={f} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => navigate("/dashboard/subscription")} className="w-full bg-primary text-primary-foreground hover:bg-primary/90 glow-teal h-12 font-semibold">
|
||||
<CreditCard className="w-4 h-4 mr-2" /> Choisir un abonnement
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
client/src/pages/SubscriptionPage.tsx
Normal file
251
client/src/pages/SubscriptionPage.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useLocation } from "wouter";
|
||||
import {
|
||||
CreditCard, Check, Sparkles, Clock, Loader2, AlertTriangle,
|
||||
TrendingUp, Crown, Heart,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
plan: "trial" as const,
|
||||
name: "Essai",
|
||||
price: "0€",
|
||||
period: "30 jours",
|
||||
description: "Découvrez QueueMed sans engagement.",
|
||||
features: ["1 cabinet", "Patients illimités", "Statistiques de base", "Support email"],
|
||||
icon: Sparkles,
|
||||
color: "from-slate-500 to-slate-600",
|
||||
},
|
||||
{
|
||||
plan: "basic" as const,
|
||||
name: "Basic",
|
||||
price: "29€",
|
||||
period: "/ mois",
|
||||
description: "Pour un cabinet individuel.",
|
||||
features: ["1 cabinet", "Patients illimités", "Écran d'affichage", "Statistiques avancées", "Support prioritaire"],
|
||||
icon: TrendingUp,
|
||||
color: "from-emerald-500 to-teal-500",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
plan: "pro" as const,
|
||||
name: "Pro",
|
||||
price: "79€",
|
||||
period: "/ mois",
|
||||
description: "Centres médicaux et multi-praticiens.",
|
||||
features: ["Cabinets illimités", "Multi-praticiens", "Recommandations IA", "Export CSV", "Support téléphonique"],
|
||||
icon: Crown,
|
||||
color: "from-violet-500 to-fuchsia-500",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [, navigate] = useLocation();
|
||||
const subQuery = trpc.subscription.get.useQuery();
|
||||
const checkQuery = trpc.subscription.check.useQuery();
|
||||
|
||||
const sub = subQuery.data;
|
||||
const check = checkQuery.data;
|
||||
|
||||
const handleSubscribe = (plan: "basic" | "pro") => {
|
||||
toast.info(`Redirection vers le paiement ${plan}…`, {
|
||||
description: "L'intégration Stripe sera activée prochainement.",
|
||||
});
|
||||
};
|
||||
|
||||
if (subQuery.isLoading) {
|
||||
return (
|
||||
<div className="container py-20 flex justify-center">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isTrialing = sub?.status === "trialing";
|
||||
const isActive = sub?.status === "active";
|
||||
const daysLeft = check?.daysRemaining ?? 0;
|
||||
const expired = !check?.active;
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="font-bold text-3xl mb-1">Abonnement</h1>
|
||||
<p className="text-slate-600">Gérez votre plan et votre période d'essai.</p>
|
||||
</div>
|
||||
|
||||
{/* Current status */}
|
||||
<div
|
||||
className={`glass-card-strong rounded-3xl p-8 mb-8 relative overflow-hidden ${
|
||||
expired ? "border-2 border-red-200" : ""
|
||||
}`}
|
||||
>
|
||||
{!expired && (
|
||||
<div
|
||||
className="absolute -top-20 -right-20 w-72 h-72 rounded-full opacity-20 blur-3xl"
|
||||
style={{
|
||||
background:
|
||||
isTrialing
|
||||
? "linear-gradient(135deg, #fbbf24, #f97316)"
|
||||
: "linear-gradient(135deg, #10b981, #06b6d4)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-widest text-emerald-700 font-bold mb-2">Plan actuel</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="font-bold text-3xl capitalize">{sub?.plan ?? "—"}</h2>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold ${
|
||||
expired
|
||||
? "bg-red-100 text-red-700"
|
||||
: isTrialing
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-emerald-100 text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
{expired ? "Expiré" : isTrialing ? "Essai" : isActive ? "Actif" : sub?.status}
|
||||
</span>
|
||||
</div>
|
||||
{expired ? (
|
||||
<p className="text-red-600 text-sm flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Votre abonnement est expiré. Renouvelez pour continuer à utiliser QueueMed.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-600 text-sm flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4 text-emerald-600" />
|
||||
{isTrialing ? "Essai gratuit" : "Prochain renouvellement"} dans <strong>{daysLeft} jour{daysLeft > 1 ? "s" : ""}</strong>
|
||||
{sub?.trialEndsAt && isTrialing && (
|
||||
<span> (jusqu'au {new Date(sub.trialEndsAt).toLocaleDateString("fr-FR")})</span>
|
||||
)}
|
||||
{sub?.currentPeriodEnd && isActive && (
|
||||
<span> (jusqu'au {new Date(sub.currentPeriodEnd).toLocaleDateString("fr-FR")})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isTrialing || expired) && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
onClick={() => handleSubscribe("basic")}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
S'abonner maintenant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTrialing && daysLeft > 0 && (
|
||||
<div className="relative z-10 mt-6">
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1.5">
|
||||
<span>Jour {30 - daysLeft}</span>
|
||||
<span>{daysLeft} jours restants</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-cyan-500 transition-all"
|
||||
style={{ width: `${((30 - daysLeft) / 30) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plans grid */}
|
||||
<h2 className="font-bold text-2xl mb-4">Choisissez votre plan</h2>
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-8">
|
||||
{PLANS.map((p) => {
|
||||
const Icon = p.icon;
|
||||
const isCurrent = sub?.plan === p.plan;
|
||||
return (
|
||||
<div
|
||||
key={p.plan}
|
||||
className={`relative rounded-3xl p-8 transition-all ${
|
||||
p.highlighted
|
||||
? "bg-gradient-to-br from-emerald-500 to-cyan-500 text-white shadow-2xl md:scale-105"
|
||||
: "glass-card-strong"
|
||||
} ${isCurrent ? "ring-4 ring-emerald-300/60" : ""}`}
|
||||
>
|
||||
{p.highlighted && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-orange-500 text-white text-xs font-bold uppercase tracking-wider shadow-md">
|
||||
Populaire
|
||||
</div>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-3 right-4 px-3 py-1 rounded-full bg-white text-emerald-700 text-xs font-bold uppercase tracking-wider shadow-md">
|
||||
Actuel
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center mb-4 ${
|
||||
p.highlighted ? "bg-white/30" : `bg-gradient-to-br ${p.color}`
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${p.highlighted ? "text-white" : "text-white"}`} />
|
||||
</div>
|
||||
<h3 className={`font-bold text-2xl mb-1 ${p.highlighted ? "text-white" : "text-slate-900"}`}>{p.name}</h3>
|
||||
<p className={`text-sm mb-4 ${p.highlighted ? "text-emerald-50" : "text-slate-500"}`}>{p.description}</p>
|
||||
<div className="flex items-baseline gap-1 mb-6">
|
||||
<span className={`font-black text-4xl ${p.highlighted ? "text-white" : "gradient-text"}`}>{p.price}</span>
|
||||
<span className={`text-sm ${p.highlighted ? "text-emerald-100" : "text-slate-500"}`}>{p.period}</span>
|
||||
</div>
|
||||
<ul className="space-y-2.5 mb-6">
|
||||
{p.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm">
|
||||
<Check className={`w-4 h-4 mt-0.5 flex-shrink-0 ${p.highlighted ? "text-white" : "text-emerald-500"}`} />
|
||||
<span className={p.highlighted ? "text-white" : "text-slate-700"}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{p.plan === "trial" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled
|
||||
>
|
||||
{isCurrent ? "Plan actuel" : "Essai automatique"}
|
||||
</Button>
|
||||
) : isCurrent ? (
|
||||
<Button
|
||||
variant={p.highlighted ? "default" : "outline"}
|
||||
className={`w-full ${p.highlighted ? "bg-white text-emerald-700 hover:bg-emerald-50" : ""}`}
|
||||
disabled
|
||||
>
|
||||
Plan actuel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={`w-full ${
|
||||
p.highlighted
|
||||
? "bg-white text-emerald-700 hover:bg-emerald-50"
|
||||
: "bg-teal-600 hover:bg-teal-700 text-white"
|
||||
}`}
|
||||
onClick={() => handleSubscribe(p.plan)}
|
||||
>
|
||||
S'abonner
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Guarantee */}
|
||||
<div className="glass-card rounded-2xl p-6 text-center">
|
||||
<Heart className="w-8 h-8 text-rose-500 mx-auto mb-3" />
|
||||
<h3 className="font-bold text-lg mb-1">Notre engagement</h3>
|
||||
<p className="text-sm text-slate-600 max-w-2xl mx-auto">
|
||||
Annulation à tout moment. Données hébergées en France. Conformité RGPD.
|
||||
Migration et configuration assistées gratuites.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
client/src/pages/WhatsAppSetup.tsx
Normal file
419
client/src/pages/WhatsAppSetup.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import Layout from "@/components/Layout";
|
||||
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 { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
MessageSquare,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
Send,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Info,
|
||||
Smartphone,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import CountryCodeManager from "@/components/CountryCodeManager";
|
||||
import WhatsAppTemplateEditor from "@/components/WhatsAppTemplateEditor";
|
||||
|
||||
type WAStatus = "disconnected" | "connecting" | "qr_ready" | "connected";
|
||||
|
||||
const STATUS_CONFIG: Record<WAStatus, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
disconnected: {
|
||||
label: "Déconnecté",
|
||||
color: "bg-gray-500/20 text-gray-400 border-gray-500/30",
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
},
|
||||
connecting: {
|
||||
label: "Connexion en cours…",
|
||||
color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
},
|
||||
qr_ready: {
|
||||
label: "En attente du scan",
|
||||
color: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
icon: <QrCode className="w-4 h-4" />,
|
||||
},
|
||||
connected: {
|
||||
label: "Connecté",
|
||||
color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
export default function WhatsAppSetup() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [selectedClinicId, setSelectedClinicId] = useState<number | null>(null);
|
||||
const [testPhone, setTestPhone] = useState("");
|
||||
const [pollingActive, setPollingActive] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const { data: clinics = [] } = trpc.clinic.list.useQuery();
|
||||
|
||||
const { data: waStatus, refetch: refetchStatus } = trpc.whatsapp.status.useQuery(
|
||||
{ clinicId: selectedClinicId ?? 0 },
|
||||
{ enabled: !!selectedClinicId, refetchInterval: pollingActive ? 3000 : false }
|
||||
);
|
||||
|
||||
const connectMut = trpc.whatsapp.connect.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.whatsapp.status.invalidate();
|
||||
if (data.status === "qr_ready") {
|
||||
setPollingActive(true);
|
||||
toast.info("QR code généré — scannez avec WhatsApp");
|
||||
} else if (data.status === "connected") {
|
||||
setPollingActive(false);
|
||||
toast.success("WhatsApp connecté !");
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const disconnectMut = trpc.whatsapp.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
setPollingActive(false);
|
||||
utils.whatsapp.status.invalidate();
|
||||
toast.success("Session WhatsApp déconnectée");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const testMut = trpc.whatsapp.sendTest.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.success) toast.success("Message test envoyé !");
|
||||
else toast.error(`Échec : ${data.error}`);
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// Stop polling once connected
|
||||
useEffect(() => {
|
||||
if (waStatus?.status === "connected") {
|
||||
setPollingActive(false);
|
||||
}
|
||||
}, [waStatus?.status]);
|
||||
|
||||
// Auto-select first clinic
|
||||
useEffect(() => {
|
||||
if (clinics.length > 0 && !selectedClinicId) {
|
||||
setSelectedClinicId(clinics[0].id);
|
||||
}
|
||||
}, [clinics, selectedClinicId]);
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedClinicId) return;
|
||||
connectMut.mutate({ clinicId: selectedClinicId });
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
if (!selectedClinicId) return;
|
||||
disconnectMut.mutate({ clinicId: selectedClinicId });
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
if (!selectedClinicId || !testPhone.trim()) return;
|
||||
testMut.mutate({ clinicId: selectedClinicId, phone: testPhone.trim() });
|
||||
};
|
||||
|
||||
const status: WAStatus = waStatus?.status ?? "disconnected";
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-3xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<MessageSquare className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Notifications WhatsApp</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connectez WhatsApp pour envoyer des alertes automatiques à vos patients
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<Alert className="border-amber-500/30 bg-amber-500/10">
|
||||
<Info className="h-4 w-4 text-amber-400" />
|
||||
<AlertDescription className="text-amber-300 text-sm">
|
||||
<strong>Note :</strong> Cette fonctionnalité utilise WhatsApp Web (protocole non officiel).
|
||||
Limitez l'envoi à moins de 500 messages/jour pour éviter tout risque de restriction.
|
||||
Un numéro WhatsApp personnel ou professionnel est requis.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Clinic selector */}
|
||||
{clinics.length > 1 && (
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Cabinet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{clinics.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
variant={selectedClinicId === c.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedClinicId(c.id)}
|
||||
>
|
||||
{c.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status card */}
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4" />
|
||||
Statut de la connexion
|
||||
</CardTitle>
|
||||
<Badge className={`flex items-center gap-1.5 border ${cfg.color}`}>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* QR Code display */}
|
||||
{status === "qr_ready" && waStatus?.qrCode && (
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="p-4 bg-white rounded-2xl shadow-lg">
|
||||
<img
|
||||
src={waStatus.qrCode}
|
||||
alt="QR Code WhatsApp"
|
||||
className="w-56 h-56"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="font-medium text-foreground flex items-center gap-2 justify-center">
|
||||
<Smartphone className="w-4 h-4 text-emerald-400" />
|
||||
Comment scanner
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 text-left max-w-xs">
|
||||
<li>1. Ouvrez WhatsApp sur votre téléphone</li>
|
||||
<li>2. Appuyez sur ⋮ → <strong>Appareils liés</strong></li>
|
||||
<li>3. Appuyez sur <strong>Lier un appareil</strong></li>
|
||||
<li>4. Scannez ce QR code</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchStatus()}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Actualiser le statut
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected state */}
|
||||
{status === "connected" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-emerald-300">WhatsApp connecté</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Les notifications automatiques sont actives pour ce cabinet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disconnected state */}
|
||||
{(status === "disconnected") && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-muted/30 border border-border/50">
|
||||
<AlertCircle className="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Non connecté</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cliquez sur « Connecter » pour générer un QR code à scanner.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
{status === "disconnected" && (
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={!selectedClinicId || connectMut.isPending}
|
||||
className="gap-2 bg-emerald-600 hover:bg-emerald-500"
|
||||
>
|
||||
{connectMut.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<QrCode className="w-4 h-4" />
|
||||
)}
|
||||
Connecter WhatsApp
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === "qr_ready" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={connectMut.isPending}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Nouveau QR code
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
variant="ghost"
|
||||
className="gap-2 text-muted-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Annuler
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "connecting" && (
|
||||
<Button disabled className="gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Connexion en cours…
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === "connected" && (
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnectMut.isPending}
|
||||
variant="outline"
|
||||
className="gap-2 text-red-400 border-red-500/30 hover:bg-red-500/10"
|
||||
>
|
||||
{disconnectMut.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="w-4 h-4" />
|
||||
)}
|
||||
Déconnecter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test message */}
|
||||
{status === "connected" && (
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
Message de test
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Envoyez un message de test pour vérifier que la connexion fonctionne.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Numéro international (ex: 33612345678)"
|
||||
value={testPhone}
|
||||
onChange={(e) => setTestPhone(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={!testPhone.trim() || testMut.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{testMut.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
Envoyer
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Entrez le numéro sans le + (ex: 33612345678 pour +33 6 12 34 56 78)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* WhatsApp message templates */}
|
||||
{selectedClinicId && (
|
||||
<>
|
||||
<Separator />
|
||||
<WhatsAppTemplateEditor clinicId={selectedClinicId} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Country code management (owner/admin only) */}
|
||||
<CountryCodeManager />
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* How it works */}
|
||||
<Card className="border-border/50 bg-card/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Comment ça fonctionne</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
step: "1",
|
||||
title: "Inscription",
|
||||
desc: "Le patient entre son numéro WhatsApp lors de l'inscription via QR code",
|
||||
color: "text-teal-400",
|
||||
},
|
||||
{
|
||||
step: "2",
|
||||
title: "Alerte bientôt",
|
||||
desc: "Quand il reste 2 patients avant lui, il reçoit un message d'alerte",
|
||||
color: "text-orange-400",
|
||||
},
|
||||
{
|
||||
step: "3",
|
||||
title: "C'est son tour",
|
||||
desc: "Quand le médecin l'appelle, il reçoit immédiatement un message",
|
||||
color: "text-emerald-400",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.step} className="flex gap-3">
|
||||
<div className={`text-2xl font-bold ${item.color} shrink-0`}>{item.step}</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm text-foreground">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
238
client/src/styles.css
Normal file
238
client/src/styles.css
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-display: "Inter", system-ui, -apple-system, sans-serif;
|
||||
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0f172a;
|
||||
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #0f172a;
|
||||
|
||||
--color-primary: #0d9488;
|
||||
--color-primary-foreground: #ffffff;
|
||||
|
||||
--color-secondary: #06b6d4;
|
||||
--color-secondary-foreground: #ffffff;
|
||||
|
||||
--color-accent: #10b981;
|
||||
--color-accent-foreground: #ffffff;
|
||||
|
||||
--color-muted: #f1f5f9;
|
||||
--color-muted-foreground: #64748b;
|
||||
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
|
||||
--color-warning: #f97316;
|
||||
--color-warning-foreground: #ffffff;
|
||||
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #10b981;
|
||||
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.25rem;
|
||||
--radius-3xl: 1.75rem;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(16, 185, 129, 0.08) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, rgba(6, 182, 212, 0.08) 0px, transparent 50%),
|
||||
radial-gradient(at 50% 100%, rgba(16, 185, 129, 0.04) 0px, transparent 60%);
|
||||
color: #0f172a;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ─── Glass cards ─────────────────────────────────────────────────────────── */
|
||||
.glass-card {
|
||||
background-color: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 118, 110, 0.04),
|
||||
0 8px 32px rgba(13, 148, 136, 0.06);
|
||||
}
|
||||
|
||||
.glass-card-strong {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(15, 118, 110, 0.08);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 118, 110, 0.04),
|
||||
0 12px 48px rgba(13, 148, 136, 0.08);
|
||||
}
|
||||
|
||||
/* ─── Gradients ────────────────────────────────────────────────────────────── */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #10b981 0%, #06b6d4 50%, #0d9488 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #34d399 0%, #22d3ee 50%, #2dd4bf 100%);
|
||||
}
|
||||
|
||||
.gradient-bg-soft {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #ecfeff 100%);
|
||||
}
|
||||
|
||||
/* ─── Glow effects ─────────────────────────────────────────────────────────── */
|
||||
.glow-teal {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(13, 148, 136, 0.15),
|
||||
0 8px 24px rgba(13, 148, 136, 0.18);
|
||||
}
|
||||
|
||||
.glow-emerald {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(16, 185, 129, 0.15),
|
||||
0 10px 28px rgba(16, 185, 129, 0.22);
|
||||
}
|
||||
|
||||
/* ─── Status badges (queue) ────────────────────────────────────────────────── */
|
||||
.badge-waiting {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background-color: rgb(207 250 254);
|
||||
color: rgb(14 116 144);
|
||||
border: 1px solid rgb(165 243 252);
|
||||
}
|
||||
|
||||
.badge-called {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background-color: rgb(209 250 229);
|
||||
color: rgb(4 120 87);
|
||||
border: 1px solid rgb(167 243 208);
|
||||
}
|
||||
|
||||
.badge-done {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background-color: rgb(241 245 249);
|
||||
color: rgb(71 85 105);
|
||||
border: 1px solid rgb(226 232 240);
|
||||
}
|
||||
|
||||
.badge-absent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background-color: rgb(255 237 213);
|
||||
color: rgb(154 52 18);
|
||||
border: 1px solid rgb(254 215 170);
|
||||
}
|
||||
|
||||
/* ─── Animations ──────────────────────────────────────────────────────────── */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(16, 185, 129, 0) 0%,
|
||||
rgba(16, 185, 129, 0.15) 50%,
|
||||
rgba(16, 185, 129, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes ticker {
|
||||
0% { transform: translateX(100%); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
.animate-ticker {
|
||||
animation: ticker 30s linear infinite;
|
||||
}
|
||||
|
||||
/* ─── Container ───────────────────────────────────────────────────────────── */
|
||||
.container {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container { max-width: 640px; }
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.container { max-width: 768px; padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.container { max-width: 1024px; }
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.container { max-width: 1200px; }
|
||||
}
|
||||
|
||||
/* ─── Print ───────────────────────────────────────────────────────────────── */
|
||||
@media print {
|
||||
body { background: white !important; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
|
||||
/* ─── Scrollbar (subtle) ──────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(15, 118, 110, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(15, 118, 110, 0.35);
|
||||
}
|
||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
services:
|
||||
db:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-queuemed}
|
||||
MYSQL_USER: ${MYSQL_USER:-queuemed}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-queuemed}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- queuemed
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 5000
|
||||
DATABASE_URL: mysql://${MYSQL_USER:-queuemed}:${MYSQL_PASSWORD:-queuemed}@db:3306/${MYSQL_DATABASE:-queuemed}
|
||||
JWT_SECRET: ${JWT_SECRET:-changeme-in-production}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||
ports:
|
||||
- "5100:5000"
|
||||
networks:
|
||||
- queuemed
|
||||
|
||||
networks:
|
||||
queuemed:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
17
drizzle.config.ts
Normal file
17
drizzle.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is not set — required by drizzle-kit");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./server/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "mysql",
|
||||
dbCredentials: {
|
||||
url: databaseUrl,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
10718
package-lock.json
generated
Normal file
10718
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
92
package.json
Normal file
92
package.json
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"name": "queue-med",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "QueueMed — virtual waiting room for medical practices",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n server,client -c blue,green \"pnpm:dev:server\" \"pnpm:dev:client\"",
|
||||
"dev:server": "tsx watch --env-file=.env server/_core/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build",
|
||||
"start": "NODE_ENV=production tsx server/_core/index.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/boom": "^10.0.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@trpc/client": "11.0.0-rc.660",
|
||||
"@trpc/react-query": "11.0.0-rc.660",
|
||||
"@trpc/server": "11.0.0-rc.660",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.21.2",
|
||||
"framer-motion": "^11.15.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mysql2": "^3.11.5",
|
||||
"nanoid": "^5.0.9",
|
||||
"p-queue": "^9.1.0",
|
||||
"pino": "^10.3.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"wouter": "^3.3.5",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.1",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
18
server/_core/context.ts
Normal file
18
server/_core/context.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||
import { getUserFromRequest } from "../auth.js";
|
||||
import type { User } from "../schema.js";
|
||||
|
||||
export async function createContext({ req, res }: CreateExpressContextOptions) {
|
||||
const user = await getUserFromRequest(req);
|
||||
return {
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
export type TrpcContext = {
|
||||
req: CreateExpressContextOptions["req"];
|
||||
res: CreateExpressContextOptions["res"];
|
||||
user: User | null;
|
||||
};
|
||||
148
server/_core/index.ts
Normal file
148
server/_core/index.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import express from "express";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Server as SocketIOServer } from "socket.io";
|
||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||
import { appRouter } from "../routers.js";
|
||||
import { createContext } from "./context.js";
|
||||
import { authMiddleware } from "../auth.js";
|
||||
import { getDb } from "../db.js";
|
||||
import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const PORT = Number(process.env.PORT ?? 5000);
|
||||
const NODE_ENV = process.env.NODE_ENV ?? "development";
|
||||
const IS_PROD = NODE_ENV === "production";
|
||||
|
||||
async function bootstrap() {
|
||||
// Eagerly initialize database connection (warns early if DATABASE_URL missing)
|
||||
try {
|
||||
await getDb();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[db] connected");
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[db] connection failed:", err);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
// ── Socket.io ────────────────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: {
|
||||
origin: IS_PROD ? false : true,
|
||||
credentials: true,
|
||||
},
|
||||
path: "/socket.io",
|
||||
});
|
||||
(globalThis as unknown as { __socketIo: SocketIOServer }).__socketIo = io;
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
socket.on("clinic:subscribe", (clinicId: number) => {
|
||||
if (typeof clinicId === "number") socket.join(`clinic:${clinicId}`);
|
||||
});
|
||||
socket.on("display:subscribe", (clinicId: number) => {
|
||||
if (typeof clinicId === "number") socket.join(`display:${clinicId}`);
|
||||
});
|
||||
socket.on("patient:subscribe", (patientToken: string) => {
|
||||
if (typeof patientToken === "string" && patientToken.length > 0) {
|
||||
socket.join(`patient:${patientToken}`);
|
||||
}
|
||||
});
|
||||
socket.on("clinic:unsubscribe", (clinicId: number) => {
|
||||
if (typeof clinicId === "number") socket.leave(`clinic:${clinicId}`);
|
||||
});
|
||||
socket.on("display:unsubscribe", (clinicId: number) => {
|
||||
if (typeof clinicId === "number") socket.leave(`display:${clinicId}`);
|
||||
});
|
||||
socket.on("patient:unsubscribe", (patientToken: string) => {
|
||||
if (typeof patientToken === "string") socket.leave(`patient:${patientToken}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Middlewares ──────────────────────────────────────────────────────────
|
||||
app.use(
|
||||
cors({
|
||||
origin: IS_PROD ? false : ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(cookieParser());
|
||||
app.use(authMiddleware);
|
||||
|
||||
// ── Health check ─────────────────────────────────────────────────────────
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ status: "ok", env: NODE_ENV, ts: Date.now() });
|
||||
});
|
||||
|
||||
// ── tRPC ─────────────────────────────────────────────────────────────────
|
||||
app.use(
|
||||
"/api/trpc",
|
||||
createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ error, path }) {
|
||||
if (error.code === "INTERNAL_SERVER_ERROR") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[trpc] ${path}:`, error);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// ── Static client (production) ───────────────────────────────────────────
|
||||
if (IS_PROD) {
|
||||
const clientDist = path.resolve(ROOT, "dist", "client");
|
||||
const indexHtml = path.resolve(clientDist, "index.html");
|
||||
|
||||
if (fs.existsSync(clientDist)) {
|
||||
app.use(express.static(clientDist, { maxAge: "1h", index: false }));
|
||||
app.get("*", (req, res, next) => {
|
||||
if (req.path.startsWith("/api") || req.path.startsWith("/socket.io")) return next();
|
||||
if (!fs.existsSync(indexHtml)) return next();
|
||||
res.sendFile(indexHtml);
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[static] dist/client not found at ${clientDist}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error handler ────────────────────────────────────────────────────────
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[express] error:", err);
|
||||
res.status(500).json({ error: "Internal Server Error" });
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[server] listening on http://0.0.0.0:${PORT} (${NODE_ENV})`);
|
||||
// Démarre le job qui marque les patients absents après N minutes sans réponse
|
||||
startAutoAbsentJob();
|
||||
});
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[server] received ${signal}, shutting down`);
|
||||
stopAutoAbsentJob();
|
||||
io.close();
|
||||
httpServer.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 10_000).unref();
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[server] failed to start:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
43
server/_core/trpc.ts
Normal file
43
server/_core/trpc.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { TrpcContext } from "./context.js";
|
||||
import { isSubscriptionActive } from "../db.js";
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().create({
|
||||
errorFormatter({ shape }) {
|
||||
return shape;
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const middleware = t.middleware;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.user,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||
|
||||
const requireActiveSubscription = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
||||
}
|
||||
const active = await isSubscriptionActive(ctx.user.id);
|
||||
if (!active) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Subscription expired or inactive",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
export const subscriptionProcedure = t.procedure.use(requireActiveSubscription);
|
||||
113
server/auth.ts
Normal file
113
server/auth.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { getUserById } from "./db.js";
|
||||
import type { User } from "./schema.js";
|
||||
|
||||
const COOKIE_NAME = "qm_auth";
|
||||
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
function getJwtSecret(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) throw new Error("JWT_SECRET is not set");
|
||||
return secret;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
email: string;
|
||||
role: "user" | "admin";
|
||||
}
|
||||
|
||||
// ─── Password hashing ────────────────────────────────────────────────────────
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||
export function createToken(user: Pick<User, "id" | "email" | "role">): string {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
return jwt.sign(payload, getJwtSecret(), { expiresIn: "30d" });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, getJwtSecret());
|
||||
if (typeof decoded === "string") return null;
|
||||
if (
|
||||
typeof decoded.sub !== "number" ||
|
||||
typeof decoded.email !== "string" ||
|
||||
(decoded.role !== "user" && decoded.role !== "admin")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { sub: decoded.sub, email: decoded.email, role: decoded.role };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cookie helpers ──────────────────────────────────────────────────────────
|
||||
export function setAuthCookie(res: Response, token: string): void {
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: COOKIE_MAX_AGE_MS,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res: Response): void {
|
||||
res.clearCookie(COOKIE_NAME, { path: "/" });
|
||||
}
|
||||
|
||||
export function readAuthCookie(req: Request): string | null {
|
||||
const cookies = (req as Request & { cookies?: Record<string, string> }).cookies;
|
||||
if (cookies && typeof cookies[COOKIE_NAME] === "string") return cookies[COOKIE_NAME];
|
||||
const header = req.headers.authorization;
|
||||
if (header && header.startsWith("Bearer ")) return header.slice(7);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── User from request ───────────────────────────────────────────────────────
|
||||
export async function getUserFromRequest(req: Request): Promise<User | null> {
|
||||
const token = readAuthCookie(req);
|
||||
if (!token) return null;
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
return getUserById(payload.sub);
|
||||
}
|
||||
|
||||
// ─── Express middleware ──────────────────────────────────────────────────────
|
||||
export interface AuthedRequest extends Request {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export async function authMiddleware(
|
||||
req: AuthedRequest,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (user) req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export const AUTH_COOKIE_NAME = COOKIE_NAME;
|
||||
659
server/db.ts
Normal file
659
server/db.ts
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import { and, desc, eq, gte, inArray, lt, sql } from "drizzle-orm";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
users,
|
||||
subscriptions,
|
||||
clinics,
|
||||
queueEntries,
|
||||
analyticsEvents,
|
||||
whatsappCountryCodes,
|
||||
whatsappLogs,
|
||||
type User,
|
||||
type Subscription,
|
||||
type Clinic,
|
||||
type QueueEntry,
|
||||
type AnalyticsEvent,
|
||||
type InsertUser,
|
||||
type InsertClinic,
|
||||
type InsertQueueEntry,
|
||||
type InsertWhatsappLog,
|
||||
} from "./schema.js";
|
||||
|
||||
// ─── Connection pool (singleton) ─────────────────────────────────────────────
|
||||
let pool: mysql.Pool | null = null;
|
||||
let dbInstance: MySql2Database<{
|
||||
users: typeof users;
|
||||
subscriptions: typeof subscriptions;
|
||||
clinics: typeof clinics;
|
||||
queueEntries: typeof queueEntries;
|
||||
analyticsEvents: typeof analyticsEvents;
|
||||
whatsappCountryCodes: typeof whatsappCountryCodes;
|
||||
whatsappLogs: typeof whatsappLogs;
|
||||
}> | null = null;
|
||||
|
||||
export async function getDb() {
|
||||
if (dbInstance) return dbInstance;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
pool = mysql.createPool({
|
||||
uri: url,
|
||||
connectionLimit: 10,
|
||||
waitForConnections: true,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 10_000,
|
||||
});
|
||||
|
||||
dbInstance = drizzle(pool, {
|
||||
schema: { users, subscriptions, clinics, queueEntries, analyticsEvents, whatsappCountryCodes, whatsappLogs },
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export async function closeDb() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Users ───────────────────────────────────────────────────────────────────
|
||||
export async function getUserByEmail(email: string): Promise<User | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getUserById(id: number): Promise<User | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getUserByOpenId(openId: string): Promise<User | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createUser(data: InsertUser): Promise<User> {
|
||||
const db = await getDb();
|
||||
const [result] = await db.insert(users).values(data);
|
||||
const id = (result as { insertId: number }).insertId;
|
||||
const created = await getUserById(id);
|
||||
if (!created) throw new Error("Failed to create user");
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function upsertUser(data: InsertUser): Promise<User> {
|
||||
const existing = await getUserByEmail(data.email);
|
||||
if (existing) {
|
||||
const db = await getDb();
|
||||
await db
|
||||
.update(users)
|
||||
.set({ ...data, lastSignedIn: new Date() })
|
||||
.where(eq(users.id, existing.id));
|
||||
const refreshed = await getUserById(existing.id);
|
||||
if (!refreshed) throw new Error("Failed to refresh user");
|
||||
return refreshed;
|
||||
}
|
||||
return createUser(data);
|
||||
}
|
||||
|
||||
export async function touchUserLogin(userId: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
// ─── Subscriptions ───────────────────────────────────────────────────────────
|
||||
const TRIAL_DAYS = 30;
|
||||
|
||||
export async function createTrialSubscription(userId: number): Promise<Subscription> {
|
||||
const db = await getDb();
|
||||
const trialStart = new Date();
|
||||
const trialEnd = new Date(trialStart.getTime() + TRIAL_DAYS * 24 * 60 * 60 * 1000);
|
||||
await db.insert(subscriptions).values({
|
||||
userId,
|
||||
plan: "trial",
|
||||
status: "trialing",
|
||||
trialStartedAt: trialStart,
|
||||
trialEndsAt: trialEnd,
|
||||
});
|
||||
const sub = await getSubscription(userId);
|
||||
if (!sub) throw new Error("Failed to create trial subscription");
|
||||
return sub;
|
||||
}
|
||||
|
||||
export async function getSubscription(userId: number): Promise<Subscription | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.userId, userId))
|
||||
.orderBy(desc(subscriptions.createdAt))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function updateSubscription(
|
||||
userId: number,
|
||||
patch: Partial<typeof subscriptions.$inferInsert>
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.update(subscriptions).set(patch).where(eq(subscriptions.userId, userId));
|
||||
}
|
||||
|
||||
export async function isSubscriptionActive(userId: number): Promise<boolean> {
|
||||
const sub = await getSubscription(userId);
|
||||
if (!sub) return false;
|
||||
const now = Date.now();
|
||||
if (sub.status === "canceled" || sub.status === "expired") return false;
|
||||
if (sub.status === "trialing") {
|
||||
return sub.trialEndsAt.getTime() > now;
|
||||
}
|
||||
if (sub.status === "active") {
|
||||
if (!sub.currentPeriodEnd) return true;
|
||||
return sub.currentPeriodEnd.getTime() > now;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Clinics ─────────────────────────────────────────────────────────────────
|
||||
function generateQrToken(): string {
|
||||
return crypto.randomBytes(24).toString("hex");
|
||||
}
|
||||
|
||||
function computeQrExpiry(rotationMinutes: number | null | undefined): Date | null {
|
||||
if (!rotationMinutes || rotationMinutes <= 0) return null;
|
||||
return new Date(Date.now() + rotationMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
export async function getClinics(userId: number): Promise<Clinic[]> {
|
||||
const db = await getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(clinics)
|
||||
.where(eq(clinics.userId, userId))
|
||||
.orderBy(desc(clinics.createdAt));
|
||||
}
|
||||
|
||||
export async function getClinicById(id: number): Promise<Clinic | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select().from(clinics).where(eq(clinics.id, id)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getClinicByQrToken(token: string): Promise<Clinic | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select().from(clinics).where(eq(clinics.qrToken, token)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createClinic(
|
||||
userId: number,
|
||||
data: Omit<InsertClinic, "userId" | "qrToken" | "qrTokenExpiresAt">
|
||||
): Promise<{ insertId: number; qrToken: string }> {
|
||||
const db = await getDb();
|
||||
const qrToken = generateQrToken();
|
||||
const qrTokenExpiresAt = computeQrExpiry(data.qrRotationMinutes ?? 30);
|
||||
const [result] = await db.insert(clinics).values({
|
||||
...data,
|
||||
userId,
|
||||
qrToken,
|
||||
qrTokenExpiresAt,
|
||||
});
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
return { insertId, qrToken };
|
||||
}
|
||||
|
||||
export async function updateClinic(
|
||||
id: number,
|
||||
patch: Partial<typeof clinics.$inferInsert>
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.update(clinics).set(patch).where(eq(clinics.id, id));
|
||||
}
|
||||
|
||||
export async function deleteClinic(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete(queueEntries).where(eq(queueEntries.clinicId, id));
|
||||
await db.delete(analyticsEvents).where(eq(analyticsEvents.clinicId, id));
|
||||
await db.delete(clinics).where(eq(clinics.id, id));
|
||||
}
|
||||
|
||||
export async function rotateQrToken(clinicId: number): Promise<{ qrToken: string; qrTokenExpiresAt: Date | null }> {
|
||||
const db = await getDb();
|
||||
const clinic = await getClinicById(clinicId);
|
||||
if (!clinic) throw new Error("Clinic not found");
|
||||
const qrToken = generateQrToken();
|
||||
const qrTokenExpiresAt = computeQrExpiry(clinic.qrRotationMinutes);
|
||||
await db
|
||||
.update(clinics)
|
||||
.set({ qrToken, qrTokenExpiresAt })
|
||||
.where(eq(clinics.id, clinicId));
|
||||
return { qrToken, qrTokenExpiresAt };
|
||||
}
|
||||
|
||||
export async function ensureFreshQrToken(clinic: Clinic): Promise<Clinic> {
|
||||
if (!clinic.qrRotationMinutes || clinic.qrRotationMinutes <= 0) return clinic;
|
||||
if (clinic.qrTokenExpiresAt && clinic.qrTokenExpiresAt.getTime() > Date.now()) return clinic;
|
||||
await rotateQrToken(clinic.id);
|
||||
const refreshed = await getClinicById(clinic.id);
|
||||
return refreshed ?? clinic;
|
||||
}
|
||||
|
||||
// ─── Queue ───────────────────────────────────────────────────────────────────
|
||||
type QueueStatus = (typeof queueEntries.$inferSelect)["status"];
|
||||
const ACTIVE_STATUSES: QueueStatus[] = ["waiting", "called", "in_consultation"];
|
||||
|
||||
export async function getActiveQueue(clinicId: number): Promise<QueueEntry[]> {
|
||||
const db = await getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(queueEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(queueEntries.clinicId, clinicId),
|
||||
inArray(queueEntries.status, ACTIVE_STATUSES)
|
||||
)
|
||||
)
|
||||
.orderBy(queueEntries.position);
|
||||
}
|
||||
|
||||
export async function getAllQueueEntries(clinicId: number): Promise<QueueEntry[]> {
|
||||
const db = await getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(queueEntries)
|
||||
.where(eq(queueEntries.clinicId, clinicId))
|
||||
.orderBy(queueEntries.position);
|
||||
}
|
||||
|
||||
export async function getQueueEntry(id: number): Promise<QueueEntry | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select().from(queueEntries).where(eq(queueEntries.id, id)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getQueueEntryByToken(token: string): Promise<QueueEntry | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(queueEntries)
|
||||
.where(eq(queueEntries.patientToken, token))
|
||||
.orderBy(desc(queueEntries.createdAt))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function addToQueue(input: {
|
||||
clinicId: number;
|
||||
patientName?: string | null;
|
||||
patientPhone?: string | null;
|
||||
whatsappPhone?: string | null;
|
||||
visitReason?:
|
||||
| "consultation"
|
||||
| "urgence"
|
||||
| "certificat_scolaire"
|
||||
| "certificat_sportif"
|
||||
| "arret_travail"
|
||||
| "administratif"
|
||||
| "autre"
|
||||
| null;
|
||||
visitNote?: string | null;
|
||||
isPrinted?: boolean;
|
||||
}): Promise<{ entry: QueueEntry; ticketNumber: number; patientToken: string }> {
|
||||
const db = await getDb();
|
||||
const clinic = await getClinicById(input.clinicId);
|
||||
if (!clinic) throw new Error("Clinic not found");
|
||||
if (!clinic.isQueueOpen) throw new Error("Queue is closed");
|
||||
|
||||
const active = await getActiveQueue(input.clinicId);
|
||||
if (clinic.maxQueueSize && active.length >= clinic.maxQueueSize) {
|
||||
throw new Error("Queue is full");
|
||||
}
|
||||
|
||||
const ticketNumber = (clinic.currentTicketNumber ?? 0) + 1;
|
||||
const patientToken = crypto.randomBytes(24).toString("hex");
|
||||
const position = active.length + 1;
|
||||
const estimatedWaitMinutes = (clinic.avgConsultationMinutes ?? 15) * (position - 1);
|
||||
|
||||
const insertValues: InsertQueueEntry = {
|
||||
clinicId: input.clinicId,
|
||||
ticketNumber,
|
||||
patientToken,
|
||||
patientName: input.patientName ?? null,
|
||||
patientPhone: input.patientPhone ?? null,
|
||||
whatsappPhone: input.whatsappPhone ?? null,
|
||||
visitReason: input.visitReason ?? "consultation",
|
||||
visitNote: input.visitNote ?? null,
|
||||
status: "waiting",
|
||||
position,
|
||||
estimatedWaitMinutes,
|
||||
isPrinted: input.isPrinted ?? false,
|
||||
};
|
||||
|
||||
const [result] = await db.insert(queueEntries).values(insertValues);
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
|
||||
await db
|
||||
.update(clinics)
|
||||
.set({ currentTicketNumber: ticketNumber })
|
||||
.where(eq(clinics.id, input.clinicId));
|
||||
|
||||
const entry = await getQueueEntry(insertId);
|
||||
if (!entry) throw new Error("Failed to create queue entry");
|
||||
return { entry, ticketNumber, patientToken };
|
||||
}
|
||||
|
||||
export async function updateQueueEntry(
|
||||
id: number,
|
||||
patch: Partial<typeof queueEntries.$inferInsert>
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.update(queueEntries).set(patch).where(eq(queueEntries.id, id));
|
||||
}
|
||||
|
||||
export async function reorderQueue(clinicId: number): Promise<QueueEntry[]> {
|
||||
const db = await getDb();
|
||||
const active = await db
|
||||
.select()
|
||||
.from(queueEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(queueEntries.clinicId, clinicId),
|
||||
eq(queueEntries.status, "waiting")
|
||||
)
|
||||
)
|
||||
.orderBy(queueEntries.position, queueEntries.joinedAt);
|
||||
|
||||
const clinic = await getClinicById(clinicId);
|
||||
const avg = clinic?.avgConsultationMinutes ?? 15;
|
||||
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
const entry = active[i];
|
||||
const newPosition = i + 1;
|
||||
const newWait = avg * (newPosition - 1);
|
||||
if (entry.position !== newPosition || entry.estimatedWaitMinutes !== newWait) {
|
||||
await db
|
||||
.update(queueEntries)
|
||||
.set({ position: newPosition, estimatedWaitMinutes: newWait })
|
||||
.where(eq(queueEntries.id, entry.id));
|
||||
}
|
||||
}
|
||||
|
||||
return getActiveQueue(clinicId);
|
||||
}
|
||||
|
||||
export async function setQueueOrder(
|
||||
clinicId: number,
|
||||
orderedIds: number[]
|
||||
): Promise<QueueEntry[]> {
|
||||
const db = await getDb();
|
||||
const clinic = await getClinicById(clinicId);
|
||||
const avg = clinic?.avgConsultationMinutes ?? 15;
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
const id = orderedIds[i];
|
||||
const newPosition = i + 1;
|
||||
const newWait = avg * (newPosition - 1);
|
||||
await db
|
||||
.update(queueEntries)
|
||||
.set({ position: newPosition, estimatedWaitMinutes: newWait })
|
||||
.where(and(eq(queueEntries.id, id), eq(queueEntries.clinicId, clinicId)));
|
||||
}
|
||||
return getActiveQueue(clinicId);
|
||||
}
|
||||
|
||||
export async function resetQueue(clinicId: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db
|
||||
.update(queueEntries)
|
||||
.set({ status: "canceled" })
|
||||
.where(
|
||||
and(
|
||||
eq(queueEntries.clinicId, clinicId),
|
||||
inArray(queueEntries.status, ACTIVE_STATUSES)
|
||||
)
|
||||
);
|
||||
await db
|
||||
.update(clinics)
|
||||
.set({ currentTicketNumber: 0 })
|
||||
.where(eq(clinics.id, clinicId));
|
||||
}
|
||||
|
||||
// ─── Analytics ───────────────────────────────────────────────────────────────
|
||||
export async function logAnalyticsEvent(
|
||||
data: typeof analyticsEvents.$inferInsert
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const now = new Date();
|
||||
await db.insert(analyticsEvents).values({
|
||||
...data,
|
||||
hourOfDay: data.hourOfDay ?? now.getHours(),
|
||||
dayOfWeek: data.dayOfWeek ?? now.getDay(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAnalytics(
|
||||
userId: number,
|
||||
options: { days?: number; clinicId?: number } = {}
|
||||
): Promise<AnalyticsEvent[]> {
|
||||
const db = await getDb();
|
||||
const days = options.days ?? 30;
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const userClinics = await getClinics(userId);
|
||||
if (userClinics.length === 0) return [];
|
||||
|
||||
const clinicIds = options.clinicId
|
||||
? userClinics.filter((c) => c.id === options.clinicId).map((c) => c.id)
|
||||
: userClinics.map((c) => c.id);
|
||||
|
||||
if (clinicIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(analyticsEvents)
|
||||
.where(
|
||||
and(
|
||||
inArray(analyticsEvents.clinicId, clinicIds),
|
||||
gte(analyticsEvents.createdAt, since)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(analyticsEvents.createdAt));
|
||||
}
|
||||
|
||||
export async function getAnalyticsForClinic(
|
||||
clinicId: number,
|
||||
days = 30
|
||||
): Promise<AnalyticsEvent[]> {
|
||||
const db = await getDb();
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
return db
|
||||
.select()
|
||||
.from(analyticsEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(analyticsEvents.clinicId, clinicId),
|
||||
gte(analyticsEvents.createdAt, since)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(analyticsEvents.createdAt));
|
||||
}
|
||||
|
||||
// ─── WhatsApp helpers ────────────────────────────────────────────────────────
|
||||
export async function getWaitingEntriesWithPhone(clinicId: number): Promise<QueueEntry[]> {
|
||||
const db = await getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(queueEntries)
|
||||
.where(and(eq(queueEntries.clinicId, clinicId), eq(queueEntries.status, "waiting")))
|
||||
.orderBy(queueEntries.position);
|
||||
}
|
||||
|
||||
/** Masque un numéro de téléphone pour la confidentialité */
|
||||
export function maskPhone(phone: string): string {
|
||||
const cleaned = phone.replace(/[^\d+]/g, "");
|
||||
if (cleaned.length <= 4) return "****";
|
||||
const visibleStart = cleaned.slice(0, Math.min(4, cleaned.length - 2));
|
||||
const visibleEnd = cleaned.slice(-2);
|
||||
const hidden = "*".repeat(Math.max(0, cleaned.length - visibleStart.length - visibleEnd.length));
|
||||
return `${visibleStart}${hidden}${visibleEnd}`;
|
||||
}
|
||||
|
||||
export async function insertWhatsAppLog(
|
||||
data: Omit<InsertWhatsappLog, "id" | "createdAt">
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
try {
|
||||
await db.insert(whatsappLogs).values(data);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[WhatsApp Log] Failed to insert log:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWhatsAppLogs(
|
||||
clinicId: number,
|
||||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
messageType?: "joined" | "soon" | "called" | "withdrawn" | "test";
|
||||
status?: "sent" | "failed";
|
||||
} = {}
|
||||
) {
|
||||
const db = await getDb();
|
||||
const { limit = 20, offset = 0, messageType, status } = options;
|
||||
const conditions = [eq(whatsappLogs.clinicId, clinicId)];
|
||||
if (messageType) conditions.push(eq(whatsappLogs.messageType, messageType));
|
||||
if (status) conditions.push(eq(whatsappLogs.status, status));
|
||||
|
||||
const [rows, countRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(whatsappLogs)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(whatsappLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`COUNT(*)` }).from(whatsappLogs).where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return { logs: rows, total: countRows[0]?.count ?? 0 };
|
||||
}
|
||||
|
||||
// ─── Consultation history & stats ────────────────────────────────────────────
|
||||
export async function getConsultationHistory(
|
||||
clinicId: number,
|
||||
opts: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
visitReason?: string;
|
||||
} = {}
|
||||
): Promise<{ entries: QueueEntry[]; total: number }> {
|
||||
const db = await getDb();
|
||||
const { page = 1, perPage = 20, dateFrom, dateTo, visitReason } = opts;
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const conditions = [
|
||||
eq(queueEntries.clinicId, clinicId),
|
||||
inArray(queueEntries.status, ["done", "absent", "canceled"] as const),
|
||||
];
|
||||
if (dateFrom) conditions.push(gte(queueEntries.joinedAt, dateFrom));
|
||||
if (dateTo) {
|
||||
const endOfDay = new Date(dateTo);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
conditions.push(lt(queueEntries.joinedAt, endOfDay));
|
||||
}
|
||||
if (visitReason) {
|
||||
conditions.push(
|
||||
eq(
|
||||
queueEntries.visitReason,
|
||||
visitReason as "consultation" | "urgence" | "certificat_scolaire" | "certificat_sportif" | "arret_travail" | "administratif" | "autre"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const where = and(...conditions);
|
||||
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(queueEntries)
|
||||
.where(where!)
|
||||
.orderBy(desc(queueEntries.joinedAt))
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
|
||||
const countResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(queueEntries)
|
||||
.where(where!);
|
||||
const total = Number(countResult[0]?.count ?? 0);
|
||||
|
||||
return { entries, total };
|
||||
}
|
||||
|
||||
export async function getConsultationStats(
|
||||
clinicId: number,
|
||||
days = 30
|
||||
): Promise<{
|
||||
totalConsultations: number;
|
||||
avgDurationMinutes: number;
|
||||
presenceRate: number;
|
||||
topReasons: { reason: string; count: number }[];
|
||||
}> {
|
||||
const db = await getDb();
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const completed = await db
|
||||
.select()
|
||||
.from(queueEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(queueEntries.clinicId, clinicId),
|
||||
inArray(queueEntries.status, ["done", "absent", "canceled"] as const),
|
||||
gte(queueEntries.joinedAt, since)
|
||||
)
|
||||
);
|
||||
|
||||
const doneEntries = completed.filter((e) => e.status === "done");
|
||||
const absentEntries = completed.filter((e) => e.status === "absent");
|
||||
const totalConsultations = completed.length;
|
||||
|
||||
const durations = doneEntries
|
||||
.filter((e) => e.consultationStartedAt && e.consultationEndAt)
|
||||
.map(
|
||||
(e) => (e.consultationEndAt!.getTime() - e.consultationStartedAt!.getTime()) / 60000
|
||||
);
|
||||
const avgDurationMinutes =
|
||||
durations.length > 0
|
||||
? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)
|
||||
: 0;
|
||||
|
||||
const presenceRate =
|
||||
totalConsultations > 0
|
||||
? Math.round(((totalConsultations - absentEntries.length) / totalConsultations) * 100)
|
||||
: 100;
|
||||
|
||||
const reasonCounts: Record<string, number> = {};
|
||||
completed.forEach((e) => {
|
||||
const r = e.visitReason ?? "consultation";
|
||||
reasonCounts[r] = (reasonCounts[r] ?? 0) + 1;
|
||||
});
|
||||
const topReasons = Object.entries(reasonCounts)
|
||||
.map(([reason, count]) => ({ reason, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { totalConsultations, avgDurationMinutes, presenceRate, topReasons };
|
||||
}
|
||||
1358
server/routers.ts
Normal file
1358
server/routers.ts
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue