mirror of
https://github.com/BreizhHardware/Site-comptage-heure.git
synced 2026-01-18 16:17:28 +01:00
feat: initialize project structure with Next.js, Prisma, and Tailwind CSS setup
This commit is contained in:
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal 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
8
.idea/.gitignore
generated
vendored
Normal 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
14
.idea/discord.xml
generated
Normal 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
13
.idea/material_theme_project_new.xml
generated
Normal 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
5
.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2
|
||||
}
|
||||
42
API_DOCS.md
Normal file
42
API_DOCS.md
Normal 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
10
Dockerfile
Normal 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
37
INITIAL_SETUP.md
Normal 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
148
README.md
Normal 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
514
app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
55
app/api/auth/signup/route.ts
Normal file
55
app/api/auth/signup/route.ts
Normal 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
100
app/api/export/route.ts
Normal 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;
|
||||
}
|
||||
67
app/api/hours/[id]/route.ts
Normal file
67
app/api/hours/[id]/route.ts
Normal 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
84
app/api/hours/route.ts
Normal 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
29
app/api/settings/route.ts
Normal 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
35
app/api/upload/route.ts
Normal 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}` });
|
||||
}
|
||||
41
app/api/users/[id]/route.ts
Normal file
41
app/api/users/[id]/route.ts
Normal 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
232
app/dashboard/page.tsx
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
app/globals.css
Normal file
122
app/globals.css
Normal 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
41
app/layout.tsx
Normal 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
82
app/login/page.tsx
Normal 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
22
app/page.tsx
Normal 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
22
components.json
Normal 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
54
components/Header.tsx
Normal 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
14
components/providers.tsx
Normal 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
60
components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all 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
213
components/ui/calendar.tsx
Normal 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
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
43
components/ui/date-picker.tsx
Normal file
43
components/ui/date-picker.tsx
Normal 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
143
components/ui/dialog.tsx
Normal 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
168
components/ui/form.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? '') : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal 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
24
components/ui/label.tsx
Normal 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
48
components/ui/popover.tsx
Normal 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
40
components/ui/sonner.tsx
Normal 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
116
components/ui/table.tsx
Normal 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
9
docker-compose.yml
Normal 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
60
lib/auth.ts
Normal 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
9
lib/prisma.ts
Normal 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
6
lib/utils.ts
Normal 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
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
48
package.json
Normal file
48
package.json
Normal 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
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
50
prisma/schema.prisma
Normal file
50
prisma/schema.prisma
Normal 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
|
||||
}
|
||||
27
scripts/create-super-admin.js
Normal file
27
scripts/create-super-admin.js
Normal 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
27
tsconfig.json
Normal 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
21
types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user