nice
This commit is contained in:
parent
19173d1e0c
commit
12d3a95811
@ -49,6 +49,7 @@ INSTALLED_APPS = [
|
|||||||
'time_tracking',
|
'time_tracking',
|
||||||
'events',
|
'events',
|
||||||
'notes',
|
'notes',
|
||||||
|
'file', # Ajout du module de gestion des fichiers
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -156,3 +157,4 @@ SIMPLE_JWT = {
|
|||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
|||||||
@ -29,4 +29,5 @@ urlpatterns = [
|
|||||||
path('api/time/', include('time_tracking.urls')),
|
path('api/time/', include('time_tracking.urls')),
|
||||||
path('api/event/', include('events.urls')),
|
path('api/event/', include('events.urls')),
|
||||||
path('api/notes/', include('notes.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
6
backend/file/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FileConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'file'
|
||||||
30
backend/file/migrations/0001_initial.py
Normal file
30
backend/file/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/file/migrations/__init__.py
Normal file
0
backend/file/migrations/__init__.py
Normal file
23
backend/file/models.py
Normal file
23
backend/file/models.py
Normal 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}"
|
||||||
8
backend/file/serializers.py
Normal file
8
backend/file/serializers.py
Normal 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
8
backend/file/urls.py
Normal 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
27
backend/file/views.py
Normal 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)
|
||||||
@ -24,6 +24,7 @@ import TimeClockPage from './pages/TimeClockPage';
|
|||||||
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
||||||
import CalendarPage from './pages/CalendarPage';
|
import CalendarPage from './pages/CalendarPage';
|
||||||
import NotesPage from './pages/NotesPage';
|
import NotesPage from './pages/NotesPage';
|
||||||
|
import ManageUsersPage from './pages/ManageUsersPage';
|
||||||
import { useAuth } from './context/useAuth';
|
import { useAuth } from './context/useAuth';
|
||||||
|
|
||||||
// Composant personnalisé pour le trigger mobile avec icône menu
|
// 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
|
<Route
|
||||||
path="/weekly-summary"
|
path="/weekly-summary"
|
||||||
element={
|
element={
|
||||||
@ -144,12 +154,10 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/notes" element={<ProtectedRoute><NotesPage /></ProtectedRoute>} />
|
<Route path="/notes" element={<ProtectedRoute><NotesPage /></ProtectedRoute>} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="site-footer shrink-0 py-2">
|
<footer className="site-footer shrink-0 py-2">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||||
import { Link, NavLink } from 'react-router-dom';
|
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -24,14 +24,31 @@ import {
|
|||||||
Wheat,
|
Wheat,
|
||||||
Calendar,
|
Calendar,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
ChevronDown,
|
||||||
|
List,
|
||||||
|
BarChart3,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const NavBar = () => {
|
const NavBar = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { isMobile, toggleSidebar, setOpen } = useSidebar();
|
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);
|
const closeTimeout = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUsersSection) {
|
||||||
|
setUsersMenuOpen(true);
|
||||||
|
}
|
||||||
|
}, [pathname, isUsersSection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (closeTimeout.current) {
|
if (closeTimeout.current) {
|
||||||
@ -74,7 +91,7 @@ const NavBar = () => {
|
|||||||
*/
|
*/
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
title: user.first_name,
|
title: 'Mon espace',
|
||||||
href: '/myspace',
|
href: '/myspace',
|
||||||
icon: User,
|
icon: User,
|
||||||
},
|
},
|
||||||
@ -109,11 +126,22 @@ const NavBar = () => {
|
|||||||
/*
|
/*
|
||||||
* Administration
|
* Administration
|
||||||
*/
|
*/
|
||||||
const administration = [
|
const userNavigation = [
|
||||||
{
|
{
|
||||||
title: 'Utilisateurs',
|
title: 'Liste',
|
||||||
href: '/users',
|
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>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<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>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@ -129,7 +129,7 @@ const AdminUsersPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainCard title="Gestion des utilisateurs" icon={Users}>
|
<MainCard title="Liste des utilisateurs" icon={Users}>
|
||||||
<CardContent className="overflow-y-auto">
|
<CardContent className="overflow-y-auto">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
|||||||
@ -1,77 +1,199 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
import {
|
||||||
import { Button } from '@/components/ui/button';
|
ArrowUpRight,
|
||||||
import MainCard from "@/components/MainCard"
|
BarChart3,
|
||||||
import { House } from 'lucide-react';
|
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 HomePage = () => {
|
||||||
const tabs = [
|
const { user } = useAuth();
|
||||||
{
|
const displayName = user?.first_name?.trim() || user?.username;
|
||||||
name: 'Accueil',
|
|
||||||
description: 'Page d\'accueil principale avec la liste des onglets et leurs utilités',
|
const visibleSections = sections.filter(
|
||||||
path: '/'
|
(section) => !section.adminOnly || user?.is_admin,
|
||||||
},
|
);
|
||||||
{
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainCard title="Accueil" icon={House}>
|
<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">
|
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||||
<div className="tabs-list">
|
Retrouve ici les outils et les informations de la boulangerie.
|
||||||
<h2>Liste des onglets</h2>
|
</p>
|
||||||
<div className="tabs-grid">
|
</header>
|
||||||
{tabs.map((tab, index) => (
|
|
||||||
<Card key={index} className="tab-card">
|
{visibleSections.map((section) => (
|
||||||
<CardHeader>
|
<ShortcutSection key={section.id} section={section} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="home-footer">
|
|
||||||
<p>© 2023 - Application</p>
|
|
||||||
</footer>
|
|
||||||
</MainCard>
|
</MainCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
187
frontend/src/pages/ManageUsersPage.jsx
Normal file
187
frontend/src/pages/ManageUsersPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import LoadingCard from '@/components/LoadingCard';
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
import MainCard from '@/components/MainCard';
|
import MainCard from '@/components/MainCard';
|
||||||
import { useAuth } from '../context/useAuth';
|
|
||||||
import { CardContent } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -15,12 +13,88 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import {
|
||||||
import { User } from 'lucide-react';
|
AlertCircle,
|
||||||
import api from '../api/axios'; // Adapte ce chemin si ton instance Axios est ailleurs.
|
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 [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
@ -28,6 +102,66 @@ const MyspacePage = () => {
|
|||||||
const [passwordError, setPasswordError] = useState('');
|
const [passwordError, setPasswordError] = useState('');
|
||||||
const [passwordSuccess, setPasswordSuccess] = useState('');
|
const [passwordSuccess, setPasswordSuccess] = useState('');
|
||||||
const [savingPassword, setSavingPassword] = useState(false);
|
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 = () => {
|
const resetPasswordForm = () => {
|
||||||
setCurrentPassword('');
|
setCurrentPassword('');
|
||||||
@ -37,14 +171,17 @@ const MyspacePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePasswordDialogChange = (open) => {
|
const handlePasswordDialogChange = (open) => {
|
||||||
if (savingPassword) return;
|
if (passwordRequestPending.current) return;
|
||||||
|
|
||||||
|
resetPasswordForm();
|
||||||
|
if (open) setPasswordSuccess('');
|
||||||
setPasswordDialogOpen(open);
|
setPasswordDialogOpen(open);
|
||||||
if (!open) resetPasswordForm();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangePassword = async (event) => {
|
const handleChangePassword = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (passwordRequestPending.current) return;
|
||||||
|
|
||||||
setPasswordError('');
|
setPasswordError('');
|
||||||
setPasswordSuccess('');
|
setPasswordSuccess('');
|
||||||
|
|
||||||
@ -58,6 +195,7 @@ const MyspacePage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
passwordRequestPending.current = true;
|
||||||
setSavingPassword(true);
|
setSavingPassword(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -69,170 +207,188 @@ const MyspacePage = () => {
|
|||||||
|
|
||||||
resetPasswordForm();
|
resetPasswordForm();
|
||||||
setPasswordDialogOpen(false);
|
setPasswordDialogOpen(false);
|
||||||
setPasswordSuccess('Mot de passe modifié.');
|
setPasswordSuccess('Ton mot de passe a bien été modifié.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const data = error.response?.data;
|
setPasswordError(getRequestError(error, 'Impossible de modifier le mot de passe.'));
|
||||||
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);
|
|
||||||
} finally {
|
} finally {
|
||||||
|
passwordRequestPending.current = false;
|
||||||
setSavingPassword(false);
|
setSavingPassword(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim();
|
||||||
return (
|
const displayName = fullName || user.username || 'Mon profil';
|
||||||
<LoadingCard
|
const initials = [user.first_name?.trim()?.[0], user.last_name?.trim()?.[0]]
|
||||||
icon={User}
|
.filter(Boolean).join('').toUpperCase() || user.username?.[0]?.toUpperCase() || '?';
|
||||||
title="Profil"
|
const filesBusy = filesState.loading || filesState.loadingMore;
|
||||||
message="Chargement du profil..."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainCard title={user.first_name} icon={User}>
|
<MainCard title="Mon espace" icon={User}>
|
||||||
<CardContent className="overflow-y-auto">
|
<div className="h-full min-h-0 w-full overflow-y-auto">
|
||||||
<div className="space-y-4">
|
<div className="mx-auto w-full max-w-6xl space-y-6 p-4 sm:p-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<header className="flex items-center gap-4">
|
||||||
<span className="font-medium">Prénom :</span>
|
<div
|
||||||
<span>{user.first_name}</span>
|
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>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<div className="min-w-0 space-y-2">
|
||||||
|
<p className="break-words text-xl font-semibold tracking-tight sm:text-2xl">
|
||||||
<div className="flex items-center justify-between gap-4">
|
{displayName}
|
||||||
<span className="font-medium">Nom :</span>
|
</p>
|
||||||
<span>{user.last_name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="font-medium">Admin :</span>
|
|
||||||
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
||||||
{user.is_admin ? 'Oui' : 'Non'}
|
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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">
|
<dl className="mt-3 divide-y divide-border">
|
||||||
<span className="font-medium">Nom d'utilisateur :</span>
|
<ProfileField label="Prénom">{user.first_name}</ProfileField>
|
||||||
<span>{user.username}</span>
|
<ProfileField label="Nom">{user.last_name}</ProfileField>
|
||||||
</div>
|
<ProfileField label="Nom d’utilisateur">{user.username}</ProfileField>
|
||||||
|
<ProfileField label="Date d’inscription">{formatDate(user.date_joined)}</ProfileField>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Separator />
|
<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">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
<span className="font-medium">Date d'inscription :</span>
|
<KeyRound className="size-5" aria-hidden="true" />
|
||||||
<span>
|
|
||||||
{user.date_joined
|
|
||||||
? new Date(user.date_joined).toLocaleDateString('fr-FR')
|
|
||||||
: 'Non spécifié'}
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Button type="button" variant="outline" className="mt-4 w-full sm:w-auto" onClick={() => handlePasswordDialogChange(true)}>
|
||||||
|
|
||||||
<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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Modifier le mot de passe
|
Modifier le mot de passe
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{passwordSuccess && (
|
{passwordSuccess && (
|
||||||
<p role="status" className="text-sm text-green-600">
|
<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">
|
||||||
{passwordSuccess}
|
<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>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog open={passwordDialogOpen} onOpenChange={handlePasswordDialogChange}>
|
||||||
open={passwordDialogOpen}
|
<DialogContent className="sm:max-w-md">
|
||||||
onOpenChange={handlePasswordDialogChange}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Modifier le mot de passe</DialogTitle>
|
<DialogTitle>Modifier le mot de passe</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Saisis ton mot de passe actuel, puis le nouveau deux fois.</DialogDescription>
|
||||||
Saisis ton mot de passe actuel, puis le nouveau deux fois.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<form onSubmit={handleChangePassword} className="space-y-4" aria-busy={savingPassword}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="current-password">Mot de passe actuel</Label>
|
<Label htmlFor="current-password">Mot de passe actuel</Label>
|
||||||
<Input
|
<Input id="current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={savingPassword} required />
|
||||||
id="current-password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
||||||
disabled={savingPassword}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-password">Nouveau mot de passe</Label>
|
<Label htmlFor="new-password">Nouveau mot de passe</Label>
|
||||||
<Input
|
<Input id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={savingPassword} required />
|
||||||
id="new-password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
disabled={savingPassword}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password">
|
<Label htmlFor="confirm-password">Confirmer le nouveau mot de passe</Label>
|
||||||
Confirmer le nouveau mot de passe
|
<Input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={savingPassword} required />
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="confirm-password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
|
||||||
disabled={savingPassword}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{passwordError && (
|
{passwordError && <p role="alert" className="text-sm text-destructive">{passwordError}</p>}
|
||||||
<p role="alert" className="text-sm text-destructive">
|
|
||||||
{passwordError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => handlePasswordDialogChange(false)} disabled={savingPassword}>Annuler</Button>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handlePasswordDialogChange(false)}
|
|
||||||
disabled={savingPassword}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={savingPassword}>
|
<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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</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;
|
export default MyspacePage;
|
||||||
Loading…
x
Reference in New Issue
Block a user