Document de Conception — Task Management System (Frontend)
1. Introduction et Choix Architectural
Vue d’ensemble
Le frontend du Task Management System est une application web monopage (SPA) construite selon l’approche Jamstack (JavaScript, APIs, Markup). L’interface permet aux utilisateurs de gérer des tâches, des projets et des équipes via une communication exclusive avec l’API RESTful du backend.
L’application est entièrement découplée du backend :
vite build produit des fichiers statiques purs (HTML,
JS, CSS) déployables sur n’importe quel CDN ou serveur de fichiers
statiques. Zéro serveur côté frontend — toute l’interactivité est
gérée par JavaScript côté client.
Stack technique
| Technologie | Rôle | Justification |
|---|---|---|
| React 18+ | Bibliothèque UI | Écosystème mature, composants réutilisables, hooks |
| TypeScript | Typage statique | Sécurité du code, autocomplétion, types partagés avec le backend |
| Vite | Bundler / Dev server | Build rapide, HMR instantané, configuration minimale |
| Shadcn/UI (Radix UI) | Composants UI | Accessibilité native (WAI-ARIA), personnalisable, non-opinionated |
| TanStack Query | Data fetching / Cache | Cache intelligent, revalidation automatique, gestion des états serveur |
| Zustand | State management client | Léger, API simple, pas de boilerplate |
| React Router | Routing | Standard de facto pour le routing SPA React |
| Tailwind CSS | Styling | Utility-first, responsive design intégré, cohérence visuelle |
| Playwright | Tests E2E | Tests cross-browser, API puissante, recommandé par les standards projet |
Principes Jamstack appliqués
- J — JavaScript : React gère toute l’interactivité côté client
- A — APIs : TanStack Query structure la communication avec le backend REST
- M — Markup : Vite produit le HTML statique initial, React hydrate le DOM
- Découplage total : Le frontend ne connaît que les URLs de l’API, aucune dépendance serveur
- Déploiement statique :
vite build→ dossierdist/→ CDN
2. Architecture Globale
Diagramme d’architecture
graph TD
subgraph "Frontend (SPA Statique)"
Router["React Router\n(Routes & Navigation)"]
Pages["Pages\n(TasksPage, ProjectsPage, etc.)"]
Components["Composants UI\n(Shadcn/UI + Custom)"]
Hooks["Hooks TanStack Query\n(useTasksQuery, etc.)"]
Stores["Zustand Stores\n(authStore, uiStore)"]
ApiClient["Client API\n(fetch + intercepteurs)"]
Validators["Validation Client\n(Zod schemas)"]
end
subgraph "Backend (API REST)"
API["API RESTful\nExpress + PostgreSQL"]
end
CDN["CDN / Serveur Statique\n(fichiers HTML, JS, CSS)"]
CDN -->|"Sert les fichiers statiques"| Router
Router --> Pages
Pages --> Components
Pages --> Hooks
Pages --> Stores
Hooks --> ApiClient
ApiClient -->|"HTTP (JSON)"| API
Components --> Validators
Diagramme de flux de données
sequenceDiagram
participant U as Utilisateur
participant C as Composant React
participant TQ as TanStack Query
participant Z as Zustand Store
participant API as Backend API
U->>C: Interaction (clic, saisie)
C->>Z: Lecture/écriture state local
C->>TQ: Appel hook (useQuery/useMutation)
TQ->>API: Requête HTTP (GET/POST/PATCH/DELETE)
API-->>TQ: Réponse JSON
TQ-->>TQ: Mise à jour du cache
TQ-->>C: Données mises à jour
C-->>U: Re-rendu de l'interface
Flux d’authentification (Release 2+ — Keycloak OIDC)
sequenceDiagram
participant U as Utilisateur
participant F as Frontend SPA
participant K as Keycloak (auth.localhost)
participant API as Backend API
U->>F: Navigation vers /tasks
F->>F: Pas de token → redirection /login
F->>K: Redirect vers /realms/task-management/auth (PKCE)
K-->>U: Page de connexion Keycloak
U->>K: Email + mot de passe
K-->>F: Redirect avec authorization_code
F->>K: POST /token (code + code_verifier)
K-->>F: access_token (JWT RS256) + refresh_token
F->>API: POST /api/auth/login { keycloak_token }
API-->>API: Valider JWT via JWKS Keycloak
API-->>F: 200 { user, session_token }
F->>F: Stocker session_token dans localStorage
F-->>U: Redirection vers /tasks
3. Structure des Dossiers
frontend/
├── public/
│ └── favicon.ico
├── src/
│ ├── api/
│ │ ├── client.ts # Client HTTP (fetch wrapper + intercepteurs)
│ │ ├── tasks.ts # Fonctions API pour les tâches
│ │ ├── projects.ts # Fonctions API pour les projets
│ │ ├── users.ts # Fonctions API pour les utilisateurs
│ │ ├── teams.ts # Fonctions API pour les équipes
│ │ ├── assignments.ts # Fonctions API pour les attributions
│ │ └── notifications.ts # Fonctions API pour les notifications
│ ├── components/
│ │ ├── ui/ # Composants Shadcn/UI (Button, Input, Dialog, etc.)
│ │ ├── layout/
│ │ │ ├── AppLayout.tsx # Layout principal (sidebar + header + contenu)
│ │ │ ├── Sidebar.tsx # Navigation latérale
│ │ │ └── Header.tsx # Barre supérieure (user info, notifications)
│ │ ├── tasks/
│ │ │ ├── TaskList.tsx # Liste des tâches (tableau/cartes)
│ │ │ ├── TaskCard.tsx # Carte individuelle d'une tâche
│ │ │ ├── TaskForm.tsx # Formulaire création/édition de tâche
│ │ │ ├── TaskStatusBadge.tsx # Badge de statut coloré
│ │ │ ├── TaskPriorityBadge.tsx # Badge de priorité
│ │ │ └── TaskFilters.tsx # Filtres et tri des tâches
│ │ ├── projects/
│ │ │ ├── ProjectList.tsx # Liste des projets
│ │ │ ├── ProjectCard.tsx # Carte individuelle d'un projet
│ │ │ └── ProjectForm.tsx # Formulaire création/édition de projet
│ │ ├── teams/
│ │ │ ├── TeamList.tsx # Liste des équipes
│ │ │ ├── TeamCard.tsx # Carte individuelle d'une équipe
│ │ │ └── TeamForm.tsx # Formulaire création/édition d'équipe
│ │ ├── dashboard/
│ │ │ ├── StatusCounters.tsx # Compteurs par statut
│ │ │ ├── CompletionRate.tsx # Taux de complétion
│ │ │ └── OverdueTasks.tsx # Liste des tâches en retard
│ │ └── common/
│ │ ├── ProtectedRoute.tsx # Guard de route basé sur les rôles
│ │ ├── ErrorBoundary.tsx # Gestion des erreurs React
│ │ ├── LoadingSpinner.tsx # Indicateur de chargement
│ │ └── EmptyState.tsx # État vide (aucune donnée)
│ ├── hooks/
│ │ ├── useTasks.ts # Hooks TanStack Query pour les tâches
│ │ ├── useProjects.ts # Hooks TanStack Query pour les projets
│ │ ├── useUsers.ts # Hooks TanStack Query pour les utilisateurs
│ │ ├── useTeams.ts # Hooks TanStack Query pour les équipes
│ │ ├── useAssignments.ts # Hooks TanStack Query pour les attributions
│ │ ├── useNotifications.ts # Hooks TanStack Query pour les notifications
│ │ └── useDashboard.ts # Hooks TanStack Query pour le tableau de bord
│ ├── stores/
│ │ ├── authStore.ts # State d'authentification (user, token, rôles)
│ │ └── uiStore.ts # State UI (sidebar ouverte, thème, filtres actifs)
│ ├── types/
│ │ └── index.ts # Types frontend (miroir des types backend + types UI)
│ ├── lib/
│ │ ├── validators.ts # Schémas Zod pour la validation côté client
│ │ ├── permissions.ts # Utilitaires de vérification des permissions
│ │ ├── date-utils.ts # Utilitaires de formatage de dates
│ │ └── constants.ts # Constantes (statuts, priorités, rôles, URLs API)
│ ├── pages/
│ │ ├── LoginPage.tsx # Page de connexion
│ │ ├── TasksPage.tsx # Page liste des tâches
│ │ ├── TaskDetailPage.tsx # Page détail d'une tâche
│ │ ├── ProjectsPage.tsx # Page liste des projets
│ │ ├── ProjectDetailPage.tsx # Page détail d'un projet (avec ses tâches)
│ │ ├── TeamsPage.tsx # Page gestion des équipes
│ │ ├── DashboardPage.tsx # Page tableau de bord
│ │ ├── NotificationsPage.tsx # Page notifications
│ │ └── NotFoundPage.tsx # Page 404
│ ├── router.tsx # Configuration React Router
│ ├── App.tsx # Composant racine (providers)
│ └── main.tsx # Point d'entrée (ReactDOM.createRoot)
├── tests/
│ ├── unit/
│ │ ├── lib/ # Tests unitaires TDD pour validators, permissions, utilitaires
│ │ └── stores/ # Tests unitaires TDD pour les stores Zustand
│ └── e2e/
│ ├── tasks.spec.ts # Tests E2E tâches
│ ├── projects.spec.ts # Tests E2E projets
│ ├── auth.spec.ts # Tests E2E authentification
│ ├── dashboard.spec.ts # Tests E2E tableau de bord
│ └── fixtures/
│ └── test-data.ts # Données de test partagées
├── index.html
├── vite.config.ts
├── tailwind.config.ts
├── tsconfig.json
├── playwright.config.ts
└── package.json
4. Couche API (Data Fetching)
Client HTTP
Le client HTTP centralise la configuration des requêtes vers le
backend. Il utilise l’API native fetch avec des
intercepteurs pour l’authentification et la gestion des erreurs.
// src/api/client.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
/**
* Client HTTP centralisé pour les appels API.
* Ajoute automatiquement le token JWT et gère les erreurs.
*/
async function apiClient<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = useAuthStore.getState().token;
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new ApiClientError(error.error, error.code, response.status, error.details);
}
// HTTP 204 No Content
if (response.status === 204) return undefined as T;
return response.json();
}Hooks TanStack Query — Tâches
// src/hooks/useTasks.ts
/** Récupère la liste des tâches avec filtres optionnels */
function useTasksQuery(filters?: TaskFilters) {
return useQuery({
queryKey: ['tasks', filters],
queryFn: () => fetchTasks(filters),
staleTime: 30_000, // 30 secondes avant revalidation
});
}
/** Récupère une tâche par son ID */
function useTaskQuery(taskId: string) {
return useQuery({
queryKey: ['tasks', taskId],
queryFn: () => fetchTask(taskId),
enabled: !!taskId,
});
}
/** Crée une nouvelle tâche */
function useCreateTaskMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTaskInput) => createTask(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
});
}
/** Met à jour une tâche existante */
function useUpdateTaskMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTaskInput }) =>
updateTask(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', id] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
});
}
/** Supprime une tâche */
function useDeleteTaskMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (taskId: string) => deleteTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
});
}Hooks TanStack Query — Projets
// src/hooks/useProjects.ts
/** Récupère la liste des projets */
function useProjectsQuery() {
return useQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
staleTime: 60_000,
});
}
/** Récupère les tâches d'un projet spécifique */
function useProjectTasksQuery(projectId: string) {
return useQuery({
queryKey: ['projects', projectId, 'tasks'],
queryFn: () => fetchProjectTasks(projectId),
enabled: !!projectId,
});
}
/** Crée un nouveau projet */
function useCreateProjectMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProjectInput) => createProject(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
}Fonctions API
// src/api/tasks.ts
/** GET /api/tasks — Liste des tâches avec filtres optionnels (paginé) */
async function fetchTasks(filters?: TaskFilters): Promise<PaginatedResponse<Task>> {
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
if (filters?.priority) params.set('priority', filters.priority);
if (filters?.projectId) params.set('projectId', filters.projectId);
if (filters?.assignedTo) params.set('assignedTo', filters.assignedTo);
if (filters?.sortBy) params.set('sortBy', filters.sortBy);
if (filters?.page) params.set('page', String(filters.page));
if (filters?.limit) params.set('limit', String(filters.limit));
const query = params.toString();
return apiClient<PaginatedResponse<Task>>(`/tasks${query ? `?${query}` : ''}`);
}
/** POST /api/tasks — Création d'une tâche */
async function createTask(data: CreateTaskInput): Promise<ApiResponseWithWarnings<Task>> {
return apiClient<ApiResponseWithWarnings<Task>>('/tasks', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** PATCH /api/tasks/:id — Mise à jour d'une tâche */
async function updateTask(id: string, data: UpdateTaskInput): Promise<Task> {
return apiClient<Task>(`/tasks/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
/** DELETE /api/tasks/:id — Suppression d'une tâche */
async function deleteTask(id: string): Promise<void> {
return apiClient<void>(`/tasks/${id}`, { method: 'DELETE' });
}
/** GET /api/tasks/:id — Détail d'une tâche */
async function fetchTask(id: string): Promise<Task> {
return apiClient<Task>(`/tasks/${id}`);
}// src/api/projects.ts
/** GET /api/projects — Liste des projets */
async function fetchProjects(): Promise<Project[]> {
return apiClient<Project[]>('/projects');
}
/** GET /api/projects/:id/tasks — Tâches d'un projet */
async function fetchProjectTasks(projectId: string): Promise<Task[]> {
return apiClient<Task[]>(`/projects/${projectId}/tasks`);
}
/** POST /api/projects — Création d'un projet */
async function createProject(data: CreateProjectInput): Promise<Project> {
return apiClient<Project>('/projects', {
method: 'POST',
body: JSON.stringify(data),
});
}Stratégie de cache TanStack Query
| Ressource | queryKey |
staleTime |
Invalidation |
|---|---|---|---|
| Liste des tâches (paginé) | ['tasks', filters] |
30s | Après création/modification/suppression de tâche |
| Détail d’une tâche | ['tasks', id] |
30s | Après modification de cette tâche |
| Tâches d’un projet (paginé) | ['projects', id, 'tasks'] |
30s | Après modification de tâche dans ce projet |
| Liste des projets (paginé) | ['projects'] |
60s | Après création/modification de projet |
| Tableau de bord | ['dashboard', filters] |
30s | Après toute modification de tâche |
| Notifications | ['notifications'] |
15s | Après marquage comme lue |
| Équipes | ['teams'] |
60s | Après modification d’équipe |
5. State Management (Zustand)
Auth Store
Le store d’authentification gère l’état de connexion de l’utilisateur, son token JWT et ses rôles. C’est le seul state véritablement « client » — tout le reste est du state serveur géré par TanStack Query.
// src/stores/authStore.ts
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
hasRole: (role: Role) => boolean;
hasAnyRole: (roles: Role[]) => boolean;
canEditTask: (task: Task) => boolean;
}
const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: localStorage.getItem('auth_token'),
isAuthenticated: !!localStorage.getItem('auth_token'),
login: async (email, password) => {
const response = await apiClient<{ user: User; token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
localStorage.setItem('auth_token', response.token);
set({ user: response.user, token: response.token, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('auth_token');
set({ user: null, token: null, isAuthenticated: false });
},
hasRole: (role) => {
const { user } = get();
return user?.roles.includes(role) ?? false;
},
hasAnyRole: (roles) => {
const { user } = get();
return user?.roles.some((r) => roles.includes(r)) ?? false;
},
canEditTask: (task) => {
const { user } = get();
if (!user) return false;
if (user.roles.includes('admin') || user.roles.includes('manager')) return true;
if (user.roles.includes('developer')) return task.createdBy === user.id;
return false; // viewer ne peut pas modifier
},
}));UI Store
Le store UI gère les préférences d’interface qui ne dépendent pas du serveur.
// src/stores/uiStore.ts
interface UIState {
sidebarOpen: boolean;
activeFilters: TaskFilters;
sortBy: 'priority' | 'dueDate' | 'createdAt' | 'status';
sortOrder: 'asc' | 'desc';
toggleSidebar: () => void;
setFilters: (filters: Partial<TaskFilters>) => void;
resetFilters: () => void;
setSortBy: (sortBy: UIState['sortBy']) => void;
toggleSortOrder: () => void;
}
const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
activeFilters: {},
sortBy: 'createdAt',
sortOrder: 'desc',
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setFilters: (filters) =>
set((s) => ({ activeFilters: { ...s.activeFilters, ...filters } })),
resetFilters: () => set({ activeFilters: {} }),
setSortBy: (sortBy) => set({ sortBy }),
toggleSortOrder: () =>
set((s) => ({ sortOrder: s.sortOrder === 'asc' ? 'desc' : 'asc' })),
}));Séparation State Serveur / State Client
graph LR
subgraph "State Serveur (TanStack Query)"
T["Tâches"]
P["Projets"]
Te["Équipes"]
N["Notifications"]
D["Dashboard"]
end
subgraph "State Client (Zustand)"
A["Auth (user, token, rôles)"]
UI["UI (sidebar, filtres, tri)"]
end
T -.->|"Cache + Revalidation"| API["Backend API"]
P -.->|"Cache + Revalidation"| API
Te -.->|"Cache + Revalidation"| API
N -.->|"Cache + Revalidation"| API
D -.->|"Cache + Revalidation"| API
A -->|"localStorage"| Browser["Navigateur"]
UI -->|"Mémoire"| Browser
6. Composants UI Principaux
Layout Principal
Le layout suit un modèle classique avec sidebar de navigation, header et zone de contenu principal.
graph TD
subgraph "AppLayout"
subgraph "Sidebar"
Nav["Navigation\n• Tâches\n• Projets\n• Équipes\n• Dashboard\n• Notifications"]
end
subgraph "Zone Principale"
Header["Header\n(Nom utilisateur, rôle, déconnexion, notifications)"]
Content["Zone de Contenu\n(Pages via React Router Outlet)"]
end
end
// src/components/layout/AppLayout.tsx
/**
* Layout principal de l'application.
* Sidebar responsive (masquée sur mobile, toggle via hamburger).
* Header avec informations utilisateur et badge de notifications.
*/
function AppLayout() {
const { sidebarOpen, toggleSidebar } = useUIStore();
const { user } = useAuthStore();
return (
<div className="flex h-screen bg-gray-50">
<Sidebar open={sidebarOpen} onToggle={toggleSidebar} />
<div className="flex flex-1 flex-col overflow-hidden">
<Header user={user} onMenuToggle={toggleSidebar} />
<main className="flex-1 overflow-y-auto p-6">
<Outlet /> {/* React Router rend la page active ici */}
</main>
</div>
</div>
);
}Composants Tâches
TaskList — Liste des tâches
// src/components/tasks/TaskList.tsx
/**
* Affiche la liste des tâches sous forme de tableau responsive.
* Supporte le tri par colonnes et le filtrage.
* Affiche un état vide si aucune tâche ne correspond aux filtres.
*/
function TaskList() {
const filters = useUIStore((s) => s.activeFilters);
const { data: tasks, isLoading, error } = useTasksQuery(filters);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorDisplay error={error} />;
if (!tasks?.length) return <EmptyState message="Aucune tâche trouvée" />;
return (
<div className="space-y-4">
<TaskFilters />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
</div>
);
}TaskCard — Carte d’une tâche
// src/components/tasks/TaskCard.tsx
/**
* Carte individuelle affichant les informations clés d'une tâche :
* description, statut, priorité, échéance, indicateur de retard.
* Clic → navigation vers la page de détail.
*/
function TaskCard({ task }: { task: Task }) {
const navigate = useNavigate();
const { canEditTask } = useAuthStore();
return (
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/tasks/${task.id}`)}
role="article"
aria-label={`Tâche: ${task.description}`}
>
<CardHeader className="flex flex-row items-center justify-between">
<TaskPriorityBadge priority={task.priority} />
<TaskStatusBadge status={task.status} />
</CardHeader>
<CardContent>
<p className="text-sm text-gray-700 line-clamp-2">{task.description}</p>
{task.dueDate && (
<div className="mt-2 flex items-center gap-1 text-xs text-gray-500">
<CalendarIcon className="h-3 w-3" />
<span className={task.isOverdue ? 'text-red-600 font-semibold' : ''}>
{formatDate(task.dueDate)}
</span>
{task.isOverdue && (
<span className="text-red-600 ml-1" role="status">En retard</span>
)}
</div>
)}
</CardContent>
</Card>
);
}TaskStatusBadge — Badge de statut
// src/components/tasks/TaskStatusBadge.tsx
const STATUS_CONFIG: Record<Status, { label: string; className: string }> = {
'todo': { label: 'À faire', className: 'bg-gray-100 text-gray-800' },
'in-progress': { label: 'En cours', className: 'bg-blue-100 text-blue-800' },
'completed': { label: 'Terminée', className: 'bg-green-100 text-green-800' },
};
function TaskStatusBadge({ status }: { status: Status }) {
const config = STATUS_CONFIG[status];
return (
<Badge className={config.className} aria-label={`Statut: ${config.label}`}>
{config.label}
</Badge>
);
}TaskPriorityBadge — Badge de priorité
// src/components/tasks/TaskPriorityBadge.tsx
const PRIORITY_CONFIG: Record<Priority, { label: string; className: string }> = {
'low': { label: 'Basse', className: 'bg-slate-100 text-slate-700' },
'medium': { label: 'Moyenne', className: 'bg-yellow-100 text-yellow-800' },
'high': { label: 'Haute', className: 'bg-red-100 text-red-800' },
};
function TaskPriorityBadge({ priority }: { priority: Priority }) {
const config = PRIORITY_CONFIG[priority];
return (
<Badge className={config.className} aria-label={`Priorité: ${config.label}`}>
{config.label}
</Badge>
);
}Composants Projets
ProjectCard — Carte d’un projet
// src/components/projects/ProjectCard.tsx
/**
* Carte affichant un projet avec son nom, sa description,
* et un compteur de tâches par statut.
*/
function ProjectCard({ project }: { project: Project }) {
const navigate = useNavigate();
const { data: tasks } = useProjectTasksQuery(project.id);
const statusCounts = useMemo(() => {
if (!tasks) return { todo: 0, 'in-progress': 0, completed: 0 };
return tasks.reduce(
(acc, t) => ({ ...acc, [t.status]: acc[t.status] + 1 }),
{ todo: 0, 'in-progress': 0, completed: 0 }
);
}, [tasks]);
return (
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/projects/${project.id}`)}
role="article"
aria-label={`Projet: ${project.name}`}
>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
{project.description && (
<CardDescription>{project.description}</CardDescription>
)}
</CardHeader>
<CardContent className="flex gap-3">
<span className="text-xs text-gray-500">{statusCounts.todo} à faire</span>
<span className="text-xs text-blue-500">{statusCounts['in-progress']} en cours</span>
<span className="text-xs text-green-500">{statusCounts.completed} terminées</span>
</CardContent>
</Card>
);
}Composants Dashboard
StatusCounters — Compteurs par statut
// src/components/dashboard/StatusCounters.tsx
/**
* Affiche trois cartes avec le nombre de tâches par statut.
* Correspond à l'Exigence 11 — Tableau de bord de progression.
*/
function StatusCounters({ tasks }: { tasks: Task[] }) {
const counts = useMemo(() => ({
todo: tasks.filter((t) => t.status === 'todo').length,
'in-progress': tasks.filter((t) => t.status === 'in-progress').length,
completed: tasks.filter((t) => t.status === 'completed').length,
}), [tasks]);
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3" role="region" aria-label="Compteurs de statut">
<StatCard label="À faire" count={counts.todo} color="gray" />
<StatCard label="En cours" count={counts['in-progress']} color="blue" />
<StatCard label="Terminées" count={counts.completed} color="green" />
</div>
);
}CompletionRate — Taux de complétion
// src/components/dashboard/CompletionRate.tsx
/**
* Affiche le taux de complétion sous forme de barre de progression.
* Calcul : (completed / total) * 100, arrondi à l'entier.
* Correspond à l'Exigence 11.4.
*/
function CompletionRate({ tasks }: { tasks: Task[] }) {
const rate = useMemo(() => {
if (tasks.length === 0) return 0;
const completed = tasks.filter((t) => t.status === 'completed').length;
return Math.round((completed / tasks.length) * 100);
}, [tasks]);
return (
<Card>
<CardHeader>
<CardTitle>Taux de complétion</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<Progress value={rate} aria-label={`${rate}% complété`} />
<span className="text-2xl font-bold">{rate}%</span>
</div>
</CardContent>
</Card>
);
}7. Routing (React Router)
Configuration des routes
// src/router.tsx
const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
path: '/',
element: (
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
),
errorElement: <ErrorBoundary />,
children: [
{ index: true, element: <Navigate to="/tasks" replace /> },
{ path: 'tasks', element: <TasksPage /> },
{ path: 'tasks/:taskId', element: <TaskDetailPage /> },
{ path: 'projects', element: <ProjectsPage /> },
{ path: 'projects/:projectId', element: <ProjectDetailPage /> },
{
path: 'teams',
element: (
<ProtectedRoute requiredRoles={['admin', 'manager']}>
<TeamsPage />
</ProtectedRoute>
),
},
{
path: 'dashboard',
element: (
<ProtectedRoute requiredRoles={['admin', 'manager']}>
<DashboardPage />
</ProtectedRoute>
),
},
{ path: 'notifications', element: <NotificationsPage /> },
],
},
{ path: '*', element: <NotFoundPage /> },
]);Carte des routes et permissions
| Route | Page | Rôles autorisés | Release |
|---|---|---|---|
/login |
LoginPage | Tous (non authentifié) | MVP |
/tasks |
TasksPage | Tous (authentifié) | MVP |
/tasks/:taskId |
TaskDetailPage | Tous (authentifié) | MVP |
/projects |
ProjectsPage | Tous (authentifié) | MVP |
/projects/:projectId |
ProjectDetailPage | Tous (authentifié) | MVP |
/teams |
TeamsPage | admin, manager | Release 2 |
/dashboard |
DashboardPage | admin, manager | Release 3 |
/notifications |
NotificationsPage | Tous (authentifié) | Release 3 |
Diagramme de navigation
graph TD
Login["/login\nPage de connexion"]
Tasks["/tasks\nListe des tâches"]
TaskDetail["/tasks/:id\nDétail tâche"]
Projects["/projects\nListe des projets"]
ProjectDetail["/projects/:id\nDétail projet"]
Teams["/teams\nGestion équipes"]
Dashboard["/dashboard\nTableau de bord"]
Notifs["/notifications\nNotifications"]
NotFound["/*\nPage 404"]
Login -->|"Authentification réussie"| Tasks
Tasks -->|"Clic sur tâche"| TaskDetail
Tasks -->|"Sidebar"| Projects
Tasks -->|"Sidebar"| Teams
Tasks -->|"Sidebar"| Dashboard
Tasks -->|"Header"| Notifs
Projects -->|"Clic sur projet"| ProjectDetail
ProjectDetail -->|"Clic sur tâche"| TaskDetail
TaskDetail -->|"Retour"| Tasks
8. Gestion des Formulaires et Validation Côté Client
Schémas de validation (Zod)
La validation côté client utilise Zod pour garantir la cohérence avec les règles métier avant l’envoi au backend. Cela offre un retour immédiat à l’utilisateur sans attendre la réponse serveur.
// src/lib/validators.ts
import { z } from 'zod';
/** Valeurs valides pour les énumérations du domaine */
const STATUS_VALUES = ['todo', 'in-progress', 'completed'] as const;
const PRIORITY_VALUES = ['low', 'medium', 'high'] as const;
const ROLE_VALUES = ['admin', 'manager', 'developer', 'viewer'] as const;
/** Schéma de création d'une tâche (Exigences 1, 7) */
const createTaskSchema = z.object({
description: z
.string()
.trim()
.min(1, 'La description est obligatoire')
.max(250, 'La description ne peut pas dépasser 250 caractères'),
priority: z.enum(PRIORITY_VALUES).optional().default('medium'),
dueDate: z
.string()
.datetime({ message: "Format de date invalide (ISO 8601 attendu)" })
.optional()
.nullable(),
projectId: z.string().uuid().optional().nullable(),
});
/** Schéma de mise à jour d'une tâche */
const updateTaskSchema = z.object({
description: z
.string()
.trim()
.min(1, 'La description est obligatoire')
.max(250, 'La description ne peut pas dépasser 250 caractères')
.optional(),
status: z.enum(STATUS_VALUES, {
errorMap: () => ({
message: `Statut invalide. Valeurs acceptées : ${STATUS_VALUES.join(', ')}`,
}),
}).optional(),
priority: z.enum(PRIORITY_VALUES, {
errorMap: () => ({
message: `Priorité invalide. Valeurs acceptées : ${PRIORITY_VALUES.join(', ')}`,
}),
}).optional(),
dueDate: z.string().datetime().optional().nullable(),
});
/** Schéma de création d'un projet (Exigence 2) */
const createProjectSchema = z.object({
name: z.string().trim().min(1, 'Le nom du projet est obligatoire'),
description: z.string().trim().optional().nullable(),
teamId: z.string().uuid().optional().nullable(),
});
/** Schéma de connexion */
const loginSchema = z.object({
email: z.string().email('Adresse email invalide'),
password: z.string().min(1, 'Le mot de passe est obligatoire'),
});Formulaire de création de tâche
// src/components/tasks/TaskForm.tsx
/**
* Formulaire de création/édition de tâche.
* Validation côté client avec Zod avant soumission.
* Affiche les erreurs inline sous chaque champ.
* Gère l'avertissement pour les échéances dans le passé (Exigence 7.4).
*/
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
const createMutation = useCreateTaskMutation();
const updateMutation = useUpdateTaskMutation();
const [errors, setErrors] = useState<Record<string, string>>({});
const [warning, setWarning] = useState<string | null>(null);
const isEditing = !!task;
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = Object.fromEntries(formData);
// Validation côté client
const schema = isEditing ? updateTaskSchema : createTaskSchema;
const result = schema.safeParse(data);
if (!result.success) {
const fieldErrors: Record<string, string> = {};
result.error.issues.forEach((issue) => {
const field = issue.path[0] as string;
fieldErrors[field] = issue.message;
});
setErrors(fieldErrors);
return;
}
// Avertissement échéance passée
if (result.data.dueDate && new Date(result.data.dueDate) < new Date()) {
setWarning("Attention : l'échéance spécifiée est déjà dépassée.");
}
// Soumission
if (isEditing) {
updateMutation.mutate({ id: task.id, data: result.data }, { onSuccess: onClose });
} else {
createMutation.mutate(result.data, { onSuccess: onClose });
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
name="description"
defaultValue={task?.description}
maxLength={250}
required
aria-describedby={errors.description ? 'description-error' : undefined}
aria-invalid={!!errors.description}
/>
{errors.description && (
<p id="description-error" className="text-sm text-red-600 mt-1" role="alert">
{errors.description}
</p>
)}
</div>
<div>
<Label htmlFor="priority">Priorité</Label>
<Select name="priority" defaultValue={task?.priority ?? 'medium'}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Basse</SelectItem>
<SelectItem value="medium">Moyenne</SelectItem>
<SelectItem value="high">Haute</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="dueDate">Échéance</Label>
<Input
id="dueDate"
name="dueDate"
type="datetime-local"
defaultValue={task?.dueDate ? formatDateForInput(task.dueDate) : undefined}
/>
{warning && (
<p className="text-sm text-yellow-600 mt-1" role="status">{warning}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Annuler
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
>
{isEditing ? 'Mettre à jour' : 'Créer la tâche'}
</Button>
</div>
</form>
);
}Gestion des erreurs API dans les formulaires
sequenceDiagram
participant U as Utilisateur
participant F as Formulaire
participant Z as Zod Validation
participant TQ as TanStack Query
participant API as Backend API
U->>F: Remplit et soumet le formulaire
F->>Z: Validation côté client
alt Validation échouée
Z-->>F: Erreurs de validation
F-->>U: Affiche erreurs inline (rouge)
else Validation réussie
Z-->>F: Données validées
F->>TQ: mutation.mutate(data)
TQ->>API: POST/PATCH requête
alt Succès API
API-->>TQ: 201/200 + données
TQ-->>F: onSuccess callback
F-->>U: Ferme le formulaire + toast succès
else Erreur API (400)
API-->>TQ: 400 + ApiError JSON
TQ-->>F: onError callback
F-->>U: Affiche message d'erreur serveur
else Erreur API (403)
API-->>TQ: 403 Forbidden
TQ-->>F: onError callback
F-->>U: "Vous n'avez pas les permissions nécessaires"
end
end
9. Authentification et Gestion des Rôles
Flux d’authentification
sequenceDiagram
participant U as Utilisateur
participant LP as LoginPage
participant AS as AuthStore
participant API as Backend API
participant R as React Router
U->>LP: Saisit email + mot de passe
LP->>AS: login(email, password)
AS->>API: POST /api/auth/login
alt Authentification réussie
API-->>AS: { user, token }
AS-->>AS: Stocke token dans localStorage
AS-->>AS: Met à jour state (user, isAuthenticated)
AS-->>R: Redirection vers /tasks
else Authentification échouée
API-->>AS: 401 Unauthorized
AS-->>LP: Erreur affichée
LP-->>U: "Email ou mot de passe incorrect"
end
Guard de route (ProtectedRoute)
// src/components/common/ProtectedRoute.tsx
/**
* Composant de protection des routes.
* Vérifie l'authentification et optionnellement les rôles requis.
* Redirige vers /login si non authentifié.
* Affiche un message d'accès refusé si rôle insuffisant.
*/
function ProtectedRoute({
children,
requiredRoles,
}: {
children: React.ReactNode;
requiredRoles?: Role[];
}) {
const { isAuthenticated, hasAnyRole } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requiredRoles && !hasAnyRole(requiredRoles)) {
return (
<div className="flex items-center justify-center h-full" role="alert">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Accès refusé</CardTitle>
</CardHeader>
<CardContent>
<p>Vous n'avez pas les permissions nécessaires pour accéder à cette page.</p>
<p className="text-sm text-gray-500 mt-2">
Rôles requis : {requiredRoles.join(', ')}
</p>
</CardContent>
</Card>
</div>
);
}
return <>{children}</>;
}Matrice des permissions UI
La matrice ci-dessous définit ce que chaque rôle peut voir et faire dans l’interface. Elle est le miroir côté frontend de la matrice RBAC du backend (Exigence 5).
| Élément UI | viewer | developer | manager | admin |
|---|---|---|---|---|
| Voir la liste des tâches | ✅ | ✅ | ✅ | ✅ |
| Voir le détail d’une tâche | ✅ | ✅ | ✅ | ✅ |
| Bouton « Créer une tâche » | ❌ | ✅ | ✅ | ✅ |
| Modifier une tâche (propre) | ❌ | ✅ | ✅ | ✅ |
| Modifier une tâche (autre) | ❌ | ❌ | ✅ | ✅ |
| S’auto-attribuer une tâche | ❌ | ✅ | ✅ | ✅ |
| Voir la liste des projets | ✅ | ✅ | ✅ | ✅ |
| Bouton « Créer un projet » | ❌ | ❌ | ✅ | ✅ |
| Accéder à la page Équipes | ❌ | ❌ | ✅ | ✅ |
| Accéder au Dashboard | ❌ | ❌ | ✅ | ✅ |
| Gérer les utilisateurs | ❌ | ❌ | ❌ | ✅ |
Utilitaire de permissions
// src/lib/permissions.ts
type Permission =
| 'task:create'
| 'task:edit-own'
| 'task:edit-any'
| 'task:self-assign'
| 'project:create'
| 'team:manage'
| 'dashboard:view'
| 'user:manage';
/** Matrice rôle → permissions */
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
viewer: [],
developer: ['task:create', 'task:edit-own', 'task:self-assign'],
manager: ['task:create', 'task:edit-own', 'task:edit-any', 'task:self-assign', 'project:create', 'team:manage', 'dashboard:view'],
admin: ['task:create', 'task:edit-own', 'task:edit-any', 'task:self-assign', 'project:create', 'team:manage', 'dashboard:view', 'user:manage'],
};
/** Vérifie si un utilisateur a une permission donnée */
function hasPermission(user: User, permission: Permission): boolean {
return user.roles.some((role) => ROLE_PERMISSIONS[role].includes(permission));
}
/** Vérifie si un utilisateur peut éditer une tâche spécifique */
function canEditTask(user: User, task: Task): boolean {
if (hasPermission(user, 'task:edit-any')) return true;
if (hasPermission(user, 'task:edit-own') && task.createdBy === user.id) return true;
return false;
}10. Responsive Design et Accessibilité
Stratégie Responsive
L’application utilise une approche mobile-first avec Tailwind CSS. Les breakpoints suivent la convention Tailwind :
| Breakpoint | Largeur min | Comportement |
|---|---|---|
sm |
640px | Sidebar masquée, menu hamburger |
md |
768px | Grille 2 colonnes pour les cartes |
lg |
1024px | Sidebar visible, grille 3 colonnes |
xl |
1280px | Layout étendu avec plus d’espace |
Adaptations par composant
- Sidebar : Masquée sur mobile
(
< lg), affichée en overlay avec backdrop. Toggle via bouton hamburger dans le Header. - TaskList : 1 colonne sur mobile, 2 sur tablette
(
md), 3 sur desktop (lg). - TaskForm : Pleine largeur sur mobile, dialog modal centré sur desktop.
- Dashboard : Compteurs empilés sur mobile
(
grid-cols-1), côte à côte sur tablette+ (sm:grid-cols-3). - Header : Nom complet sur desktop, initiales sur mobile. Badge de notifications toujours visible.
Accessibilité (WCAG 2.1 AA)
L’application vise la conformité WCAG 2.1 niveau AA. Shadcn/UI (basé sur Radix UI) fournit des primitives accessibles par défaut.
Principes appliqués
- Perceptible
- Contrastes de couleurs suffisants (ratio 4.5:1 minimum pour le texte)
- Textes alternatifs pour les icônes
(
aria-label) - Indicateurs visuels non basés uniquement sur la couleur (badges avec texte + couleur)
- Messages d’erreur associés aux champs via
aria-describedby
- Utilisable
- Navigation complète au clavier (Tab, Shift+Tab, Enter, Escape)
- Focus visible sur tous les éléments interactifs
(
focus-visible:ring-2) - Skip links pour accéder directement au contenu principal
- Pas de piège au clavier dans les modales (Radix UI gère le focus trap)
- Compréhensible
- Labels explicites sur tous les champs de formulaire
(
<Label htmlFor>) - Messages d’erreur clairs et contextuels
- Langue de la page définie
(
<html lang="fr">) - Navigation cohérente et prévisible
- Labels explicites sur tous les champs de formulaire
(
- Robuste
- HTML sémantique (
<main>,<nav>,<header>,<article>) - Rôles ARIA appropriés (
role="alert",role="status",role="region") aria-live="polite"pour les mises à jour dynamiques (notifications, toasts)- Composants Radix UI testés avec les lecteurs d’écran
- HTML sémantique (
Attributs ARIA utilisés
| Composant | Attributs ARIA |
|---|---|
| TaskCard | role="article",
aria-label="Tâche: {description}" |
| TaskStatusBadge | aria-label="Statut: {label}" |
| TaskPriorityBadge | aria-label="Priorité: {label}" |
| Formulaires | aria-invalid, aria-describedby,
aria-required |
| Messages d’erreur | role="alert" |
| Indicateur de retard | role="status" |
| Compteurs dashboard | role="region", aria-label |
| Barre de progression | aria-label="{rate}% complété" |
| Sidebar navigation | role="navigation",
aria-label="Navigation principale" |
11. Stratégie de Tests
Approche hybride : TDD Unit + E2E
Conformément aux standards du projet, le frontend utilise une stratégie de tests hybride à deux couches complémentaires :
Couche 1 — Tests unitaires TDD (logique isolée, feedback continu)
Appliquer TDD (test-first) pour toute la logique frontend isolée qui ne dépend pas du navigateur ou du backend :
- Validators (schémas Zod) : Écrire les tests en premier à partir des critères d’acceptation, puis implémenter le schéma
- Permissions (matrice RBAC) : Écrire les tests pour chaque combinaison rôle/permission, puis implémenter la logique
- Stores (Zustand) : Écrire les tests pour les transitions d’état (login/logout, filtres), puis implémenter le store
- Utilitaires (date-utils, constants) : Écrire les tests pour les cas limites, puis implémenter
Règles des tests unitaires TDD : - Les tests s’exécutent sans navigateur, sans backend et sans DOM - Les tests doivent être rapides (millisecondes) et exécutés en continu - Dériver les cas de test directement des critères d’acceptation (scénarios Gherkin) du requirements.md
Structure des tests unitaires :
frontend/tests/
├── unit/
│ ├── lib/ # Tests pour validators, permissions, utilitaires
│ └── stores/ # Tests pour les stores Zustand
Couche 2 — Tests E2E (comportement utilisateur, validation aux checkpoints)
Les tests E2E avec Playwright valident l’intégration complète : composants + routing + communication API + permissions.
Configuration Playwright
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: 1,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});Plan de tests E2E par exigence
Release 1 — MVP
| Fichier de test | Exigence couverte | Scénarios clés |
|---|---|---|
tests/e2e/tasks.spec.ts |
Exigences 1, 3, 4, 6 | Création de tâche, changement de statut, modification de priorité, affichage des échéances |
tests/e2e/projects.spec.ts |
Exigence 2 | Création de projet, association de tâches, affichage des tâches d’un projet |
tests/e2e/auth.spec.ts |
Exigence 5 | Connexion, déconnexion, restrictions par rôle |
tests/e2e/validation.spec.ts |
Exigence 7 | Rejet description vide, rejet statut invalide, avertissement échéance passée |
Release 2 — Collaboration
| Fichier de test | Exigence couverte | Scénarios clés |
|---|---|---|
tests/e2e/assignments.spec.ts |
Exigence 9 | Attribution de tâche, attributions multiples, consultation des tâches attribuées, auto-attribution developer |
tests/e2e/teams.spec.ts |
Exigence 10 | Création d’équipe, ajout de membres, limite de 6 membres |
Release 3 — Pilotage
| Fichier de test | Exigence couverte | Scénarios clés |
|---|---|---|
tests/e2e/dashboard.spec.ts |
Exigence 11 | Compteurs par statut, filtrage par projet, taux de complétion |
tests/e2e/history.spec.ts |
Exigence 12 | Affichage de l’historique des modifications |
tests/e2e/notifications.spec.ts |
Exigence 13 | Affichage des notifications, marquage comme lue |
Exemple de test E2E
// tests/e2e/tasks.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Gestion des tâches', () => {
test.beforeEach(async ({ page }) => {
// Connexion en tant que developer
await page.goto('/login');
await page.getByLabel('Email').fill('developer@example.com');
await page.getByLabel('Mot de passe').fill('password123');
await page.getByRole('button', { name: 'Se connecter' }).click();
await expect(page).toHaveURL('/tasks');
});
test('Exigence 1.1 — Création d\'une tâche complète', async ({ page }) => {
await page.getByRole('button', { name: 'Créer une tâche' }).click();
await page.getByLabel('Description').fill('Corriger le bug #42');
await page.getByLabel('Priorité').selectOption('high');
await page.getByLabel('Échéance').fill('2025-12-31T18:00');
await page.getByRole('button', { name: 'Créer la tâche' }).click();
// Vérifier que la tâche apparaît dans la liste
await expect(page.getByText('Corriger le bug #42')).toBeVisible();
await expect(page.getByText('Haute')).toBeVisible();
await expect(page.getByText('À faire')).toBeVisible();
});
test('Exigence 1.4 — Rejet d\'une description vide', async ({ page }) => {
await page.getByRole('button', { name: 'Créer une tâche' }).click();
// Soumettre sans description
await page.getByRole('button', { name: 'Créer la tâche' }).click();
// Vérifier le message d'erreur
await expect(page.getByText('La description est obligatoire')).toBeVisible();
});
test('Exigence 3.1 — Changement de statut', async ({ page }) => {
// Cliquer sur une tâche existante
await page.getByText('Corriger le bug #42').click();
// Changer le statut
await page.getByLabel('Statut').selectOption('in-progress');
await page.getByRole('button', { name: 'Mettre à jour' }).click();
// Vérifier le nouveau statut
await expect(page.getByText('En cours')).toBeVisible();
});
test('Exigence 5.3 — Viewer ne peut pas créer de tâche', async ({ page }) => {
// Se déconnecter et se reconnecter en tant que viewer
await page.getByRole('button', { name: 'Déconnexion' }).click();
await page.getByLabel('Email').fill('viewer@example.com');
await page.getByLabel('Mot de passe').fill('password123');
await page.getByRole('button', { name: 'Se connecter' }).click();
// Vérifier que le bouton "Créer une tâche" n'est pas visible
await expect(page.getByRole('button', { name: 'Créer une tâche' })).not.toBeVisible();
});
test('Exigence 6.2 — Identification des tâches en retard', async ({ page }) => {
// Vérifier qu'une tâche en retard affiche l'indicateur
const overdueTask = page.locator('[data-testid="task-card"]').filter({
hasText: 'En retard',
});
await expect(overdueTask).toBeVisible();
});
});12. Types Frontend
Types partagés (miroir du backend)
Les types frontend reprennent les types définis dans
backend/src/types/index.ts avec des adaptations pour le
contexte client (dates en string ISO au lieu de
Date, types d’entrée pour les formulaires).
// src/types/index.ts
/** Types du domaine — miroir des types backend */
export type Status = 'todo' | 'in-progress' | 'completed';
export type Priority = 'low' | 'medium' | 'high';
export type Role = 'admin' | 'manager' | 'developer' | 'viewer';
/** Tâche telle que reçue de l'API (dates en string ISO) */
export interface Task {
id: string;
description: string;
status: Status;
priority: Priority;
projectId: string | null;
dueDate: string | null; // ISO 8601 string (pas Date)
isOverdue: boolean;
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string | null;
completedAt: string | null;
}
export interface Project {
id: string;
name: string;
description: string | null;
teamId: string | null;
createdAt: string;
createdBy: string;
}
export interface User {
id: string;
email: string;
name: string;
roles: Role[];
createdAt: string;
}
export interface Team {
id: string;
name: string;
memberIds: string[];
createdAt: string;
createdBy: string;
}
export interface Assignment {
id: string;
taskId: string;
userId: string;
assignedAt: string;
assignedBy: string;
}
export interface Notification {
id: string;
userId: string;
taskId: string;
type: 'due-soon' | 'overdue';
isRead: boolean;
createdAt: string;
}
export interface HistoryEntry {
id: string;
taskId: string;
field: 'status' | 'priority';
previousValue: string;
newValue: string;
changedBy: string;
changedAt: string;
}
/** Types d'erreur API */
export interface ApiError {
error: string;
code: string;
details?: Record<string, unknown>;
}
export interface ApiResponseWithWarnings<T> {
data: T;
warnings?: string[];
}
/** Types d'entrée pour les formulaires */
export interface CreateTaskInput {
description: string;
priority?: Priority;
dueDate?: string | null;
projectId?: string | null;
}
export interface UpdateTaskInput {
description?: string;
status?: Status;
priority?: Priority;
dueDate?: string | null;
}
export interface CreateProjectInput {
name: string;
description?: string | null;
teamId?: string | null;
}
export interface CreateTeamInput {
name: string;
}
/** Types UI pour les filtres et le tri */
export interface TaskFilters {
status?: Status;
priority?: Priority;
projectId?: string;
assignedTo?: string;
sortBy?: 'priority' | 'dueDate' | 'createdAt' | 'status';
sortOrder?: 'asc' | 'desc';
page?: number;
limit?: number;
}
/** Type pour les statistiques du dashboard */
export interface DashboardStats {
totalTasks: number;
statusCounts: Record<Status, number>;
completionRate: number;
overdueTasks: number;
}
/** Type pour les réponses paginées de l'API */
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}13. Gestion des Erreurs Frontend
Stratégie globale
graph TD
subgraph "Erreurs Réseau"
E1["Pas de connexion\n(fetch échoue)"]
E2["Timeout"]
end
subgraph "Erreurs API"
E3["400 — Validation"]
E4["401 — Non authentifié"]
E5["403 — Non autorisé"]
E6["404 — Introuvable"]
E7["500 — Erreur serveur"]
end
subgraph "Erreurs Client"
E8["Erreur React\n(composant crash)"]
end
E1 -->|"Toast"| T1["'Connexion au serveur impossible.\nVérifiez votre réseau.'"]
E2 -->|"Toast"| T2["'La requête a expiré.\nRéessayez.'"]
E3 -->|"Inline"| T3["Messages sous les champs\ndu formulaire"]
E4 -->|"Redirect"| T4["Redirection vers /login"]
E5 -->|"Toast"| T5["'Vous n'avez pas les\npermissions nécessaires.'"]
E6 -->|"Page"| T6["Page 404 ou message\n'Ressource introuvable'"]
E7 -->|"Toast"| T7["'Une erreur inattendue\nest survenue.'"]
E8 -->|"ErrorBoundary"| T8["Page d'erreur avec\nbouton 'Réessayer'"]
ErrorBoundary
// src/components/common/ErrorBoundary.tsx
/**
* Capture les erreurs React non gérées et affiche une page de secours.
* Empêche le crash complet de l'application.
*/
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
state = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center h-screen" role="alert">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Une erreur est survenue</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
L'application a rencontré un problème inattendu.
</p>
<Button
className="mt-4"
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.href = '/';
}}
>
Retour à l'accueil
</Button>
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}Classe d’erreur API personnalisée
// src/api/client.ts
/**
* Erreur typée pour les réponses API en erreur.
* Permet de distinguer les types d'erreur dans les handlers.
*/
class ApiClientError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
public details?: Record<string, unknown>
) {
super(message);
this.name = 'ApiClientError';
}
get isValidationError() { return this.statusCode === 400; }
get isForbidden() { return this.statusCode === 403; }
get isNotFound() { return this.statusCode === 404; }
get isServerError() { return this.statusCode === 500; }
}14. Constantes et Configuration
// src/lib/constants.ts
/** Labels français pour les statuts */
export const STATUS_LABELS: Record<Status, string> = {
'todo': 'À faire',
'in-progress': 'En cours',
'completed': 'Terminée',
};
/** Labels français pour les priorités */
export const PRIORITY_LABELS: Record<Priority, string> = {
'low': 'Basse',
'medium': 'Moyenne',
'high': 'Haute',
};
/** Labels français pour les rôles */
export const ROLE_LABELS: Record<Role, string> = {
'admin': 'Administrateur',
'manager': 'Gestionnaire',
'developer': 'Développeur',
'viewer': 'Observateur',
};
/** Ordre de tri des priorités (high = 0, low = 2) */
export const PRIORITY_ORDER: Record<Priority, number> = {
'high': 0,
'medium': 1,
'low': 2,
};
/** URL de base de l'API */
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';15. Traçabilité Exigences ↔︎ Composants
| Exigence | Composants Frontend | Hooks | Validation |
|---|---|---|---|
| 1 — Création de tâches | TaskForm, TaskList | useCreateTaskMutation | createTaskSchema |
| 2 — Organisation en projets | ProjectForm, ProjectCard, ProjectDetailPage | useProjectsQuery, useProjectTasksQuery | createProjectSchema |
| 3 — Suivi du statut | TaskStatusBadge, TaskDetailPage | useUpdateTaskMutation | updateTaskSchema (status) |
| 4 — Gestion des priorités | TaskPriorityBadge, TaskFilters | useUpdateTaskMutation | updateTaskSchema (priority) |
| 5 — Gestion des rôles | ProtectedRoute, permissions.ts | useAuthStore | ROLE_PERMISSIONS |
| 6 — Suivi des échéances | TaskCard (indicateur retard), TaskForm (dueDate) | useTasksQuery (sortBy: dueDate) | createTaskSchema (dueDate) |
| 7 — Validation des données | TaskForm (erreurs inline), validators.ts | Mutations (onError) | Tous les schémas Zod |
| 8 — API RESTful | api/client.ts, api/tasks.ts, api/projects.ts | Tous les hooks TanStack Query | — |
| 9 — Attribution de tâches | TaskDetailPage (section attributions) | useAssignments | — |
| 10 — Collaboration en équipe | TeamList, TeamCard, TeamForm | useTeams | createTeamSchema |
| 11 — Tableau de bord | StatusCounters, CompletionRate, OverdueTasks | useDashboard | — |
| 12 — Historique | TaskDetailPage (onglet historique) | useTaskHistory | — |
| 13 — Notifications | Header (badge), NotificationsPage | useNotifications | — |