This commit is contained in:
Louis Mazin 2026-09-25 19:30:07 +02:00
parent 19173d1e0c
commit 12d3a95811
15 changed files with 912 additions and 224 deletions

View File

@ -49,6 +49,7 @@ INSTALLED_APPS = [
'time_tracking',
'events',
'notes',
'file', # Ajout du module de gestion des fichiers
]
MIDDLEWARE = [
@ -156,3 +157,4 @@ SIMPLE_JWT = {
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@ -29,4 +29,5 @@ urlpatterns = [
path('api/time/', include('time_tracking.urls')),
path('api/event/', include('events.urls')),
path('api/notes/', include('notes.urls')),
path('api/file/', include('file.urls')), # Ajout des URLs du module file
]

6
backend/file/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class FileConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'file'

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.17 on 2026-09-25 17:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('path', models.CharField(max_length=500)),
('content', models.FileField(upload_to='files/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('type', models.CharField(choices=[('pay_slip', 'Fiche de paie'), ('timetable', 'Emploi du temps'), ('contract', 'Contrat'), ('identity', "Pièce d'identité"), ('other', 'Autre')], max_length=50)),
('display_path', models.CharField(blank=True, max_length=500)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

23
backend/file/models.py Normal file
View File

@ -0,0 +1,23 @@
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class FileType(models.TextChoices):
PAY_SLIP = 'pay_slip', 'Fiche de paie'
TIMETABLE = 'timetable', 'Emploi du temps'
CONTRACT = 'contract', 'Contrat'
IDENTITY = 'identity', 'Pièce d\'identité'
OTHER = 'other', 'Autre'
class File(models.Model):
name = models.CharField(max_length=255)
path = models.CharField(max_length=500)
content = models.FileField(upload_to='files/')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='files')
created_at = models.DateTimeField(auto_now_add=True)
type = models.CharField(max_length=50, choices=FileType.choices)
display_path = models.CharField(max_length=500, blank=True)
def __str__(self):
return f"{self.name} - {self.user.username}"

View File

@ -0,0 +1,8 @@
from rest_framework import serializers
from .models import File, FileType
class FileSerializer(serializers.ModelSerializer):
class Meta:
model = File
fields = ['id', 'name', 'path', 'content', 'user', 'created_at', 'type', 'display_path']
read_only_fields = ['user', 'created_at']

8
backend/file/urls.py Normal file
View File

@ -0,0 +1,8 @@
from django.urls import path
from .views import FileListView, FileUploadView, FileDetailView
urlpatterns = [
path('files/', FileListView.as_view(), name='file-list'),
path('files/upload/', FileUploadView.as_view(), name='file-upload'),
path('files/<int:id>/', FileDetailView.as_view(), name='file-detail'),
]

27
backend/file/views.py Normal file
View File

@ -0,0 +1,27 @@
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import File
from .serializers import FileSerializer
class FileListView(generics.ListAPIView):
serializer_class = FileSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return File.objects.filter(user=self.request.user)
class FileUploadView(generics.CreateAPIView):
serializer_class = FileSerializer
permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class FileDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = FileSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'id'
def get_queryset(self):
return File.objects.filter(user=self.request.user)

View File

@ -24,6 +24,7 @@ import TimeClockPage from './pages/TimeClockPage';
import WeeklySummaryPage from './pages/WeeklySummaryPage';
import CalendarPage from './pages/CalendarPage';
import NotesPage from './pages/NotesPage';
import ManageUsersPage from './pages/ManageUsersPage';
import { useAuth } from './context/useAuth';
// Composant personnalisé pour le trigger mobile avec icône menu
@ -120,6 +121,15 @@ function App() {
}
/>
<Route
path="/users/manage"
element={
<AdminRoute>
<ManageUsersPage />
</AdminRoute>
}
/>
<Route
path="/weekly-summary"
element={
@ -144,12 +154,10 @@ function App() {
/>
<Route path="/notes" element={<ProtectedRoute><NotesPage /></ProtectedRoute>} />
</Routes>
</div>
</div>
</main>
<footer className="site-footer shrink-0 py-2">

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { Link, NavLink } from 'react-router-dom';
import React, { useEffect, useId, useRef, useState } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { useAuth } from '../context/useAuth';
import {
@ -24,14 +24,31 @@ import {
Wheat,
Calendar,
ClipboardList,
ChevronDown,
List,
BarChart3,
UserCog,
} from 'lucide-react';
const NavBar = () => {
const { user, logout } = useAuth();
const { isMobile, toggleSidebar, setOpen } = useSidebar();
const { pathname } = useLocation();
const isUsersSection =
pathname === '/users' || pathname.startsWith('/users/');
const [usersMenuOpen, setUsersMenuOpen] = useState(isUsersSection);
const usersMenuId = useId();
const closeTimeout = useRef(null);
useEffect(() => {
if (isUsersSection) {
setUsersMenuOpen(true);
}
}, [pathname, isUsersSection]);
useEffect(() => {
return () => {
if (closeTimeout.current) {
@ -74,7 +91,7 @@ const NavBar = () => {
*/
const navigation = [
{
title: user.first_name,
title: 'Mon espace',
href: '/myspace',
icon: User,
},
@ -109,11 +126,22 @@ const NavBar = () => {
/*
* Administration
*/
const administration = [
const userNavigation = [
{
title: 'Utilisateurs',
title: 'Liste',
href: '/users',
icon: Users,
icon: List,
end: true,
},
{
title: 'Statistiques',
href: '/users/statistics',
icon: BarChart3,
},
{
title: 'Gestion',
href: '/users/manage',
icon: UserCog,
},
];
@ -286,7 +314,78 @@ const NavBar = () => {
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(administration)}
<SidebarMenuItem>
<div className={menuItemClass}>
<SidebarMenuButton
type="button"
tooltip="Utilisateur"
aria-label="Utilisateur"
isActive={isUsersSection}
aria-expanded={usersMenuOpen}
aria-controls={usersMenuId}
className={buttonClass}
onClick={() => {
setUsersMenuOpen((open) => !open);
if (!isMobile) {
setOpen(true);
}
}}
>
<Users className="size-6 shrink-0" />
<span className="truncate text-base group-data-[collapsible=icon]:hidden">
Utilisateur
</span>
<ChevronDown
aria-hidden="true"
className={`
ml-auto size-4 shrink-0 transition-transform
group-data-[collapsible=icon]:hidden
${usersMenuOpen ? 'rotate-180' : ''}
`}
/>
</SidebarMenuButton>
</div>
<ul
id={usersMenuId}
aria-label="Utilisateur"
hidden={!usersMenuOpen}
className="ml-4 space-y-1 border-l border-sidebar-border py-1 pl-3 group-data-[collapsible=icon]:hidden"
>
{userNavigation.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
(!item.end && pathname.startsWith(`${item.href}/`));
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
onClick={handleCloseMenu}
className="h-8 min-h-8 w-full"
render={
<NavLink
to={item.href}
end={item.end}
className="flex h-full w-full items-center gap-2 text-sm"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">
{item.title}
</span>
</NavLink>
}
/>
</SidebarMenuItem>
);
})}
</ul>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -129,7 +129,7 @@ const AdminUsersPage = () => {
}
return (
<MainCard title="Gestion des utilisateurs" icon={Users}>
<MainCard title="Liste des utilisateurs" icon={Users}>
<CardContent className="overflow-y-auto">
{error && (
<Alert variant="destructive" className="mb-4">

View File

@ -1,77 +1,199 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import MainCard from "@/components/MainCard"
import { House } from 'lucide-react';
import {
ArrowUpRight,
BarChart3,
Calendar,
CalendarDays,
ClipboardList,
Clock,
House,
User,
UserCog,
Users,
} from 'lucide-react';
import MainCard from '@/components/MainCard';
import { useAuth } from '../context/useAuth';
const sections = [
{
id: 'personal',
title: 'Navigation',
description: 'Tes informations et tes outils du quotidien.',
iconClassName: 'bg-primary/10 text-primary',
gridClassName: 'md:grid-cols-3',
items: [
{
title: 'Mon espace',
description: 'Retrouve ton profil, tes statistiques et tes documents personnels.',
path: '/myspace',
icon: User,
},
{
title: 'Planning',
description: 'Consulte les heures enregistrées.',
path: '/weekly-summary',
icon: CalendarDays,
},
{
title: 'Pointage',
description: 'Enregistre tes entrées et tes sorties, puis retrouve ton historique.',
path: '/pointage',
icon: Clock,
},
],
},
{
id: 'company',
title: 'Entreprise',
description: 'La vie de la boulangerie et les échanges de l’équipe.',
iconClassName: 'bg-sky-500/10 text-sky-700 dark:text-sky-300',
gridClassName: 'md:grid-cols-2',
items: [
{
title: 'Calendrier',
description: 'Retrouve tes événements personnels et ceux partagés avec l’équipe.',
path: '/calendar',
icon: Calendar,
},
{
title: "Espace d’échanges",
description: 'Consulte les notes, partage des informations et suis les demandes.',
path: '/notes',
icon: ClipboardList,
},
],
},
{
id: 'administration',
title: 'Administration',
description: 'Les outils de gestion des utilisateurs.',
adminOnly: true,
iconClassName: 'bg-violet-500/10 text-violet-700 dark:text-violet-300',
gridClassName: 'md:grid-cols-3',
items: [
{
title: 'Liste des utilisateurs',
description: 'Consulte les comptes et gère les accès des utilisateurs.',
path: '/users',
icon: Users,
},
{
title: 'Statistiques',
description: 'Retrouve les statistiques et les indicateurs liés aux utilisateurs.',
path: '/users/statistics',
icon: BarChart3,
},
{
title: 'Gestion',
description: 'Retrouve les fichiers, les catégories et les actions liés aux utilisateurs.',
path: '/users/manage',
icon: UserCog,
},
],
},
];
const ShortcutCard = ({ item, iconClassName }) => {
const Icon = item.icon;
return (
<Link
to={item.path}
className="
group flex h-full min-w-0 flex-col gap-4
rounded-xl border border-border bg-card p-4
text-card-foreground shadow-sm
transition-[background-color,border-color,box-shadow]
hover:border-primary/30 hover:bg-accent/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
focus-visible:ring-offset-background
motion-reduce:transition-none sm:p-5
"
>
<div className="flex items-center justify-between gap-3">
<span
className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${iconClassName}`}
>
<Icon className="size-5" aria-hidden="true" />
</span>
<ArrowUpRight
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground group-hover:text-foreground group-focus-visible:text-foreground"
/>
</div>
<div className="min-w-0 space-y-1.5">
<h3 className="break-words text-base font-semibold leading-snug">
{item.title}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{item.description}
</p>
</div>
</Link>
);
};
const ShortcutSection = ({ section }) => (
<section
aria-labelledby={`home-section-${section.id}`}
className={section.adminOnly ? 'space-y-4 border-t border-border pt-6' : 'space-y-4'}
>
<div className="space-y-1">
<h2
id={`home-section-${section.id}`}
className="text-lg font-semibold tracking-tight"
>
{section.title}
</h2>
<p className="text-sm text-muted-foreground">
{section.description}
</p>
</div>
<ul className={`grid grid-cols-1 gap-3 sm:gap-4 ${section.gridClassName}`}>
{section.items.map((item) => (
<li key={item.path} className="min-w-0">
<ShortcutCard item={item} iconClassName={section.iconClassName} />
</li>
))}
</ul>
</section>
);
const HomePage = () => {
const tabs = [
{
name: 'Accueil',
description: 'Page d\'accueil principale avec la liste des onglets et leurs utilités',
path: '/'
},
{
name: 'Profil',
description: 'Ton profil',
path: '/profile'
},
{
name: 'Utilisateurs',
description: 'Gestion des utilisateurs et permissions',
path: '/users'
},
{
name: 'Produits',
description: 'Gestion des produits et catégories',
path: '/notes'
},
{
name: 'Commandes',
description: 'Suivi et gestion des commandes',
path: '/calendar'
},
{
name: 'Statistiques',
description: 'Vue d\'ensemble des statistiques et analyses',
path: '/weekly-summary'
},
{
name: 'Paramètres',
description: 'Configuration générale de l\'application',
path: '/pointage'
}
];
const { user } = useAuth();
const displayName = user?.first_name?.trim() || user?.username;
const visibleSections = sections.filter(
(section) => !section.adminOnly || user?.is_admin,
);
return (
<MainCard title="Accueil" icon={House}>
<main className="h-full min-h-0 w-full overflow-y-auto">
<div className="mx-auto w-full max-w-6xl space-y-7 p-4 sm:space-y-8 sm:p-6">
<header className="space-y-2">
<p className="break-words text-xl font-semibold tracking-tight sm:text-2xl">
{displayName ? `Bonjour ${displayName}` : 'Bienvenue au Fournil du Bocage'}
</p>
<main className="home-content">
<div className="tabs-list">
<h2>Liste des onglets</h2>
<div className="tabs-grid">
{tabs.map((tab, index) => (
<Card key={index} className="tab-card">
<CardHeader>
<CardTitle>{tab.name}</CardTitle>
<CardDescription>{tab.description}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="default">
<Link to={tab.path}>
Accéder à {tab.name}
</Link>
</Button>
</CardContent>
</Card>
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
Retrouve ici les outils et les informations de la boulangerie.
</p>
</header>
{visibleSections.map((section) => (
<ShortcutSection key={section.id} section={section} />
))}
</div>
</div>
</main>
<footer className="home-footer">
<p>© 2023 - Application</p>
</footer>
</MainCard>
);
};

View File

@ -0,0 +1,187 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Users} from 'lucide-react';
// Composant pour la gestion des utilisateurs
export default function ManageUsersPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState(null);
// Récupération des utilisateurs
useEffect(() => {
const fetchUsers = async () => {
try {
// Ici, vous devrez implémenter l'appel API réel
// Pour l'exemple, on utilise des données statiques
const mockUsers = [
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@example.com', role: 'user', status: 'active' },
{ id: 2, name: 'Marie Martin', email: 'marie.martin@example.com', role: 'admin', status: 'active' },
{ id: 3, name: 'Pierre Durand', email: 'pierre.durand@example.com', role: 'user', status: 'inactive' },
];
setUsers(mockUsers);
setLoading(false);
} catch (err) {
setError('Erreur lors de la récupération des utilisateurs');
setLoading(false);
}
};
fetchUsers();
}, []);
const handleDeleteUser = (user) => {
setUserToDelete(user);
setDeleteDialogOpen(true);
};
const confirmDeleteUser = async () => {
if (!userToDelete) return;
try {
// Ici, vous devrez implémenter l'appel API pour supprimer l'utilisateur
// Pour l'exemple, on simule la suppression
setUsers(users.filter(u => u.id !== userToDelete.id));
setDeleteDialogOpen(false);
setUserToDelete(null);
} catch (err) {
setError('Erreur lors de la suppression de l\'utilisateur');
}
};
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<LoadingCard
icon={Users}
title="Gestion des Utilisateurs"
message="Chargement du module de gestion des utilisateurs..."
/>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-red-500">{error}</p>
</div>
</div>
);
}
return (
<MainCard title="Gestion des utilisateurs" icon={Users}>
<Card>
<CardHeader>
<CardTitle>Recherche d'utilisateurs</CardTitle>
<CardDescription>
Recherchez des utilisateurs par nom ou email
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Rechercher par nom ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-md"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Liste des utilisateurs</CardTitle>
<CardDescription>
Tous les utilisateurs inscrits dans le système
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === 'active' ? 'success' : 'destructive'}>
{user.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm">Modifier</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteUser(user)}
>
Supprimer
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement l'utilisateur {userToDelete?.name} du système.
Cette action ne peut pas être annulée.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteUser}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</MainCard>
);
}

View File

@ -1,8 +1,6 @@
import React, { useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import { useAuth } from '../context/useAuth';
import { CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -15,12 +13,88 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { User } from 'lucide-react';
import api from '../api/axios'; // Adapte ce chemin si ton instance Axios est ailleurs.
import {
AlertCircle,
CheckCircle2,
FileText,
FolderOpen,
KeyRound,
Loader2,
RefreshCw,
User,
} from 'lucide-react';
import { useAuth } from '../context/useAuth';
import api from '../api/axios';
const FILES_ENDPOINT = '/file/files/';
const getErrorText = (value) => {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(getErrorText).filter(Boolean).join(' ');
return '';
};
const getRequestError = (error, fallback) => {
const data = error.response?.data;
return (
getErrorText(data?.current_password) ||
getErrorText(data?.new_password) ||
getErrorText(data?.confirm_password) ||
getErrorText(data?.non_field_errors) ||
getErrorText(data?.detail) ||
getErrorText(data?.error) ||
getErrorText(Array.isArray(data) ? data : null) ||
fallback
);
};
const parseFilesResponse = (data) => {
const items = Array.isArray(data) ? data : data?.results;
if (!Array.isArray(items)) {
throw new Error('Réponse de fichiers invalide.');
}
// La pagination reste sur le même endpoint Axios, même si Django
// renvoie une URL absolue dans "next".
const next = typeof data?.next === 'string' && data.next
? `${FILES_ENDPOINT}${new URL(data.next, 'https://localhost').search}`
: null;
return {
items,
next,
total: Array.isArray(data)
? data.length
: Number.isInteger(data?.count) && data.count >= 0
? data.count
: null,
};
};
const formatDate = (value) => {
if (!value) return 'Non renseignée';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'Non renseignée';
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const ProfileField = ({ label, children }) => (
<div className="grid gap-1.5 py-3.5 sm:grid-cols-2 sm:gap-4">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-sm font-medium sm:text-right">
{children || 'Non renseigné'}
</dd>
</div>
);
const ProfileContent = ({ user }) => {
const MyspacePage = () => {
const { user } = useAuth();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
@ -28,6 +102,66 @@ const MyspacePage = () => {
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
const [savingPassword, setSavingPassword] = useState(false);
const passwordRequestPending = useRef(false);
const [filesState, setFilesState] = useState({
items: [],
next: null,
total: null,
loading: true,
loadingMore: false,
error: '',
});
const filesRequest = useRef(null);
const loadFiles = useCallback(async (nextPage = null) => {
filesRequest.current?.abort();
const controller = new AbortController();
filesRequest.current = controller;
const append = Boolean(nextPage);
setFilesState((previous) => ({
...previous,
loading: !append,
loadingMore: append,
error: '',
}));
try {
const response = await api.get(nextPage || FILES_ENDPOINT, {
signal: controller.signal,
});
const page = parseFilesResponse(response.data);
if (controller.signal.aborted) return;
setFilesState((previous) => ({
items: append
? Array.from(new Map(
[...previous.items, ...page.items].map((file) => [file.id, file]),
).values())
: page.items,
next: page.next,
total: page.total,
loading: false,
loadingMore: false,
error: '',
}));
} catch (error) {
if (controller.signal.aborted) return;
setFilesState((previous) => ({
...previous,
loading: false,
loadingMore: false,
error: getRequestError(error, 'Impossible de charger tes fichiers. Réessaie dans un instant.'),
}));
}
}, []);
useEffect(() => {
loadFiles();
return () => filesRequest.current?.abort();
}, [loadFiles]);
const resetPasswordForm = () => {
setCurrentPassword('');
@ -37,14 +171,17 @@ const MyspacePage = () => {
};
const handlePasswordDialogChange = (open) => {
if (savingPassword) return;
if (passwordRequestPending.current) return;
resetPasswordForm();
if (open) setPasswordSuccess('');
setPasswordDialogOpen(open);
if (!open) resetPasswordForm();
};
const handleChangePassword = async (event) => {
event.preventDefault();
if (passwordRequestPending.current) return;
setPasswordError('');
setPasswordSuccess('');
@ -58,6 +195,7 @@ const MyspacePage = () => {
return;
}
passwordRequestPending.current = true;
setSavingPassword(true);
try {
@ -69,170 +207,188 @@ const MyspacePage = () => {
resetPasswordForm();
setPasswordDialogOpen(false);
setPasswordSuccess('Mot de passe modifié.');
setPasswordSuccess('Ton mot de passe a bien été modifié.');
} catch (error) {
const data = error.response?.data;
const message =
data?.current_password?.[0] ||
data?.new_password?.[0] ||
data?.confirm_password?.[0] ||
data?.detail ||
'Impossible de modifier le mot de passe.';
setPasswordError(message);
setPasswordError(getRequestError(error, 'Impossible de modifier le mot de passe.'));
} finally {
passwordRequestPending.current = false;
setSavingPassword(false);
}
};
if (!user) {
return (
<LoadingCard
icon={User}
title="Profil"
message="Chargement du profil..."
/>
);
}
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim();
const displayName = fullName || user.username || 'Mon profil';
const initials = [user.first_name?.trim()?.[0], user.last_name?.trim()?.[0]]
.filter(Boolean).join('').toUpperCase() || user.username?.[0]?.toUpperCase() || '?';
const filesBusy = filesState.loading || filesState.loadingMore;
return (
<MainCard title={user.first_name} icon={User}>
<CardContent className="overflow-y-auto">
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Prénom :</span>
<span>{user.first_name}</span>
<MainCard title="Mon espace" icon={User}>
<div className="h-full min-h-0 w-full overflow-y-auto">
<div className="mx-auto w-full max-w-6xl space-y-6 p-4 sm:p-6">
<header className="flex items-center gap-4">
<div
aria-hidden="true"
className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-xl font-semibold text-primary sm:size-16"
>
{initials}
</div>
<Separator />
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Nom :</span>
<span>{user.last_name}</span>
</div>
<Separator />
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Admin :</span>
<div className="min-w-0 space-y-2">
<p className="break-words text-xl font-semibold tracking-tight sm:text-2xl">
{displayName}
</p>
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
{user.is_admin ? 'Oui' : 'Non'}
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
</Badge>
</div>
</header>
<Separator />
<div className="grid items-start gap-5 lg:grid-cols-2">
<div className="min-w-0 space-y-5">
<section aria-labelledby="profile-details-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h2 id="profile-details-title" className="text-lg font-semibold tracking-tight">
Informations personnelles
</h2>
<p className="mt-1 text-sm text-muted-foreground">Les informations associées à ton compte.</p>
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Nom d'utilisateur :</span>
<span>{user.username}</span>
</div>
<dl className="mt-3 divide-y divide-border">
<ProfileField label="Prénom">{user.first_name}</ProfileField>
<ProfileField label="Nom">{user.last_name}</ProfileField>
<ProfileField label="Nom d’utilisateur">{user.username}</ProfileField>
<ProfileField label="Date d’inscription">{formatDate(user.date_joined)}</ProfileField>
</dl>
</section>
<Separator />
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Date d'inscription :</span>
<span>
{user.date_joined
? new Date(user.date_joined).toLocaleDateString('fr-FR')
: 'Non spécifié'}
<section aria-labelledby="profile-security-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<KeyRound className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0 space-y-1">
<h2 id="profile-security-title" className="text-lg font-semibold tracking-tight">Mot de passe</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
Modifie le mot de passe utilisé pour te connecter.
</p>
</div>
</div>
<Separator />
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Mot de passe :</span>
<Button
type="button"
variant="outline"
onClick={() => {
setPasswordSuccess('');
setPasswordDialogOpen(true);
}}
>
<Button type="button" variant="outline" className="mt-4 w-full sm:w-auto" onClick={() => handlePasswordDialogChange(true)}>
Modifier le mot de passe
</Button>
</div>
{passwordSuccess && (
<p role="status" className="text-sm text-green-600">
{passwordSuccess}
<div role="status" className="mt-4 flex items-start gap-2 rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{passwordSuccess}</p>
</div>
)}
</section>
</div>
<section aria-labelledby="profile-files-title" className="min-w-0 rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2 id="profile-files-title" className="text-lg font-semibold tracking-tight">Mes fichiers</h2>
<p className="text-sm text-muted-foreground">Les documents mis à ta disposition.</p>
</div>
<Button type="button" variant="ghost" size="icon" aria-label="Actualiser les fichiers" title="Actualiser les fichiers" disabled={filesBusy} onClick={() => loadFiles()}>
<RefreshCw className={`size-4 ${filesState.loading ? 'animate-spin motion-reduce:animate-none' : ''}`} aria-hidden="true" />
</Button>
</div>
{filesState.error && (
<div role="alert" className="mt-4 space-y-3 rounded-lg border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{filesState.error}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => loadFiles()} disabled={filesBusy}>Réessayer</Button>
</div>
)}
{filesState.loading && filesState.items.length === 0 && (
<div role="status" className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
Chargement des fichiers…
</div>
)}
{!filesState.loading && !filesState.error && filesState.items.length === 0 && (
<div className="mt-4 flex flex-col items-center rounded-lg border border-dashed border-border px-4 py-10 text-center">
<FolderOpen className="mb-3 size-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium">Aucun fichier pour le moment</p>
<p className="mt-1 max-w-xs text-sm leading-relaxed text-muted-foreground">
Les documents ajoutés à ton espace apparaîtront ici.
</p>
</div>
)}
{filesState.items.length > 0 && (
<>
<ul className="mt-4 divide-y divide-border" aria-busy={filesBusy}>
{filesState.items.map((file) => (
<li key={file.id} className="flex items-start gap-3 py-3.5">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<FileText className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<p className="break-words text-sm font-medium leading-relaxed">{file.name || 'Fichier sans nom'}</p>
<Badge variant="secondary" className="h-auto max-w-full whitespace-normal break-words text-left text-xs">
{typeof file.type === 'string' && file.type.trim() ? file.type : 'Sans catégorie'}
</Badge>
</div>
</li>
))}
</ul>
<div className="mt-3 space-y-3 border-t border-border pt-4">
<p className="text-xs text-muted-foreground" role="status">
{filesState.items.length} fichier{filesState.items.length > 1 ? 's' : ''} affiché{filesState.items.length > 1 ? 's' : ''}
{filesState.total !== null && filesState.total > filesState.items.length ? ` sur ${filesState.total}` : ''}
</p>
{filesState.next && (
<Button type="button" variant="outline" className="w-full" disabled={filesBusy} onClick={() => loadFiles(filesState.next)}>
{filesState.loadingMore && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
{filesState.loadingMore ? 'Chargement…' : 'Afficher plus'}
</Button>
)}
</div>
</CardContent>
</>
)}
</section>
</div>
</div>
</div>
<Dialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogChange}
>
<DialogContent>
<Dialog open={passwordDialogOpen} onOpenChange={handlePasswordDialogChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Modifier le mot de passe</DialogTitle>
<DialogDescription>
Saisis ton mot de passe actuel, puis le nouveau deux fois.
</DialogDescription>
<DialogDescription>Saisis ton mot de passe actuel, puis le nouveau deux fois.</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-4">
<form onSubmit={handleChangePassword} className="space-y-4" aria-busy={savingPassword}>
<div className="space-y-2">
<Label htmlFor="current-password">Mot de passe actuel</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
disabled={savingPassword}
required
/>
<Input id="current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={savingPassword} required />
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Nouveau mot de passe</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={savingPassword}
required
/>
<Input id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={savingPassword} required />
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
Confirmer le nouveau mot de passe
</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
disabled={savingPassword}
required
/>
<Label htmlFor="confirm-password">Confirmer le nouveau mot de passe</Label>
<Input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={savingPassword} required />
</div>
{passwordError && (
<p role="alert" className="text-sm text-destructive">
{passwordError}
</p>
)}
{passwordError && <p role="alert" className="text-sm text-destructive">{passwordError}</p>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handlePasswordDialogChange(false)}
disabled={savingPassword}
>
Annuler
</Button>
<Button type="button" variant="outline" onClick={() => handlePasswordDialogChange(false)} disabled={savingPassword}>Annuler</Button>
<Button type="submit" disabled={savingPassword}>
{savingPassword ? 'Modification...' : 'Enregistrer'}
{savingPassword && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
{savingPassword ? 'Modification…' : 'Enregistrer'}
</Button>
</DialogFooter>
</form>
@ -242,4 +398,15 @@ const MyspacePage = () => {
);
};
const MyspacePage = () => {
const { user } = useAuth();
if (!user) {
return <LoadingCard icon={User} title="Profil" message="Chargement du profil…" />;
}
// Réinitialise les formulaires et les fichiers si le compte change.
return <ProfileContent key={user.id ?? user.username} user={user} />;
};
export default MyspacePage;