commit 02643e3702700a8974795698d6fecb677255b45f Author: Félix MARQUET Date: Fri Oct 17 21:59:13 2025 +0200 feat: initialize project structure with Next.js, Prisma, and Tailwind CSS setup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48bbd3f --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..912db82 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..d654398 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1d296fb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2 +} diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..142ba53 --- /dev/null +++ b/API_DOCS.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52872f2 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/INITIAL_SETUP.md b/INITIAL_SETUP.md new file mode 100644 index 0000000..3ce808d --- /dev/null +++ b/INITIAL_SETUP.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d75c95 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..15f7de3 --- /dev/null +++ b/app/admin/page.tsx @@ -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([]); + const [settings, setSettings] = useState({ name: '', logo: '' }); + const [newEmail, setNewEmail] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newRole, setNewRole] = useState('MEMBER'); + const [logoFile, setLogoFile] = useState(null); + const [date, setDate] = useState(); + 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, + ); + + 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, + ); + + const handleDeleteUser = async () => { + if (!selectedUser) return; + await fetch(`/api/users/${selectedUser.id}`, { method: 'DELETE' }); + setDialogOpen(false); + setSelectedUser(null); + fetchHours(); + }; + + if (status === 'loading') return
Chargement...
; + + 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 ( +
+

Administration

+ + + Ajouter des heures + + +
+
+ + +
+
+
+ + setHoursInput(e.target.value)} + required + /> +
+
+ + setMinutesInput(e.target.value)} + required + /> +
+
+
+ + setReason(e.target.value)} + required + /> +
+ +
+
+
+ + + Gestion des heures + + + + + + Date + Durée + Raison + Statut + Utilisateur + Actions + + + + {displayedHours.map((hour) => ( + + + {new Date(hour.date).toLocaleDateString()} + + {hour.duration} min + {hour.reason} + {hour.status} + {userDisplayNames[hour.user.email]} + + {hour.status === 'VALIDATED' || + hour.status === 'REJECTED' ? ( + + ) : ( + <> + + + + )} + + + ))} + +
+ {hours.length > 10 && !showAll && ( +
+ +
+ )} +
+

Totaux par utilisateur

+ + + + Utilisateur + Heures Validées + Actions + + + + {Object.entries(userTotals).map(([userId, total]) => ( + + {userMap[userId]?.name} + {formatHours(total)} + + {userMap[userId]?.role === 'SUPER_ADMIN' ? ( + 'Super Admin' + ) : ( + + )} + + + ))} + +
+
+
+
+ {isSuperAdmin && ( + + + Créer un compte + + +
+
+ + setNewEmail(e.target.value)} + required + /> +
+
+ + setNewPassword(e.target.value)} + required + /> +
+
+ + setNewFirstName(e.target.value)} + required + /> +
+
+ + setNewLastName(e.target.value)} + required + /> +
+
+ + +
+ +
+
+
+ )} + + + Paramètres du Club + + +
+
+ + + setSettings({ ...settings, name: e.target.value }) + } + /> +
+
+ + setLogoFile(e.target.files?.[0] || null)} + /> + {settings.logo &&

Actuel : {settings.logo}

} +
+ +
+
+
+ {selectedUser && ( + + + + Confirmation + + + Êtes-vous sûr de vouloir supprimer cet utilisateur ? + + + + + + + + )} +
+ + +
+
+ ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0a4c217 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 0000000..ef6c949 --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -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 }, + }); +} diff --git a/app/api/export/route.ts b/app/api/export/route.ts new file mode 100644 index 0000000..1f5ad2e --- /dev/null +++ b/app/api/export/route.ts @@ -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; +} diff --git a/app/api/hours/[id]/route.ts b/app/api/hours/[id]/route.ts new file mode 100644 index 0000000..18b1d5c --- /dev/null +++ b/app/api/hours/[id]/route.ts @@ -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' }); +} diff --git a/app/api/hours/route.ts b/app/api/hours/route.ts new file mode 100644 index 0000000..fbc0788 --- /dev/null +++ b/app/api/hours/route.ts @@ -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); +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..af227d9 --- /dev/null +++ b/app/api/settings/route.ts @@ -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); +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..23f660a --- /dev/null +++ b/app/api/upload/route.ts @@ -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}` }); +} diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 0000000..21c1572 --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -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é' }); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..2eb4d5e --- /dev/null +++ b/app/dashboard/page.tsx @@ -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([]); + 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
Chargement...
; + + 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 ( +
+

Tableau de bord

+ {isMember && ( + + + Ajouter une heure + + +
+
+ + setDate(e.target.value)} + required + /> +
+
+
+ + setHoursInput(e.target.value)} + required + /> +
+
+ + setMinutesInput(e.target.value)} + required + /> +
+
+
+ + setReason(e.target.value)} + required + /> +
+ +
+
+
+ )} + + + Liste des heures + + + + + + Date + Durée + Raison + Statut + {isAdmin && Utilisateur} + {isAdmin && Actions} + + + + {hours.map((hour) => ( + + + {new Date(hour.date).toLocaleDateString()} + + {hour.duration} min + {hour.reason} + {hour.status} + {isAdmin && {hour.user.email}} + {isAdmin && ( + + + + + )} + + ))} + +
+
+
+
+

Totaux

+
+
+

En attente

+

{formatHours(totalPending)}

+
+
+

Validées

+

{formatHours(totalValidated)}

+
+
+

Rejetées

+

{formatHours(totalRejected)}

+
+
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c539548 --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9a6c150 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + +
+ {children} + + + + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..00ded96 --- /dev/null +++ b/app/login/page.tsx @@ -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 ( +
+ + + Connexion + + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..5509658 --- /dev/null +++ b/app/page.tsx @@ -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
Redirection en cours...
; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..da68b19 --- /dev/null +++ b/components.json @@ -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": {} +} diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..d6442be --- /dev/null +++ b/components/Header.tsx @@ -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({ name: '', logo: '' }); + + useEffect(() => { + fetchSettings(); + }, []); + + const fetchSettings = async () => { + const res = await fetch('/api/settings'); + if (res.ok) { + const data = await res.json(); + setSettings(data); + } + }; + + return ( +
+
+
+ {settings.logo && ( + Logo + )} +

+ {settings.name || 'Club Scolaire'} +

+
+ {session && ( +
+ + {session.user.email} ({session.user.role}) + + +
+ )} +
+
+ ); +} diff --git a/components/providers.tsx b/components/providers.tsx new file mode 100644 index 0000000..2cf968a --- /dev/null +++ b/components/providers.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..3d4a39f --- /dev/null +++ b/components/ui/button.tsx @@ -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 & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : 'button'; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 0000000..2b52ecf --- /dev/null +++ b/components/ui/calendar.tsx @@ -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 & { + buttonVariant?: React.ComponentProps['variant']; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + 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 ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return ( + + ); + } + + if (orientation === 'right') { + return ( + + ); + } + + return ( + + ); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + + + + + + + ); +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..f6c4e9f --- /dev/null +++ b/components/ui/dialog.tsx @@ -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) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..657c64a --- /dev/null +++ b/components/ui/form.tsx @@ -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 = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +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 '); + } + + 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( + {} as FormItemContextValue, +); + +function FormItem({ className, ...props }: React.ComponentProps<'div'>) { + const id = React.useId(); + + return ( + +
+ + ); +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField(); + + return ( +