mirror of
https://github.com/BreizhHardware/Site-comptage-heure.git
synced 2026-03-18 21:30:40 +01:00
feat: initialize project structure with Next.js, Prisma, and Tailwind CSS setup
This commit is contained in:
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user