feat: initialize project structure with Next.js, Prisma, and Tailwind CSS setup

This commit is contained in:
2025-10-17 21:59:13 +02:00
commit 02643e3702
49 changed files with 3090 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.idea/
.idea/*
.idea
INDICATIONS_POUR_LLMS.md
public/uploads
public/uploads/*
prisma/dev.db
pnpm-lock.yaml
.vscode/
.vscode/*
.vscode

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

14
.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>

13
.idea/material_theme_project_new.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="58a80762:1886ea05d16:-8000" />
<option name="version" value="8.12.6" />
</MTProjectMetadataState>
</option>
</component>
</project>

5
.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2
}

42
API_DOCS.md Normal file
View File

@@ -0,0 +1,42 @@
# Documentation des API
## Endpoints
### POST /api/auth/signup
- Rôle requis : SUPER_ADMIN
- Description : Créer un compte (membre ou admin)
- Corps : { email: string, password: string, role: 'MEMBER' | 'ADMIN' }
### GET /api/hours
- Rôle requis : MEMBER, ADMIN, SUPER_ADMIN
- Description : Lister les heures (propres pour membre, toutes pour admin)
### POST /api/hours
- Rôle requis : MEMBER
- Description : Ajouter une heure
- Corps : { date: string, duration: number, reason: string }
### PUT /api/hours/[id]
- Rôle requis : ADMIN, SUPER_ADMIN
- Description : Valider ou rejeter une heure
- Corps : { status: 'VALIDATED' | 'REJECTED' }
### GET /api/settings
- Rôle requis : ADMIN, SUPER_ADMIN
- Description : Lire les paramètres du club
### PUT /api/settings
- Rôle requis : ADMIN, SUPER_ADMIN
- Description : Mettre à jour le nom et logo du club
- Corps : { name: string, logo: string }
### GET /api/export?format=csv|excel
- Rôle requis : ADMIN, SUPER_ADMIN
- Description : Exporter les heures en CSV ou Excel

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npx prisma generate
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

37
INITIAL_SETUP.md Normal file
View File

@@ -0,0 +1,37 @@
# Configuration Initiale
## Créer le Super Admin
### Méthode 1 : Script Node.js
Exécutez le script pour créer le super admin :
```bash
node scripts/create-super-admin.js
```
Le script crée un utilisateur avec email `superadmin@example.com` et mot de passe `superadmin123` (haché).
### Méthode 2 : Via Prisma Studio
```bash
npx prisma studio
```
Créez manuellement un utilisateur avec role = SUPER_ADMIN.
## Lancer le projet
### Développement
```bash
npm run dev
```
### Production avec Docker
```bash
docker-compose up --build
```
Assurez-vous que le dossier `data` existe pour la DB SQLite.

148
README.md Normal file
View File

@@ -0,0 +1,148 @@
# Plateforme de Comptage d'Heures
Une application web moderne pour la gestion des heures travaillées dans un club scolaire ou une organisation similaire. Permet aux membres de saisir leurs heures, aux administrateurs de valider les demandes, et de gérer les utilisateurs et les paramètres du club.
## Fonctionnalités
- **Authentification sécurisée** : Connexion avec NextAuth.js
- **Gestion des rôles** : Membres, Administrateurs, Super Administrateurs
- **Saisie d'heures** : Interface intuitive pour ajouter des heures travaillées
- **Validation des heures** : Système d'approbation par les administrateurs
- **Gestion des utilisateurs** : Création, suppression et gestion des comptes
- **Paramètres du club** : Configuration du nom et du logo
- **Export de données** : Export CSV et Excel des heures validées
- **Thème sombre/clair** : Support automatique du thème selon les préférences système
- **Design responsive** : Optimisé pour mobile et desktop
- **Notifications** : Toasts pour les actions utilisateur
## Technologies utilisées
- **Framework** : Next.js 15 (App Router)
- **Langage** : TypeScript
- **Base de données** : SQLite avec Prisma ORM
- **Authentification** : NextAuth.js
- **UI** : Tailwind CSS + shadcn/ui + Radix UI
- **Gestionnaire de paquets** : pnpm
- **Déploiement** : Compatible Docker
## Prérequis
- Node.js 18+
- pnpm
## Installation
1. **Cloner le repository**
```bash
git clone https://github.com/breizhhardware/site-comptage-heure.git
cd site-comptage-heure
```
2. **Installer les dépendances**
```bash
pnpm install
```
3. **Configuration de la base de données**
```bash
# Appliquer le schéma Prisma
npx prisma db push
# (Optionnel) Générer le client Prisma
npx prisma generate
```
4. **Variables d'environnement**
Créer un fichier `.env.local` à la racine :
```env
NEXTAUTH_SECRET=votre-secret-très-long-et-sécurisé
NEXTAUTH_URL=http://localhost:3000
```
5. **Créer un Super Administrateur**
```bash
node scripts/create-super-admin.js
```
## Utilisation
### Démarrage en développement
```bash
pnpm dev
```
Ouvrir [http://localhost:3000](http://localhost:3000)
### Première connexion
- Utilisez les identifiants du Super Administrateur créé
- Configurez le nom et le logo du club dans l'admin
- Créez des comptes pour les administrateurs et membres
### Rôles et permissions
- **Membre** : Peut saisir et consulter ses propres heures
- **Administrateur** : Peut valider/rejeter les heures de tous, gérer les paramètres
- **Super Administrateur** : Peut créer des comptes admin/membre, supprimer des utilisateurs
## Scripts disponibles
- `pnpm dev` : Serveur de développement
- `pnpm build` : Build de production
- `pnpm start` : Serveur de production
- `pnpm format` : Formatage du code avec Prettier
## Structure du projet
```
├── app/ # Pages Next.js (App Router)
│ ├── api/ # Routes API
│ ├── admin/ # Page administration
│ ├── dashboard/ # Tableau de bord
│ ├── login/ # Page de connexion
│ └── layout.tsx # Layout principal
├── components/ # Composants réutilisables
│ ├── ui/ # Composants shadcn/ui
│ └── Header.tsx # Header de l'application
├── lib/ # Utilitaires
│ ├── auth.ts # Configuration NextAuth
│ ├── prisma.ts # Client Prisma
│ └── use-toast.ts # Hook pour les toasts
├── prisma/ # Schéma et migrations Prisma
└── public/ # Assets statiques
```
## API Routes
- `GET/POST /api/hours` : Gestion des heures
- `PUT /api/hours/[id]` : Validation d'une heure
- `DELETE /api/hours/[id]` : Suppression d'une heure
- `GET/PUT /api/settings` : Paramètres du club
- `POST /api/auth/signup` : Création d'utilisateur
- `GET /api/export` : Export des données
## Déploiement
### Avec Docker
1. Build l'image :
```bash
docker build -t comptage-heures .
```
2. Run le container :
```bash
docker run -p 3000:3000 comptage-heures
```
## Contribution
1. Fork le projet
2. Créer une branche feature (`git checkout -b feature/nouvelle-fonction`)
3. Commit les changements (`git commit -am 'Ajoute nouvelle fonction'`)
4. Push la branche (`git push origin feature/nouvelle-fonction`)
5. Créer une Pull Request
## Support
Pour toute question ou problème, ouvrir une issue sur GitHub.

514
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,514 @@
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { DatePicker } from '@/components/ui/date-picker';
import { format } from 'date-fns';
interface Hour {
id: string;
date: string;
duration: number;
reason: string;
status: string;
userId: string;
user: { email: string; firstName?: string; lastName?: string; role: string };
}
interface Settings {
name: string;
logo: string;
}
export default function AdminPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [hours, setHours] = useState<Hour[]>([]);
const [settings, setSettings] = useState<Settings>({ name: '', logo: '' });
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('MEMBER');
const [logoFile, setLogoFile] = useState<File | null>(null);
const [date, setDate] = useState<Date>();
const [duration, setDuration] = useState('');
const [reason, setReason] = useState('');
const [hoursInput, setHoursInput] = useState('');
const [minutesInput, setMinutesInput] = useState('');
const [newFirstName, setNewFirstName] = useState('');
const [newLastName, setNewLastName] = useState('');
const [showAll, setShowAll] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<{
id: string;
name: string;
} | null>(null);
useEffect(() => {
if (status === 'loading') return;
if (
!session ||
(session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')
) {
router.push('/dashboard');
return;
}
fetchHours();
fetchSettings();
}, [session, status, router]);
const fetchHours = async () => {
const res = await fetch('/api/hours');
if (res.ok) {
const data = await res.json();
setHours(data);
}
};
const fetchSettings = async () => {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
setSettings(data);
}
};
const handleValidate = async (id: number, status: string) => {
await fetch(`/api/hours/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
fetchHours();
};
const handleUpdateSettings = async (e: React.FormEvent) => {
e.preventDefault();
let logoPath = settings.logo;
if (logoFile) {
const formData = new FormData();
formData.append('file', logoFile);
const uploadRes = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
logoPath = uploadData.path;
} else {
alert('Erreur upload');
return;
}
}
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: settings.name, logo: logoPath }),
});
setLogoFile(null);
fetchSettings();
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: newEmail,
password: newPassword,
role: newRole,
firstName: newFirstName,
lastName: newLastName,
}),
});
if (res.ok) {
setNewEmail('');
setNewPassword('');
setNewRole('MEMBER');
setNewFirstName('');
setNewLastName('');
toast.success('Utilisateur créé');
}
};
const handleExport = (format: string) => {
window.open(`/api/export?format=${format}`, '_blank');
};
const handleAddHour = async (e: React.FormEvent) => {
e.preventDefault();
const totalMinutes = parseInt(hoursInput) * 60 + parseInt(minutesInput);
const dateString = date ? format(date, 'yyyy-MM-dd') : '';
const res = await fetch('/api/hours', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: dateString,
duration: totalMinutes,
reason,
}),
});
if (res.ok) {
setDate(undefined);
setHoursInput('');
setMinutesInput('');
setReason('');
fetchHours();
}
};
const handleDelete = async (id: number) => {
await fetch(`/api/hours/${id}`, {
method: 'DELETE',
});
fetchHours();
};
const userDisplayNames = hours.reduce(
(acc, hour) => {
const name =
`${hour.user.firstName || ''} ${hour.user.lastName || ''}`.trim() ||
hour.user.email;
acc[hour.user.email] = name;
return acc;
},
{} as Record<string, string>,
);
const userMap = {} as Record<
string,
{ name: string; email: string; role: string }
>;
hours.forEach((hour) => {
userMap[hour.userId] = {
name: userDisplayNames[hour.user.email],
email: hour.user.email,
role: hour.user.role,
};
});
const userTotals = hours.reduce(
(acc, hour) => {
if (hour.status === 'VALIDATED') {
acc[hour.userId] = (acc[hour.userId] || 0) + hour.duration;
}
return acc;
},
{} as Record<string, number>,
);
const handleDeleteUser = async () => {
if (!selectedUser) return;
await fetch(`/api/users/${selectedUser.id}`, { method: 'DELETE' });
setDialogOpen(false);
setSelectedUser(null);
fetchHours();
};
if (status === 'loading') return <div>Chargement...</div>;
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN';
const sortedHours = hours.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
const displayedHours = showAll ? sortedHours : sortedHours.slice(0, 10);
const formatHours = (minutes: number) => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${m}min`;
};
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Administration</h1>
<Card className="mb-4">
<CardHeader>
<CardTitle>Ajouter des heures</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddHour} className="space-y-4">
<div>
<Label htmlFor="date">Date</Label>
<DatePicker date={date} setDate={setDate} />
</div>
<div className="flex space-x-2">
<div>
<Label htmlFor="hours">Heures</Label>
<Input
id="hours"
type="number"
value={hoursInput}
onChange={(e) => setHoursInput(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="minutes">Minutes</Label>
<Input
id="minutes"
type="number"
value={minutesInput}
onChange={(e) => setMinutesInput(e.target.value)}
required
/>
</div>
</div>
<div>
<Label htmlFor="reason">Raison</Label>
<Input
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
/>
</div>
<Button type="submit">Ajouter</Button>
</form>
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>Gestion des heures</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Durée</TableHead>
<TableHead>Raison</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Utilisateur</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayedHours.map((hour) => (
<TableRow key={hour.id}>
<TableCell>
{new Date(hour.date).toLocaleDateString()}
</TableCell>
<TableCell>{hour.duration} min</TableCell>
<TableCell>{hour.reason}</TableCell>
<TableCell>{hour.status}</TableCell>
<TableCell>{userDisplayNames[hour.user.email]}</TableCell>
<TableCell>
{hour.status === 'VALIDATED' ||
hour.status === 'REJECTED' ? (
<Button
onClick={() => handleDelete(hour.id)}
variant="destructive"
>
Supprimer
</Button>
) : (
<>
<Button
onClick={() => handleValidate(hour.id, 'VALIDATED')}
className="mr-2"
disabled={hour.userId === session.user.id}
>
Valider
</Button>
<Button
onClick={() => handleDelete(hour.id)}
variant="destructive"
disabled={hour.userId === session.user.id}
>
Supprimer
</Button>
</>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{hours.length > 10 && !showAll && (
<div className="mt-4">
<Button onClick={() => setShowAll(true)}>Voir plus</Button>
</div>
)}
<div className="mt-4">
<h2 className="text-lg font-bold">Totaux par utilisateur</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Utilisateur</TableHead>
<TableHead>Heures Validées</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(userTotals).map(([userId, total]) => (
<TableRow key={userId}>
<TableCell>{userMap[userId]?.name}</TableCell>
<TableCell>{formatHours(total)}</TableCell>
<TableCell>
{userMap[userId]?.role === 'SUPER_ADMIN' ? (
'Super Admin'
) : (
<Button
onClick={() => {
setSelectedUser({
id: userId,
name: userMap[userId]?.name,
});
setDialogOpen(true);
}}
variant="destructive"
>
Supprimer
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{isSuperAdmin && (
<Card className="mb-4">
<CardHeader>
<CardTitle>Créer un compte</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateUser} className="space-y-4">
<div>
<Label htmlFor="newEmail">Email</Label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="newPassword">Mot de passe</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="newFirstName">Prénom</Label>
<Input
id="newFirstName"
value={newFirstName}
onChange={(e) => setNewFirstName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="newLastName">Nom de famille</Label>
<Input
id="newLastName"
value={newLastName}
onChange={(e) => setNewLastName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="newRole">Rôle</Label>
<select
id="newRole"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="MEMBER">Membre</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<Button type="submit">Créer</Button>
</form>
</CardContent>
</Card>
)}
<Card className="mb-4">
<CardHeader>
<CardTitle>Paramètres du Club</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdateSettings} className="space-y-4">
<div>
<Label htmlFor="name">Nom du Club</Label>
<Input
id="name"
value={settings.name}
onChange={(e) =>
setSettings({ ...settings, name: e.target.value })
}
/>
</div>
<div>
<Label htmlFor="logo">Logo</Label>
<Input
id="logo"
type="file"
accept="image/png,image/jpeg,image/jpg"
onChange={(e) => setLogoFile(e.target.files?.[0] || null)}
/>
{settings.logo && <p>Actuel : {settings.logo}</p>}
</div>
<Button type="submit">Mettre à jour</Button>
</form>
</CardContent>
</Card>
{selectedUser && (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmation</DialogTitle>
</DialogHeader>
<DialogDescription>
Êtes-vous sûr de vouloir supprimer cet utilisateur ?
</DialogDescription>
<DialogFooter>
<Button onClick={() => setDialogOpen(false)}>Annuler</Button>
<Button onClick={handleDeleteUser} variant="destructive">
Supprimer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<div>
<Button onClick={() => handleExport('csv')} className="mr-2">
Exporter CSV
</Button>
<Button onClick={() => handleExport('excel')}>Exporter Excel</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (session?.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const { email, password, role, firstName, lastName } = await request.json();
if (!email || !password || !role || !firstName || !lastName) {
return NextResponse.json(
{ error: 'Champs requis manquants' },
{ status: 400 },
);
}
if (!['MEMBER', 'ADMIN'].includes(role)) {
return NextResponse.json({ error: 'Rôle invalide' }, { status: 400 });
}
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'Utilisateur déjà existant' },
{ status: 400 },
);
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
id: uuidv4(),
email,
password: hashedPassword,
role,
firstName,
lastName,
},
});
return NextResponse.json({
message: 'Utilisateur créé',
user: { id: user.id, email: user.email, role: user.role },
});
}

100
app/api/export/route.ts Normal file
View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import * as csvWriter from 'csv-writer';
import ExcelJS from 'exceljs';
export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
if (
!session ||
(session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')
) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const format = searchParams.get('format');
const hours = await prisma.hour.findMany({
include: { user: { select: { email: true } } },
});
if (format === 'csv') {
const csvString = await generateCSV(hours);
return new NextResponse(csvString, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="hours.csv"',
},
});
} else if (format === 'excel') {
const buffer = await generateExcel(hours);
return new NextResponse(buffer, {
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="hours.xlsx"',
},
});
} else {
return NextResponse.json({ error: 'Format invalide' }, { status: 400 });
}
}
async function generateCSV(hours: any[]) {
const createCsvWriter = csvWriter.createObjectCsvStringifier;
const csvWriterInstance = createCsvWriter({
header: [
{ id: 'id', title: 'ID' },
{ id: 'date', title: 'Date' },
{ id: 'duration', title: 'Duration (min)' },
{ id: 'reason', title: 'Reason' },
{ id: 'status', title: 'Status' },
{ id: 'userEmail', title: 'User Email' },
],
});
const records = hours.map((h) => ({
id: h.id,
date: h.date.toISOString().split('T')[0],
duration: h.duration,
reason: h.reason,
status: h.status,
userEmail: h.user.email,
}));
return (
csvWriterInstance.getHeaderString() +
csvWriterInstance.stringifyRecords(records)
);
}
async function generateExcel(hours: any[]) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Hours');
worksheet.columns = [
{ header: 'ID', key: 'id' },
{ header: 'Date', key: 'date' },
{ header: 'Duration (min)', key: 'duration' },
{ header: 'Reason', key: 'reason' },
{ header: 'Status', key: 'status' },
{ header: 'User Email', key: 'userEmail' },
];
hours.forEach((h) => {
worksheet.addRow({
id: h.id,
date: h.date.toISOString().split('T')[0],
duration: h.duration,
reason: h.reason,
status: h.status,
userEmail: h.user.email,
});
});
const buffer = await workbook.xlsx.writeBuffer();
return buffer;
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getServerSession(authOptions);
if (
!session ||
(session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')
) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const { status } = await request.json();
if (!['VALIDATED', 'REJECTED'].includes(status)) {
return NextResponse.json({ error: 'Statut invalide' }, { status: 400 });
}
const { id } = await params;
const hour = await prisma.hour.update({
where: { id },
data: {
status,
validatedBy: session.user.id,
},
});
return NextResponse.json(hour);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getServerSession(authOptions);
if (
!session ||
(session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')
) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const { id } = await params;
const hour = await prisma.hour.findUnique({
where: { id },
});
if (!hour || (hour.status !== 'VALIDATED' && hour.status !== 'REJECTED')) {
return NextResponse.json(
{ error: 'Heure non trouvée ou non validée/rejetée' },
{ status: 404 },
);
}
await prisma.hour.delete({
where: { id },
});
return NextResponse.json({ message: 'Heure supprimée' });
}

84
app/api/hours/route.ts Normal file
View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { v4 as uuidv4 } from 'uuid';
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
const userId = session.user.id;
const role = session.user.role;
let hours;
if (role === 'MEMBER') {
hours = await prisma.hour.findMany({
where: { userId },
select: {
id: true,
date: true,
duration: true,
reason: true,
status: true,
userId: true,
user: { select: { email: true, firstName: true, lastName: true } },
},
});
} else if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
hours = await prisma.hour.findMany({
select: {
id: true,
date: true,
duration: true,
reason: true,
status: true,
userId: true,
user: {
select: { email: true, firstName: true, lastName: true, role: true },
},
},
});
} else {
return NextResponse.json({ error: 'Rôle non autorisé' }, { status: 403 });
}
return NextResponse.json(hours);
}
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (
!session ||
(session.user.role !== 'MEMBER' &&
session.user.role !== 'ADMIN' &&
session.user.role !== 'SUPER_ADMIN')
) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const { date, duration, reason } = await request.json();
if (!date || !duration || !reason) {
return NextResponse.json(
{ error: 'Champs requis manquants' },
{ status: 400 },
);
}
const userId = session.user.id;
const hour = await prisma.hour.create({
data: {
id: uuidv4(),
date: new Date(date),
duration,
reason,
userId,
},
});
return NextResponse.json(hour);
}

29
app/api/settings/route.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
const settings = await prisma.clubSettings.findFirst();
return NextResponse.json(settings || { name: '', logo: '' });
}
export async function PUT(request: NextRequest) {
const session = await getServerSession(authOptions);
if (
!session ||
(session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')
) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const { name, logo } = await request.json();
const settings = await prisma.clubSettings.upsert({
where: { id: 'settings' },
update: { name, logo },
create: { name, logo },
});
return NextResponse.json(settings);
}

35
app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import path from 'path';
export async function POST(request: NextRequest) {
const data = await request.formData();
const file = data.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'Aucun fichier' }, { status: 400 });
}
if (file.size > 2 * 1024 * 1024) {
// 2Mo
return NextResponse.json({ error: 'Fichier trop grand' }, { status: 400 });
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Type de fichier non autorisé' },
{ status: 400 },
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const filepath = path.join(process.cwd(), 'public', 'uploads', filename);
await writeFile(filepath, buffer);
return NextResponse.json({ path: `/uploads/${filename}` });
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } },
) {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const userId = params.id;
const user = await prisma.user.findUnique({
where: { id: userId },
include: { hours: true },
});
if (!user) {
return NextResponse.json(
{ error: 'Utilisateur non trouvé' },
{ status: 404 },
);
}
if (user.hours.length > 0) {
return NextResponse.json(
{ error: 'Impossible de supprimer un utilisateur avec des heures' },
{ status: 400 },
);
}
await prisma.user.delete({
where: { id: userId },
});
return NextResponse.json({ message: 'Utilisateur supprimé' });
}

232
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,232 @@
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Hour {
id: number;
date: string;
duration: number;
reason: string;
status: string;
user: { email: string };
}
export default function DashboardPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [hours, setHours] = useState<Hour[]>([]);
const [date, setDate] = useState('');
const [duration, setDuration] = useState('');
const [reason, setReason] = useState('');
const [hoursInput, setHoursInput] = useState('');
const [minutesInput, setMinutesInput] = useState('');
useEffect(() => {
if (status === 'loading') return;
if (!session) {
router.push('/login');
return;
}
const isAdmin =
session.user.role === 'ADMIN' || session.user.role === 'SUPER_ADMIN';
if (isAdmin) {
router.push('/admin');
return;
}
fetchHours();
}, [session, status, router]);
const fetchHours = async () => {
const res = await fetch('/api/hours');
if (res.ok) {
const data = await res.json();
setHours(data);
}
};
const handleAddHour = async (e: React.FormEvent) => {
e.preventDefault();
const totalMinutes = parseInt(hoursInput) * 60 + parseInt(minutesInput);
const res = await fetch('/api/hours', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, duration: totalMinutes, reason }),
});
if (res.ok) {
setDate('');
setHoursInput('');
setMinutesInput('');
setReason('');
fetchHours();
}
};
const handleValidate = async (id: number, status: string) => {
await fetch(`/api/hours/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
fetchHours();
};
if (status === 'loading') return <div>Chargement...</div>;
const isAdmin =
session?.user?.role === 'ADMIN' || session?.user?.role === 'SUPER_ADMIN';
const isMember = session?.user?.role === 'MEMBER';
const totalPending = hours
.filter((h) => h.status === 'PENDING')
.reduce((sum, h) => sum + h.duration, 0);
const totalValidated = hours
.filter((h) => h.status === 'VALIDATED')
.reduce((sum, h) => sum + h.duration, 0);
const totalRejected = hours
.filter((h) => h.status === 'REJECTED')
.reduce((sum, h) => sum + h.duration, 0);
const formatHours = (minutes: number) => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${m}min`;
};
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Tableau de bord</h1>
{isMember && (
<Card className="mb-4">
<CardHeader>
<CardTitle>Ajouter une heure</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddHour} className="space-y-4">
<div>
<Label htmlFor="date">Date</Label>
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
<div className="flex space-x-2">
<div>
<Label htmlFor="hours">Heures</Label>
<Input
id="hours"
type="number"
value={hoursInput}
onChange={(e) => setHoursInput(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="minutes">Minutes</Label>
<Input
id="minutes"
type="number"
value={minutesInput}
onChange={(e) => setMinutesInput(e.target.value)}
required
/>
</div>
</div>
<div>
<Label htmlFor="reason">Raison</Label>
<Input
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
/>
</div>
<Button type="submit">Ajouter</Button>
</form>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Liste des heures</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Durée</TableHead>
<TableHead>Raison</TableHead>
<TableHead>Statut</TableHead>
{isAdmin && <TableHead>Utilisateur</TableHead>}
{isAdmin && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{hours.map((hour) => (
<TableRow key={hour.id}>
<TableCell>
{new Date(hour.date).toLocaleDateString()}
</TableCell>
<TableCell>{hour.duration} min</TableCell>
<TableCell>{hour.reason}</TableCell>
<TableCell>{hour.status}</TableCell>
{isAdmin && <TableCell>{hour.user.email}</TableCell>}
{isAdmin && (
<TableCell>
<Button
onClick={() => handleValidate(hour.id, 'VALIDATED')}
className="mr-2"
>
Valider
</Button>
<Button
onClick={() => handleValidate(hour.id, 'REJECTED')}
variant="destructive"
>
Rejeter
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="mt-4">
<h2 className="text-xl font-bold">Totaux</h2>
<div className="flex space-x-4">
<div>
<h3 className="font-semibold">En attente</h3>
<p>{formatHours(totalPending)}</p>
</div>
<div>
<h3 className="font-semibold">Validées</h3>
<p>{formatHours(totalValidated)}</p>
</div>
<div>
<h3 className="font-semibold">Rejetées</h3>
<p>{formatHours(totalRejected)}</p>
</div>
</div>
</div>
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

122
app/globals.css Normal file
View File

@@ -0,0 +1,122 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

41
app/layout.tsx Normal file
View File

@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import { Providers } from '@/components/providers';
import Header from '@/components/Header';
import { Toaster } from '@/components/ui/sonner';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: "Plateforme de Comptage d'Heures",
description: 'Gérer les heures des clubs scolaires',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning={true}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>
<Header />
{children}
<Toaster />
</Providers>
</body>
</html>
);
}

82
app/login/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
'use client';
import { useState, useEffect } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [settings, setSettings] = useState({ name: '', logo: '' });
const router = useRouter();
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
setSettings(data);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Email ou mot de passe incorrect');
} else {
// Rediriger selon le rôle, mais pour l'instant, vers /dashboard
router.push('/dashboard');
}
};
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Connexion</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-red-500">{error}</p>}
<Button type="submit" className="w-full">
Se connecter
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

22
app/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
'use client';
import Image from 'next/image';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function Home() {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'loading') return;
if (!session) {
router.push('/login');
} else {
router.push('/dashboard');
}
}, [session, status, router]);
return <div>Redirection en cours...</div>;
}

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

54
components/Header.tsx Normal file
View File

@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
interface Settings {
name: string;
logo: string;
}
export default function Header() {
const { data: session } = useSession();
const [settings, setSettings] = useState<Settings>({ name: '', logo: '' });
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
setSettings(data);
}
};
return (
<header className="bg-white dark:bg-stone-950 p-4">
<div className="container mx-auto flex items-center justify-between">
<div className="flex items-center">
{settings.logo && (
<img src={settings.logo} alt="Logo" className="h-10 mr-4" />
)}
<h1 className="hidden md:block text-xl font-bold text-gray-900 dark:text-white">
{settings.name || 'Club Scolaire'}
</h1>
</div>
{session && (
<div className="flex items-center space-x-4">
<span className="text-gray-900 dark:text-white">
{session.user.email} ({session.user.role})
</span>
<button
onClick={() => signOut()}
className="bg-red-500 text-white px-3 py-1 rounded"
>
Déconnexion
</button>
</div>
)}
</div>
</header>
);
}

14
components/providers.tsx Normal file
View File

@@ -0,0 +1,14 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</SessionProvider>
);
}

60
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

213
components/ui/calendar.tsx Normal file
View File

@@ -0,0 +1,213 @@
'use client';
import * as React from 'react';
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react';
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months,
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,43 @@
'use client';
import * as React from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
interface DatePickerProps {
date: Date | undefined;
setDate: (date: Date | undefined) => void;
className?: string;
}
export function DatePicker({ date, setDate, className }: DatePickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
data-empty={!date}
className={cn(
'data-[empty=true]:text-muted-foreground w-[280px] justify-start text-left font-normal',
className,
)}
>
<CalendarIcon />
{date ? format(date, 'PPP') : <span>Choisir une date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={date} onSelect={setDate} />
</PopoverContent>
</Popover>
);
}

143
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,143 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

168
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,168 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 mb-2',
className,
)}
{...props}
/>
);
}
export { Label };

48
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

40
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,40 @@
'use client';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

116
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
version: '3.8'
services:
app:
build: .
ports:
- '3000:3000'
volumes:
- ./data:/app/prisma/data # Pour la DB SQLite

60
lib/auth.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prisma } from './prisma';
import bcrypt from 'bcryptjs';
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user) {
return null;
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password,
);
if (!isPasswordValid) {
return null;
}
return {
id: user.id.toString(),
email: user.email,
role: user.role,
};
},
}),
],
session: {
strategy: 'jwt',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.sub!;
session.user.role = token.role as string;
}
return session;
},
},
pages: {
signIn: '/login',
},
};

9
lib/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"format": "prettier --write ."
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "6.17.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"csv-writer": "^1.6.0",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
"lucide-react": "^0.546.0",
"next": "15.5.6",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"prisma": "^6.17.1",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
"react-hook-form": "^7.65.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"prettier": "^3.6.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
const config = {
plugins: ['@tailwindcss/postcss'],
};
export default config;

50
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,50 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id
email String @unique
password String
role Role @default(MEMBER)
hours Hour[]
firstName String?
lastName String?
}
model Hour {
id String @id
date DateTime
duration Int // en minutes
reason String
status Status @default(PENDING)
validatedBy String? // ID de l'admin
user User @relation(fields: [userId], references: [id])
userId String
}
model ClubSettings {
id String @id @default("settings")
name String
logo String // Chemin vers l'image dans /public
}
enum Role {
MEMBER
ADMIN
SUPER_ADMIN // Rôle spécial pour le premier compte
}
enum Status {
PENDING
VALIDATED
REJECTED
}

View File

@@ -0,0 +1,27 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function main() {
const hashedPassword = await bcrypt.hash('test', 10); // Changez le mot de passe ici
await prisma.user.upsert({
where: { email: 'test@test.fr' },
update: {},
create: {
email: 'test@test.fr',
password: hashedPassword,
role: 'SUPER_ADMIN',
},
});
console.log('Super admin created');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

21
types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
import NextAuth from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
email: string;
role: string;
};
}
interface User {
role: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
role: string;
}
}