Premier commit - front

This commit is contained in:
BuzzLeclair 2025-03-16 08:52:19 +01:00
commit b01049c1c6
63 changed files with 10000 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# 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

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

21
eslint.config.mjs Normal file
View File

@ -0,0 +1,21 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"react/no-unescaped-entities": "off" // désactivation de la règle pour les appostrophe..
}
}
];
export default eslintConfig;

14
next.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:5080/api/:path*",
},
];
},
};
export default nextConfig;

5285
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "ldap-cesi",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"framer-motion": "^12.4.10",
"jose": "^6.0.8",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.479.0",
"next": "15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

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

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
export default function AdminDashboard() {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">Dashboard administrateur</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link href="/admin/sites" className="block">
<div className="border border-border p-6 rounded-lg bg-card hover:shadow-md transition-shadow">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 mb-4 text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z" />
</svg>
<h2 className="text-xl font-medium mb-2">Gérer les sites</h2>
<p className="text-muted-foreground">Dans cet espace vous allez pouvoir ajouter, supprimer et modifier des sites.</p>
</div>
</Link>
<Link href="/admin/services" className="block">
<div className="border border-border p-6 rounded-lg bg-card hover:shadow-md transition-shadow">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 mb-4 text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
</svg>
<h2 className="text-xl font-medium mb-2">Gérer les services</h2>
<p className="text-muted-foreground">Dans cet espace vous allez pouvoir ajouter, supprimer et modifier des services</p>
</div>
</Link>
<Link href="/admin/salaries" className="block">
<div className="border border-border p-6 rounded-lg bg-card hover:shadow-md transition-shadow">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 mb-4 text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<h2 className="text-xl font-medium mb-2">Gérer les salariés</h2>
<p className="text-muted-foreground">Dans cet espace vous allez pouvoir ajouter, supprimer et modifier des salariés</p>
</div>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/AuthProvider';
export default function AdminLogin() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { login } = useAuth();
// suppression du cookie d'accès quand la page estchargée
useEffect(() => {
fetch('/api/admin/clear-access-cookie', { method: 'POST' })
.catch(err => console.error('Erreur lors de la suppression du cookie d\'accès:', err));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
motDePasse: password
}),
});
const data = await response.json();
console.log('Réponse de login:', data);
if (response.ok && data.success) {
login(data.data);
router.push('/admin/dashboard');
} else {
setError(data.message || 'Identifiants incorrects');
}
} catch (error) {
console.error('Erreur de connexion:', error);
setError('Erreur de connexion au serveur');
} finally {
setIsLoading(false);
}
};
return (
<div className="container mx-auto flex items-center justify-center min-h-[calc(100vh-200px)]">
<div className="w-full max-w-md p-8 bg-card rounded-lg shadow-md border border-border">
<h1 className="text-2xl font-bold mb-6 text-center">Administration</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
<div className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 mr-2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span>{error}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md"
required
/>
</div>
<div className="mb-6">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Mot de passe
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-md transition-colors"
>
{isLoading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,380 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { useState, useEffect } from 'react';
import ProtectedRoute from '@/components/ProtectedRoute';
import { PlusCircle, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
import SalarieModal from '@/components/admin/SalarieModal';
import DeleteConfirmation from '@/components/admin/DeleteConfirmation';
import { SearchBar } from '@/components/SearchBar';
import {
fetchSalaries,
fetchSites,
fetchServices,
fetchSalariesBySite,
fetchSalariesByService
} from '@/utils/api';
import { Salarie } from '@/types/Salarie';
import { Site } from '@/types/Site';
import { Service } from '@/types/Service';
import { PaginatedResponse } from '@/types/PaginatedResponse';
export default function SalariesManagement() {
const [salaries, setSalaries] = useState<Salarie[]>([]);
const [sites, setSites] = useState<Site[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [selectedSite, setSelectedSite] = useState<number | null>(null);
const [selectedService, setSelectedService] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [currentSalarie, setCurrentSalarie] = useState<Salarie | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadData();
}, [currentPage, pageSize, searchTerm, selectedSite, selectedService]);
const loadData = async () => {
setIsLoading(true);
setError(null);
try {
// chargement des sites et des services qui se fait seulement au premier chargement
if (sites.length === 0) {
const sitesResponse = await fetchSites(1, 100);
setSites(sitesResponse?.data || []);
}
if (services.length === 0) {
const servicesResponse = await fetchServices(1, 100);
setServices(servicesResponse?.data || []);
}
let salariesResponse: PaginatedResponse<Salarie>;
if (selectedSite !== null) {
salariesResponse = await fetchSalariesBySite(selectedSite, currentPage, pageSize);
} else if (selectedService !== null) {
salariesResponse = await fetchSalariesByService(selectedService, currentPage, pageSize);
} else if (searchTerm) {
salariesResponse = await fetchSalaries(currentPage, pageSize, searchTerm);
} else {
salariesResponse = await fetchSalaries(currentPage, pageSize);
}
setSalaries(salariesResponse?.data || []);
setTotalItems(salariesResponse?.totalCount || 0);
setTotalPages(salariesResponse?.totalPages || 0);
} catch (error) {
console.error('Erreur lors du chargement des données:', error);
setError('Impossible de charger les données. Veuillez réessayer.');
setSalaries([]);
if (sites.length === 0) setSites([]);
if (services.length === 0) setServices([]);
} finally {
setIsLoading(false);
}
};
const handleOpenModal = (salarie: Salarie | null = null) => {
setCurrentSalarie(salarie);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setCurrentSalarie(null);
};
const handleOpenDeleteModal = (salarie: Salarie) => {
setCurrentSalarie(salarie);
setIsDeleteModalOpen(true);
};
const handleCloseDeleteModal = () => {
setIsDeleteModalOpen(false);
setCurrentSalarie(null);
};
const handleSaveSalarie = async (salarie: Omit<Salarie, 'id'>) => {
try {
let response;
if (currentSalarie) {
response = await fetch('/api/salaries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...salarie,
id: currentSalarie.id
}),
});
} else {
response = await fetch('/api/salaries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(salarie),
});
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Erreur lors de l'opération");
}
handleCloseModal();
loadData();
} catch (error) {
console.error(`Erreur lors de la création d'un salarié`, error);
setError(`Erreur lors de la création d'un salarié`);
}
};
const handleDeleteSalarie = async () => {
if (!currentSalarie) return;
try {
// le routeur ne fonctionne pas correctement pour delete.. donc je gruge comme je peux
const response = await fetch('/api/salaries/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: currentSalarie.id })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Erreur lors de la suppression");
}
handleCloseDeleteModal();
loadData();
} catch (error) {
console.error('Une erreur est survenue lors de la suppression du salarié :', error);
setError('Une erreur est survenue lors de la suppression du salarié');
}
};
// gestion de la pagination
const handlePageChange = (newPage: number) => {
if (newPage > 0 && newPage <= totalPages) {
setCurrentPage(newPage);
}
};
const handleSearch = (query: string) => {
setSearchTerm(query);
setCurrentPage(1);
};
const handleResetFilters = () => {
setSearchTerm('');
setSelectedSite(null);
setSelectedService(null);
setCurrentPage(1);
};
const handleSiteChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value ? parseInt(e.target.value) : null;
setSelectedSite(value);
setSelectedService(null);
setCurrentPage(1);
};
const handleServiceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value ? parseInt(e.target.value) : null;
setSelectedService(value);
setSelectedSite(null);
setCurrentPage(1);
};
return (
<ProtectedRoute>
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestion des Salariés</h1>
<button
onClick={() => handleOpenModal()}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors"
>
<PlusCircle size={18} />
<span>Ajouter un salarié</span>
</button>
</div>
{/* filtre et recherche */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<SearchBar
onSearch={handleSearch}
placeholder="Rechercher un salarié..."
initialValue={searchTerm}
/>
<select
value={selectedSite?.toString() || ''}
onChange={handleSiteChange}
className="px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">Tous les sites</option>
{sites && sites.map((site) => (
<option key={site.id} value={site.id}>{site.ville}</option>
))}
</select>
<select
value={selectedService?.toString() || ''}
onChange={handleServiceChange}
className="px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">Tous les services</option>
{services && services.map((service) => (
<option key={service.id} value={service.id}>{service.nom}</option>
))}
</select>
</div>
{/* boouton de réinitialisation des filtres */}
{(searchTerm || selectedSite || selectedService) && (
<div className="mb-4">
<button
onClick={handleResetFilters}
className="text-primary hover:text-primary-dark text-sm font-medium flex items-center"
>
Réinitialiser les filtres
</button>
</div>
)}
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{isLoading ? (
<div className="flex justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
) : (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Nom</th>
<th className="px-4 py-3 text-left text-sm font-medium">Prénom</th>
<th className="px-4 py-3 text-left text-sm font-medium">Service</th>
<th className="px-4 py-3 text-left text-sm font-medium">Site</th>
<th className="px-4 py-3 text-left text-sm font-medium">Email</th>
<th className="px-4 py-3 text-right text-sm font-medium w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{!salaries || salaries.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-4 text-center text-muted-foreground">
Aucun salarié trouvé
</td>
</tr>
) : (
salaries.map((salarie) => {
return (
<tr key={salarie.id} className="hover:bg-muted/50">
<td className="px-4 py-4">{salarie.nom}</td>
<td className="px-4 py-4">{salarie.prenom}</td>
<td className="px-4 py-4">{salarie.service?.nom || '-'}</td>
<td className="px-4 py-4">{salarie.site?.ville || '-'}</td>
<td className="px-4 py-4">{salarie.email}</td>
<td className="px-4 py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleOpenModal(salarie)}
className="text-blue-600 hover:text-blue-800"
aria-label="Modifier"
>
<Edit size={18} />
</button>
<button
onClick={() => handleOpenDeleteModal(salarie)}
className="text-red-600 hover:text-red-800"
aria-label="Supprimer"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center px-4 py-3 border-t border-border bg-muted">
<div className="text-sm text-muted-foreground">
Affichage de <span className="font-medium">{((currentPage - 1) * pageSize) + 1}</span> à{' '}
<span className="font-medium">{Math.min(currentPage * pageSize, totalItems)}</span> sur{' '}
<span className="font-medium">{totalItems}</span> résultats
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="p-2 rounded-md hover:bg-background disabled:opacity-50 disabled:pointer-events-none"
>
<ChevronLeft size={18} />
</button>
<span className="text-sm font-medium">
Page {currentPage} sur {totalPages}
</span>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-2 rounded-md hover:bg-background disabled:opacity-50 disabled:pointer-events-none"
>
<ChevronRight size={18} />
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* modal d'ajout/modification */}
{isModalOpen && sites && services && (
<SalarieModal
salarie={currentSalarie}
services={services}
sites={sites}
onClose={handleCloseModal}
onSave={handleSaveSalarie}
/>
)}
{/* modal pour confirmer la suppression */}
{isDeleteModalOpen && currentSalarie && (
<DeleteConfirmation
title="Suppression d'un salarié"
message={`Voulez vous vraiment supprimer ce salarié : ${currentSalarie.prenom} ${currentSalarie.nom} ?`}
onConfirm={handleDeleteSalarie}
onCancel={handleCloseDeleteModal}
/>
)}
</ProtectedRoute>
);
}

View File

@ -0,0 +1,203 @@
'use client';
import { useState, useEffect } from 'react';
import ProtectedRoute from '@/components/ProtectedRoute';
import { PlusCircle, Edit, Trash2 } from 'lucide-react';
import ServiceModal from '@/components/admin/ServiceModal';
import DeleteConfirmation from '@/components/admin/DeleteConfirmation';
interface Service {
id: number;
nom: string;
}
export default function ServicesManagement() {
const [services, setServices] = useState<Service[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [currentService, setCurrentService] = useState<Service | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchServices();
}, []);
const fetchServices = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/services');
if (!response.ok) throw new Error('Erreur lors du chargement des services');
const data = await response.json();
setServices(data.data);
} catch (error) {
setError('Impossible de charger les services. Veuillez réessayer.');
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleOpenModal = (service: Service | null = null) => {
setCurrentService(service);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setCurrentService(null);
};
const handleOpenDeleteModal = (service: Service) => {
setCurrentService(service);
setIsDeleteModalOpen(true);
};
const handleCloseDeleteModal = () => {
setIsDeleteModalOpen(false);
setCurrentService(null);
};
const handleSaveService = async (service: { nom: string }) => {
try {
if (currentService) {
const response = await fetch(`/api/services/`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({id: currentService.id,nom: service.nom}),
});
if (!response.ok) throw new Error("Erreur lors de la mise à jour");
} else {
const response = await fetch('/api/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(service),
});
if (!response.ok) throw new Error("Erreur lors de la création");
}
handleCloseModal();
fetchServices();
} catch (error) {
console.error(`Une erreur est survenue lors de l'enristrement des informations`, error);
setError(`Une erreur est survenue lors de l'enregistrement du service`);
}
};
const handleDeleteService = async () => {
if (!currentService) return;
try {
const response = await fetch('/api/services/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: currentService.id })
});
if (!response.ok) throw new Error("Erreur lors de la suppression");
handleCloseDeleteModal();
fetchServices();
} catch (error) {
console.log(error);
console.error('Erreur lors de la suppression :', error);
setError('Erreur lors de la suppression :');
}
};
return (
<ProtectedRoute>
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestion des Services</h1>
<button
onClick={() => handleOpenModal()}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors"
>
<PlusCircle size={18} />
<span>Ajouter un service</span>
</button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{isLoading ? (
<div className="flex justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
) : (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-sm font-medium">Service</th>
<th className="px-6 py-3 text-right text-sm font-medium w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{services.length === 0 ? (
<tr>
<td colSpan={2} className="px-6 py-4 text-center text-muted-foreground">
Aucun service trouvé
</td>
</tr>
) : (
services.map((service) => (
<tr key={service.id} className="hover:bg-muted/50">
<td className="px-6 py-4">{service.nom}</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleOpenModal(service)}
className="text-blue-600 hover:text-blue-800"
aria-label="Modifier"
>
<Edit size={18} />
</button>
<button
onClick={() => handleOpenDeleteModal(service)}
className="text-red-600 hover:text-red-800"
aria-label="Supprimer"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{/* modal d'ajout/modification */}
{isModalOpen && (
<ServiceModal
service={currentService}
onClose={handleCloseModal}
onSave={handleSaveService}
/>
)}
{/* modal de confirmation de suppression */}
{isDeleteModalOpen && currentService && (
<DeleteConfirmation
title="Supprimer le service"
message={`Êtes-vous sûr de vouloir supprimer le service "${currentService.nom}" ?`}
onConfirm={handleDeleteService}
onCancel={handleCloseDeleteModal}
/>
)}
</ProtectedRoute>
);
}

View File

@ -0,0 +1,223 @@
'use client';
import { useState, useEffect } from 'react';
import ProtectedRoute from '@/components/ProtectedRoute';
import { PlusCircle, Edit, Trash2 } from 'lucide-react';
import SiteModal from '@/components/admin/SiteModal';
import DeleteConfirmation from '@/components/admin/DeleteConfirmation';
interface Site {
id: number;
ville: string;
salaries?: unknown[];
}
interface PaginatedResponse {
data: Site[];
pageNumber: number;
pageSize: number;
totalPages: number;
totalCount: number;
hasPreviousPage: boolean;
hasNextPage: boolean;
}
export default function SitesManagement() {
const [sitesData, setSitesData] = useState<PaginatedResponse>({
data: [],
pageNumber: 1,
pageSize: 25,
totalPages: 0,
totalCount: 0,
hasPreviousPage: false,
hasNextPage: false
});
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [currentSite, setCurrentSite] = useState<Site | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSites();
}, []);
const fetchSites = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/sites');
if (!response.ok) throw new Error('Erreur lors du chargement des sites');
const data: PaginatedResponse = await response.json();
setSitesData(data);
} catch (error) {
setError('Impossible de charger les sites. Veuillez réessayer.');
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleOpenModal = (site: Site | null = null) => {
setCurrentSite(site);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setCurrentSite(null);
};
const handleOpenDeleteModal = (site: Site) => {
setCurrentSite(site);
setIsDeleteModalOpen(true);
};
const handleCloseDeleteModal = () => {
setIsDeleteModalOpen(false);
setCurrentSite(null);
};
const handleSaveSite = async (site: { ville: string }) => {
try {
if (currentSite) {
const response = await fetch('/api/sites', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: currentSite.id,
ville: site.ville
}),
});
if (!response.ok) throw new Error("Erreur lors de la mise à jour");
} else {
const response = await fetch('/api/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(site),
});
if (!response.ok) throw new Error("Erreur lors de la création");
}
handleCloseModal();
fetchSites();
} catch (error) {
console.error('Erreur lors de l\'enregistrement :', error);
setError('Erreur lors de l\'enregistrement du site');
}
};
const handleDeleteSite = async () => {
if (!currentSite) return;
try {
const response = await fetch('/api/sites/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: currentSite.id })
});
if (!response.ok) throw new Error("Erreur lors de la suppression");
handleCloseDeleteModal();
fetchSites();
} catch (error) {
console.error('Erreur lors de la suppression :', error);
setError('Erreur lors de la suppression du site');
}
};
return (
<ProtectedRoute>
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestion des Sites</h1>
<button
onClick={() => handleOpenModal()}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors"
>
<PlusCircle size={18} />
<span>Ajouter un site</span>
</button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{isLoading ? (
<div className="flex justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
) : (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-sm font-medium">Site</th>
<th className="px-6 py-3 text-right text-sm font-medium w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sitesData.data.length === 0 ? (
<tr>
<td colSpan={2} className="px-6 py-4 text-center text-muted-foreground">
Aucun site trouvé
</td>
</tr>
) : (
sitesData.data.map((site) => (
<tr key={site.id} className="hover:bg-muted/50">
<td className="px-6 py-4">{site.ville}</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleOpenModal(site)}
className="text-blue-600 hover:text-blue-800"
aria-label="Modifier"
>
<Edit size={18} />
</button>
<button
onClick={() => handleOpenDeleteModal(site)}
className="text-red-600 hover:text-red-800"
aria-label="Supprimer"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{/* modal d'ajout/modification */}
{isModalOpen && (
<SiteModal
site={currentSite}
onClose={handleCloseModal}
onSave={handleSaveSite}
/>
)}
{/* modal de confirmation de suppression */}
{isDeleteModalOpen && currentSite && (
<DeleteConfirmation
title="Supprimer le site"
message={`Êtes-vous sûr de vouloir supprimer le site de ${currentSite.ville} ?`}
onConfirm={handleDeleteSite}
onCancel={handleCloseDeleteModal}
/>
)}
</ProtectedRoute>
);
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
// suprime le cookie en définissant une date d'expiration passée
response.cookies.set({
name: 'admin_access',
value: '',
expires: new Date(0),
path: '/',
});
return response;
}

View File

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { email, motDePasse } = await request.json();
// validation des champs saisis
if (!email || !motDePasse) {
return NextResponse.json(
{ success: false, message: 'Email et mot de passe sont obligatoire' },
{ status: 400 }
);
}
const loginResponse = await fetch('http://localhost:5080/api/utilisateurs/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': '*/*'
},
body: JSON.stringify({ email, motDePasse }),
});
const loginData = await loginResponse.json();
if (!loginResponse.ok) {
return NextResponse.json(
{ success: false, message: loginData.message || 'Identifiants invalides' },
{ status: loginResponse.status }
);
}
// créetion d'une réponse avec le cookie d'authentification
const response = NextResponse.json(loginData);
// définition du cookie contenant le token JWT
response.cookies.set({
name: 'admin_token',
value: loginData.token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
// expiration du cookie 2 jours
maxAge: 2 * 24 * 60 * 60
});
return response;
} catch (error) {
console.error('Erreur lors de la connexion:', error);
return NextResponse.json(
{ success: false, message: 'Erreur de serveur' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (token) {
try {
await fetch('http://localhost:5080/api/utilisateurs/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
}
});
} catch (error) {
console.error(`Erreur lors de l'appel à l'api : `, error);
}
}
const response = NextResponse.json({ success: true });
// suppressiuon du cookie
response.cookies.set({
name: 'admin_token',
value: '',
expires: new Date(0),
path: '/',
});
return response;
} catch (error) {
console.error('Erreur lors de la déconnexion:', error);
const response = NextResponse.json({ success: true });
//supprimme le cookie
response.cookies.set({
name: 'admin_token',
value: '',
expires: new Date(0),
path: '/',
});
return response;
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
// route qui permet de vérifier la validité du token
const apiResponse = await fetch('http://localhost:5080/api/utilisateurs/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
}
});
if (!apiResponse.ok) {
// si le retour de la réponse n'est pas correct on supprimme le cookie
const nextResponse = NextResponse.json(
{ success: false, message: 'Session invalide' },
{ status: 401 }
);
// suprpession du cookie
nextResponse.cookies.set({
name: 'admin_token',
value: '',
expires: new Date(0),
path: '/',
});
return nextResponse;
}
const userData = await apiResponse.json();
// vérificationm que le role est dans la réponse de l'api
if (userData.data && !userData.data.roleNom) {
console.error(`le rôle ou les informations de l'uttilisateur ne sont pas présentes`);
}
return NextResponse.json({
success: true,
user: userData.data
});
} catch (error) {
console.error('Erreur de vérification de session:', error);
// erreur de conexion : suppression du cookie
const nextResponse = NextResponse.json(
{ success: false, message: 'Erreur de vérification de session' },
{ status: 500 }
);
nextResponse.cookies.set({
name: 'admin_token',
value: '',
expires: new Date(0),
path: '/',
});
return nextResponse;
}
}

View File

@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set({
name: 'admin_access',
value: 'true',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 5,
path: '/',
});
return response;
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
// récupération d'un salarié via le système du router intégré de next
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const id = await params;
const response = await fetch(`http://localhost:5080/api/salaries/${id}`);
if (!response.ok) {
return NextResponse.json(
{ success: false, message: 'Salarié non trouvé' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Erreur lors de la récupération du salarié :', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { id } = await request.json();
if (!id) {
return NextResponse.json(
{ success: false, message: 'ID du salarié requis' },
{ status: 400 }
);
}
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
const response = await fetch(`http://localhost:5080/api/salaries/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
}
});
if (!response.ok) {
const responseText = await response.text();
return NextResponse.json(
{ success: false, message: `Erreur ${response.status}: ${responseText}` },
{ status: response.status }
);
}
return NextResponse.json(
{ success: true, message: 'Salarié supprimé avec succès' }
);
} catch (error) {
console.error('Erreur lors de la suppression du salarié:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,136 @@
import { fetchServicesFromBackend } from '@/app/lib/backend-api';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const pageNumber = parseInt(searchParams.get('pageNumber') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '25');
const data = await fetchServicesFromBackend(pageNumber, pageSize);
return NextResponse.json(data);
} catch (error) {
console.error('Erreur lors de la récupération des services:', error);
return NextResponse.json(
{
data: [],
pageNumber: 1,
pageSize: 25,
totalPages: 0,
totalCount: 0,
hasPreviousPage: false,
hasNextPage: false
},
{ status: 500 }
);
}
}
// gestion de la création d'un salarié
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
const originalBody = await request.json();
// adapte le body pour s'adapter à l'API
const transformedBody = {
nom: originalBody.nom,
prenom: originalBody.prenom,
telephoneFixe: originalBody.telephoneFixe || "",
telephonePortable: originalBody.telephonePortable || "",
email: originalBody.email,
idSite: originalBody.siteId,
idService: originalBody.serviceId
};
const response = await fetch('http://localhost:5080/api/salaries', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
body: JSON.stringify(transformedBody)
});
if (!response.ok) {
const errorData = await response.json();
return NextResponse.json(
{ success: false, message: errorData.message || 'Erreur lors de la création' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error('Erreur lors de la création du salarié:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur' },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
const originalBody = await request.json();
const transformedBody = {
id: originalBody.id,
nom: originalBody.nom,
prenom: originalBody.prenom,
telephoneFixe: originalBody.telephoneFixe || "",
telephonePortable: originalBody.telephonePortable || "",
email: originalBody.email,
idSite: originalBody.siteId,
idService: originalBody.serviceId
};
console.log("Corps transformé pour la mise à jour:", JSON.stringify(transformedBody, null, 2));
const response = await fetch('http://localhost:5080/api/salaries', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
body: JSON.stringify(transformedBody)
});
if (!response.ok) {
const errorData = await response.json();
return NextResponse.json(
{ success: false, message: errorData.message || 'Erreur lors de la mise à jour' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Erreur lors de la mise à jour du salarié:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { id } = await request.json();
if (!id) {
return NextResponse.json(
{ success: false, message: 'ID du service requis' },
{ status: 400 }
);
}
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
const response = await fetch(`http://localhost:5080/api/services/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
}
});
console.log(`Statut de la réponse du backend: ${response.status}`);
if (!response.ok) {
try {
const errorData = await response.json();
// vérification que l'erreur concerne des salariés affectés à ce service
if (errorData.message && errorData.message.includes("il n'est pas possible de supprimer ce service car des salariés y sont liés")) {
return NextResponse.json(
{
success: false,
message: errorData.message,
isDependencyError: true
},
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, message: errorData.message || `Erreur ${response.status}` },
{ status: response.status }
);
} catch {
// sila réponse n'est pas au format JSON, tenter de récupérer le texte
const responseText = await response.text();
console.error(`Erreur du backend: ${responseText}`);
return NextResponse.json(
{ success: false, message: `Erreur ${response.status}: ${responseText}` },
{ status: response.status }
);
}
}
return NextResponse.json(
{ success: true, message: 'Service supprimé avec succès' }
);
} catch (error) {
console.error('Exception lors de la suppression:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchServicesFromBackend, updateServiceInBackend } from '@/app/lib/backend-api';
export async function GET(request: NextRequest) {
try {
// récupération dess paramètres de pagination de l'url
const { searchParams } = new URL(request.url);
const pageNumber = parseInt(searchParams.get('pageNumber') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '25');
const data = await fetchServicesFromBackend(pageNumber, pageSize);
return NextResponse.json(data);
} catch (error) {
console.error('Erreur lors de la récupération des services:', error);
return NextResponse.json(
{
data: [],
pageNumber: 1,
pageSize: 25,
totalPages: 0,
totalCount: 0,
hasPreviousPage: false,
hasNextPage: false
},
{ status: 500 }
);
}
}
// vréation d'un service
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
const originalBody = await request.json();
// vérification que le nom est présent
if (!originalBody.nom) {
return NextResponse.json(
{ success: false, message: 'Le nom du service est obligatoire' },
{ status: 400 }
);
}
// adapter le body pour correspondre aux attentes de l'api
const transformedBody = {
nom: originalBody.nom
};
const response = await fetch('http://localhost:5080/api/services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
body: JSON.stringify(transformedBody)
});
if (!response.ok) {
const errorData = await response.json();
return NextResponse.json(
{ success: false, message: errorData.message || 'Erreur lors de la création' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error('Erreur lors de la création du service:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}
// mise à jour d'un service
export async function PUT(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
const originalBody = await request.json();
// vérification des champs requis
if (!originalBody.id) {
return NextResponse.json(
{ success: false, message:`ID manquant` },
{ status: 400 }
);
}
if (!originalBody.nom) {
return NextResponse.json(
{ success: false, message: `Le nom est obligatoire` },
{ status: 400 }
);
}
const data = await updateServiceInBackend(token, originalBody.id, originalBody.nom);
return NextResponse.json({ success: true, data });
} catch (error) {
console.error('Erreur lors de la mise à jour du service : ', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
// récupération de l'id du corps de la requête
const { id } = await request.json();
if (!id) {
return NextResponse.json(
{ success: false, message: 'ID du site requis' },
{ status: 400 }
);
}
// récupération ddu token du cookie
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
console.log(`Envoi d'une requête DELETE au backend pour le site ${id}`);
const response = await fetch(`http://localhost:5080/api/sites/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
}
});
console.log(`Statut de la réponse du backend: ${response.status}`);
if (!response.ok) {
try {
const errorData = await response.json();
// vérification si l'erreur concerne des salariés affectés à ce site
if (errorData.message && errorData.message.includes("il n'est pas possible de supprimer ce site car des salariés y sont liés")) {
return NextResponse.json(
{
success: false,
message: errorData.message,
isDependencyError: true
},
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, message: errorData.message || `Erreur ${response.status}` },
{ status: response.status }
);
} catch {
const responseText = await response.text();
console.error(`Erreur du backend: ${responseText}`);
return NextResponse.json(
{ success: false, message: `Erreur ${response.status}: ${responseText}` },
{ status: response.status }
);
}
}
return NextResponse.json(
{ success: true, message: 'Site supprimé' }
);
} catch (error) {
console.error('erreur lors de la suppression:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}

106
src/app/api/sites/route.ts Normal file
View File

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchSitesFromBackend, createSiteInBackend, updateSiteInBackend } from '@/app/lib/backend-api';
// r'épurératuio nde tous les sites
export async function GET(request: NextRequest) {
try {
// récupère les informations de la pagination
const { searchParams } = new URL(request.url);
const pageNumber = parseInt(searchParams.get('pageNumber') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '25');
const data = await fetchSitesFromBackend(pageNumber, pageSize);
return NextResponse.json(data);
} catch (error) {
console.error('Erreur lors de la récupération des sites:', error);
return NextResponse.json(
{
data: [],
pageNumber: 1,
pageSize: 25,
totalPages: 0,
totalCount: 0,
hasPreviousPage: false,
hasNextPage: false
},
{ status: 500 }
);
}
}
// creatiopn d'un site
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: `Vous n'êtes pas connecté` },
{ status: 401 }
);
}
const originalBody = await request.json();
// vérific ation que la ville soit présente
if (!originalBody.ville) {
return NextResponse.json(
{ success: false, message: 'La ville du site est obligatoire' },
{ status: 400 }
);
}
const data = await createSiteInBackend(token, originalBody.ville);
return NextResponse.json({ success: true, data }, { status: 201 });
} catch (error) {
console.error('Erreur lors de la création du site:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}
// mise à jour d'un site
export async function PUT(request: NextRequest) {
try {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: 'Non authentifié' },
{ status: 401 }
);
}
const originalBody = await request.json();
// vérificatio nde l'id et de la ville
if (!originalBody.id) {
return NextResponse.json(
{ success: false, message: `L'id est obligatoire`},
{ status: 400 }
);
}
if (!originalBody.ville) {
return NextResponse.json(
{ success: false, message: `La ville du site est obligatoire`},
{ status: 400 }
);
}
const data = await updateSiteInBackend(token, originalBody.id, originalBody.ville);
return NextResponse.json({ success: true, data });
} catch (error) {
console.error('Erreur lors de la mise à jour du site:', error);
return NextResponse.json(
{ success: false, message: 'Erreur serveur', error: String(error) },
{ status: 500 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

96
src/app/globals.css Normal file
View File

@ -0,0 +1,96 @@
@import "tailwindcss";
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--secondary: #64748b;
--accent: #0ea5e9;
--background: #ffffff;
--foreground: #171717;
--card: #f9fafb;
--card-foreground: #1f2937;
--border: #e5e7eb;
--ring: #3b82f6;
--muted: #f3f4f6;
--muted-foreground: #6b7280;
}
@media (prefers-color-scheme: dark) {
:root {
--primary: #3b82f6;
--primary-dark: #60a5fa;
--secondary: #94a3b8;
--accent: #38bdf8;
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #e2e8f0;
--border: #334155;
--ring: #60a5fa;
--muted: #1e293b;
--muted-foreground: #94a3b8;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
transition: background-color 0.3s ease, color 0.3s ease;
@apply antialiased;
}
/* style personnalisés */
.input {
border: 1px solid var(--border);
padding: 0.5rem 0.75rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border-radius: 0.375rem;
}
.input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--ring);
}
.button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
outline: none;
transition: all 0.2s ease-in-out;
}
.button-primary {
background-color: var(--primary);
color: white;
}
.button-primary:hover {
background-color: var(--primary-dark);
}
.button-outline {
border: 1px solid var(--primary);
color: var(--primary);
}
.button-outline:hover {
background-color: var(--primary);
color: white;
}
.card {
background-color: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

View File

@ -0,0 +1,34 @@
// hooks/useKeyboardShortcut.ts
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/AuthProvider';
export function useAdminKeyboardShortcut() {
const router = useRouter();
const { user, isLoading } = useAuth();
useEffect(() => {
const handleKeyDown = async (event: KeyboardEvent) => {
// Ctrl+Alt+Shift pour accéder à l'admin
if (event.ctrlKey && event.altKey && event.shiftKey) {
if (user) {
router.push('/admin/dashboard');
} else {
await fetch('/api/admin/set-access-cookie', { method: 'POST' });
router.push('/admin/login');
}
}
};
if (!isLoading) {
window.addEventListener('keydown', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [router, user, isLoading]);
}

45
src/app/layout.tsx Normal file
View File

@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import KeyboardShortcutHandler from "@/components/KeyboardShortcutHandler";
import { AuthProvider } from '@/components/AuthProvider';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "LDAP - Cesi",
description: "Annuaire d'entreprise",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" className={`${geistSans.variable} ${geistMono.variable}`}>
<body>
<AuthProvider>
<KeyboardShortcutHandler />
<Header />
<main className="min-h-screen">
{children}
</main>
<Footer />
</AuthProvider>
</body>
</html>
);
}

214
src/app/lib/backend-api.ts Normal file
View File

@ -0,0 +1,214 @@
import { Service } from '@/types/Service';
import { Site } from '@/types/Site';
import { PaginatedResponse } from '@/types/PaginatedResponse';
import { ApiPaginatedResponse } from '@/types/ApiPaginatedResponse';
// fonction utilitaire pour transformer les réponses de l'API
function transformPaginatedResponse<T>(data: ApiPaginatedResponse<T>, pageNumber: number, pageSize: number): PaginatedResponse<T> {
return {
data: data.data || [],
pageNumber: data.pageNumber || pageNumber,
pageSize: data.pageSize || pageSize,
totalPages: data.totalPages || 1,
totalCount: data.totalCount || data.data?.length || 0,
hasPreviousPage: data.hasPreviousPage || pageNumber > 1,
hasNextPage: data.hasNextPage || false
};
}
/**
* récupère tous les services depuis le backend (sans authentification)
*/
export async function fetchServicesFromBackend(pageNumber: number, pageSize: number): Promise<PaginatedResponse<Service>> {
const headers: HeadersInit = {
'Accept': '*/*'
};
const response = await fetch(`http://localhost:5080/api/services?pageNumber=${pageNumber}&pageSize=${pageSize}`, {
headers
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur API services:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
const data = await response.json();
return transformPaginatedResponse<Service>(data, pageNumber, pageSize);
}
/**
* créé un nouveau service
*/
export async function createServiceInBackend(token: string, nom: string) {
const transformedBody = {
Nom: nom
};
const response = await fetch('http://localhost:5080/api/services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
body: JSON.stringify(transformedBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur création service:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
return await response.json();
}
/**
* met à jour un service existant
*/
export async function updateServiceInBackend(token: string, id: number, nom: string) {
const transformedBody = {
id: id,
nom: nom
};
const response = await fetch('http://localhost:5080/api/services', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
body: JSON.stringify(transformedBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur mise à jour service:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
return await response.json();
}
/**
* supprime un service
*/
export async function deleteServiceFromBackend(token: string, id: number) {
const response = await fetch(`http://localhost:5080/api/services/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur suppression service:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
return await response.json();
}
/**
* récupère tous les sites
*/
export async function fetchSitesFromBackend( pageNumber: number, pageSize: number): Promise<PaginatedResponse<Site>> {
const headers: HeadersInit = {
'Accept': '*/*'
};
const response = await fetch(`http://localhost:5080/api/sites?pageNumber=${pageNumber}&pageSize=${pageSize}`, {
headers
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur API sites:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
const data = await response.json();
return transformPaginatedResponse<Site>(data, pageNumber, pageSize);
}
/**
* créé un nouveau site
*/
export async function createSiteInBackend(token: string, ville: string) {
const transformedBody = {
Ville: ville
};
const response = await fetch('http://localhost:5080/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
body: JSON.stringify(transformedBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur création site:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
return await response.json();
}
/**
* met à jour un site
*/
export async function updateSiteInBackend(token: string, id: number, ville: string) {
const transformedBody = {
id: id,
ville: ville
};
const response = await fetch('http://localhost:5080/api/sites', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
body: JSON.stringify(transformedBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur mise à jour site:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
return await response.json();
}
/**
* supprime un site
*/
export async function deleteSiteFromBackend(token: string, id: number) {
const response = await fetch(`http://localhost:5080/api/sites/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('Erreur suppression site:', errorText);
throw new Error(`Erreur HTTP ${response.status}`);
}
return await response.json();
}

111
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,111 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
export default function NotFound() {
// variante pour les animations
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
// animation du chiffre 404
const textVariants = {
hidden: { opacity: 0, scale: 0.8 },
visible: {
opacity: 1,
scale: 1,
transition: {
duration: 0.8,
ease: [0.43, 0.13, 0.23, 0.96]
}
}
};
return (
<motion.div
className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center"
variants={containerVariants}
initial="hidden"
animate="visible"
>
<motion.div
className="mb-6 overflow-hidden"
variants={textVariants}
>
<div className="text-9xl font-bold text-primary relative">
<motion.span
className="inline-block"
animate={{
rotateY: [0, 360],
}}
transition={{
duration: 2,
ease: "easeInOut",
repeat: Infinity,
repeatDelay: 5
}}
>4</motion.span>
<motion.span
className="inline-block mx-4"
animate={{
scale: [1, 1.2, 1],
}}
transition={{
duration: 1.5,
ease: "easeInOut",
repeat: Infinity,
repeatDelay: 2
}}
>0</motion.span>
<motion.span
className="inline-block"
animate={{
rotateY: [0, -360],
}}
transition={{
duration: 2,
ease: "easeInOut",
repeat: Infinity,
repeatDelay: 5
}}
>4</motion.span>
</div>
</motion.div>
<motion.h1
className="text-3xl font-bold mb-6"
variants={itemVariants}
>
Page Non Trouvée
</motion.h1>
<motion.p
className="text-lg text-muted-foreground mb-8 max-w-md"
variants={itemVariants}
>
Oups ! La page que vous recherchez n'existe pas ou a é déplacée.
</motion.p>
<motion.div variants={itemVariants}>
<Link
href="/"
className="px-6 py-3 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors hover:scale-105 transform duration-200 inline-block"
>
Retour à l'accueil
</Link>
</motion.div>
</motion.div>
);
}

376
src/app/page.tsx Normal file
View File

@ -0,0 +1,376 @@
'use client';
import { useEffect, useState } from 'react';
import { SearchBar } from '@/components/SearchBar';
import Dropdown from '@/components/Dropdown';
import SalarieCard from '@/components/SalarieCard';
import { Salarie } from '@/types/Salarie';
import { fetchSalaries, fetchSites, fetchServices, fetchSalariesBySite, fetchSalariesByService } from '@/utils/api';
import { Site } from '@/types/Site';
import { Service } from '@/types/Service';
const Home = () => {
// states pour stocker les services, les sites et les salariés
const [sites, setSites] = useState<Site[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [filteredSalaries, setFilteredSalaries] = useState<Salarie[]>([]);
// states gérer la pagination
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(25);
const [totalPages, setTotalPages] = useState<number>(1);
const [totalCount, setTotalCount] = useState<number>(0);
// states pour le chargement et les erreurs
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// states pour la recherche
const [searchTerm, setSearchTerm] = useState<string>('');
// states pour les filtres actifs
const [activeFilters, setActiveFilters] = useState({
site: '',
service: ''
});
// set des sites et services uniquement au premier chargement
useEffect(() => {
async function fetchInitialData() {
setIsLoading(true);
setError(null);
try {
const [sitesResponse, servicesResponse] = await Promise.all([
fetchSites(),
fetchServices(),
]);
setSites(sitesResponse.data);
setServices(servicesResponse.data);
} catch (error) {
console.error('Erreur lors du chargement des sites et services:', error);
setError('Erreur lors du chargement des sites et services. Veuillez réessayer.');
} finally {
setIsLoading(false);
}
}
fetchInitialData();
}, []);
// chargement des salariés avec pagination et recherche
useEffect(() => {
async function fetchSalariesData() {
setIsLoading(true);
setError(null);
try {
const salariesResponse = await fetchSalaries(currentPage, pageSize, searchTerm);
setFilteredSalaries(salariesResponse.data);
setTotalPages(salariesResponse.totalPages);
setTotalCount(salariesResponse.totalCount);
} catch (error) {
console.error('Erreur lors du chargement des salariés:', error);
setError('Erreur lors du chargement des salariés. Veuillez réessayer.');
} finally {
setIsLoading(false);
}
}
// récupération des salariés si aucun filtre n'est actif
if (!activeFilters.site && !activeFilters.service) {
fetchSalariesData();
}
}, [currentPage, pageSize, searchTerm, activeFilters.site, activeFilters.service]);
const handleSearch = (query: string) => {
setSearchTerm(query);
setCurrentPage(1); // retour à la page 1 pour une d'une nouvelle recherche
// réinitialisation des filtres actifs
setActiveFilters({
site: '',
service: ''
});
};
const handleSiteFilter = async (siteId: number | '') => {
setCurrentPage(1);
setIsLoading(true);
setError(null);
setActiveFilters(prev => ({
...prev,
site: siteId ? String(siteId) : ''
}));
try {
if (siteId) {
const salariesResponse = await fetchSalariesBySite(siteId, 1, pageSize);
setFilteredSalaries(salariesResponse.data);
setTotalPages(salariesResponse.totalPages);
setTotalCount(salariesResponse.totalCount);
} else if (activeFilters.service) {
// si un service est sélectionné, garder ce filtre
const serviceId = Number(activeFilters.service);
const salariesResponse = await fetchSalariesByService(serviceId, 1, pageSize);
setFilteredSalaries(salariesResponse.data);
setTotalPages(salariesResponse.totalPages);
setTotalCount(salariesResponse.totalCount);
} else {
// aucun filtre actif, retourner aux données de base
const salariesResponse = await fetchSalaries(1, pageSize, searchTerm);
setFilteredSalaries(salariesResponse.data);
setTotalPages(salariesResponse.totalPages);
setTotalCount(salariesResponse.totalCount);
}
} catch (error) {
console.error('Erreur lors du filtrage par site:', error);
setError('Erreur lors du filtrage par site. Veuillez réessayer.');
} finally {
setIsLoading(false);
}
};
// filtre qui se fait uniquement si il n'y a que celui qui est demandé
const handleServiceFilter = async (serviceId: number | '') => {
setCurrentPage(1);
setIsLoading(true);
setError(null);
setActiveFilters(prev => ({
...prev,
service: serviceId ? String(serviceId) : ''
}));
try {
if (serviceId) {
const salariesResponse = await fetchSalariesByService(serviceId, 1, pageSize);
setFilteredSalaries(salariesResponse.data);
setTotalPages(salariesResponse.totalPages);
setTotalCount(salariesResponse.totalCount);
} else if (activeFilters.site) {
const siteId = Number(activeFilters.site);
const salariesResponse = await fetchSalariesBySite(siteId, 1, pageSize);
setFilteredSalaries(salariesResponse.data);
setTotalPages(salariesResponse.totalPages);
setTotalCount(salariesResponse.totalCount);
} else {
const salariesResponse = await fetchSalaries(1, pageSize, searchTerm);
setFilteredSalaries(salariesResponse.data);
setTotalPages(salariesResponse.totalPages);
setTotalCount(salariesResponse.totalCount);
}
} catch (error) {
console.error('Erreur lors du filtrage par service:', error);
setError('Erreur lors du filtrage par service. Veuillez réessayer.');
} finally {
setIsLoading(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
{/* searchbar */}
<section className="mb-12 animate-in fade-in-50 slide-in-from-bottom-5">
<SearchBar onSearch={handleSearch} />
</section>
{/* dropdown des sites et des services */}
<section className="mb-8 grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in-50 slide-in-from-bottom-5 delay-100">
<Dropdown
options={sites.map((site) => ({ id: site.id, nom: site.ville }))}
onSelect={(id) => handleSiteFilter(id as number)}
label="Filtrer par Site"
/>
<Dropdown
options={services.map((service) => ({ id: service.id, nom: service.nom }))}
onSelect={(id) => handleServiceFilter(id as number)}
label="Filtrer par Service"
/>
</section>
{/* afficahage des infos */}
<section className="flex justify-between items-center mb-6">
<p className="text-sm text-muted-foreground">
{totalCount > 0 ? (
<>
<span className="font-medium">{totalCount}</span> {totalCount > 1 ? 'salariés trouvés' : 'salarié trouvé'}
{activeFilters.site && sites.length > 0 && (
<> à <span className="text-primary font-medium">
{sites.find(s => s.id === Number(activeFilters.site))?.ville}
</span></>
)}
{activeFilters.service && services.length > 0 && (
<> dans <span className="text-primary font-medium">
{services.find(s => s.id === Number(activeFilters.service))?.nom}
</span></>
)}
</>
) : !isLoading ? (
'Aucun salarié trouvé'
) : (
'Chargement des résultats...'
)}
</p>
{/* information des filtres actifs avec possibilité de les supprimer */}
{(activeFilters.site || activeFilters.service || searchTerm) && (
<div className="flex flex-wrap gap-2">
{searchTerm && (
<span className="inline-flex items-center bg-primary/10 text-primary text-xs rounded-full px-3 py-1">
Recherche: {searchTerm}
<button
onClick={() => handleSearch('')}
className="ml-2 hover:text-primary-dark"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-3 h-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
)}
{activeFilters.site && sites.length > 0 && (
<span className="inline-flex items-center bg-primary/10 text-primary text-xs rounded-full px-3 py-1">
Site: {sites.find(s => s.id === Number(activeFilters.site))?.ville}
<button
onClick={() => handleSiteFilter('')}
className="ml-2 hover:text-primary-dark"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-3 h-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
)}
{activeFilters.service && services.length > 0 && (
<span className="inline-flex items-center bg-primary/10 text-primary text-xs rounded-full px-3 py-1">
Service: {services.find(s => s.id === Number(activeFilters.service))?.nom}
<button
onClick={() => handleServiceFilter('')}
className="ml-2 hover:text-primary-dark"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-3 h-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
)}
</div>
)}
</section>
{/* affichage des erreurs */}
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-8 animate-in fade-in-50">
<div className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 mr-2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span>{error}</span>
</div>
</div>
)}
{/* affichage du chargement */}
{isLoading ? (
<div className="flex justify-center items-center h-64 animate-pulse">
<div className="space-y-6 w-full max-w-md">
<div className="h-2.5 bg-muted rounded-full w-full"></div>
<div className="h-2.5 bg-muted rounded-full w-3/4"></div>
<div className="h-2.5 bg-muted rounded-full w-1/2"></div>
<div className="h-12 bg-muted rounded-lg w-full"></div>
<div className="h-12 bg-muted rounded-lg w-full"></div>
</div>
</div>
) : (
<>
{/* grille qui contient les salariés */}
{filteredSalaries.length > 0 ? (
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 animate-in fade-in-50">
{filteredSalaries.map((salarie, index) => (
<div key={salarie.id}
className="animate-in fade-in-50 slide-in-from-bottom-5"
style={{ animationDelay: `${index * 50}ms` }}
>
<SalarieCard salarie={salarie}/>
</div>
))}
</div>
) : (
<div className="bg-card border border-border rounded-lg p-8 text-center animate-in fade-in-50">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-12 h-12 mx-auto text-muted-foreground mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
<h3 className="text-lg font-medium mb-2">Aucun salarié trouvé</h3>
<p className="text-muted-foreground">Essayez de modifier vos critères de recherche ou de filtrage.</p>
</div>
)}
{/* pagination */}
{filteredSalaries.length > 0 && (
<div className="flex flex-col md:flex-row justify-between items-center mt-12 mb-4 space-y-4 md:space-y-0 animate-in fade-in-50 slide-in-from-bottom-5 delay-200">
{/* choix du nombre d'éléments pas page */}
<div className="md:order-1 flex items-center gap-2 w-full md:w-auto">
<label htmlFor="pageSize" className="text-sm text-muted-foreground whitespace-nowrap">
Afficher :
</label>
<select
id="pageSize"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
}}
className="input bg-card text-foreground border border-border rounded-md px-2 py-1.5 text-sm"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* navigation de pagination */}
<div className="md:order-2 flex items-center justify-center w-full md:w-auto">
<button
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="button button-outline px-4 py-2 flex items-center disabled:opacity-50 disabled:cursor-not-allowed hover:scale-105 transition-transform"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 mr-1">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Précédent
</button>
<div className="flex items-center mx-4 text-sm text-muted-foreground">
<span className="whitespace-nowrap">Page <span className="font-medium text-foreground">{currentPage}</span> sur <span className="font-medium text-foreground">{totalPages}</span></span>
</div>
<button
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="button button-primary px-4 py-2 flex items-center disabled:opacity-50 disabled:cursor-not-allowed hover:scale-105 transition-transform"
>
Suivant
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 ml-1">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</button>
</div>
{/* compteur du nombre résultats renvoyés */}
<div className="md:order-3 text-sm text-muted-foreground w-full md:w-auto text-center md:text-right">
<span className="whitespace-nowrap"><span className="font-medium text-foreground">{totalCount}</span> résultats</span>
</div>
</div>
)}
</>
)}
</div>
);
};
export default Home;

View File

@ -0,0 +1,84 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
export default function Restricted() {
// animation cadenas
const lockVariants = {
locked: {
rotate: [0, -10, 10, -10, 10, 0],
transition: {
duration: 0.5,
repeat: 1,
repeatDelay: 3
}
}
};
// animation contenu
const contentVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
delay: 0.3,
duration: 0.6
}
}
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<motion.div
className="mb-8"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
damping: 10,
stiffness: 100
}}
>
<motion.div
className="w-32 h-32 mx-auto relative"
animate="locked"
variants={lockVariants}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-full h-full text-red-500">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<motion.div
className="absolute w-full h-full top-0 left-0 flex items-center justify-center text-white"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
</motion.div>
</motion.div>
</motion.div>
<motion.div
variants={contentVariants}
initial="hidden"
animate="visible"
>
<h1 className="text-3xl font-bold mb-4">Accès Restreint</h1>
<p className="text-lg text-muted-foreground mb-8 max-w-md mx-auto">
Cette zone nécessite une méthode d'accès spéciale. Utilisez le raccourci clavier pour y accéder.
</p>
<Link
href="/"
className="mt-4 px-6 py-3 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors inline-block"
>
Retour à l'accueil
</Link>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,156 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import SalarieCard from '@/components/SalarieCard';
interface SalarieData {
id: number;
nom: string;
prenom: string;
telephoneFixe: string;
telephonePortable: string;
email: string;
service: { nom: string };
site: { ville: string };
fonctionOfficielle?: string;
fonctionUsuelle?: string;
notesParticulieres?: string;
}
export default function SalariePage() {
const params = useParams();
const router = useRouter();
const id = params.id;
const [salarieData, setSalarieData] = useState<SalarieData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchSalarieData() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/salaries/${id}`);
if (!response.ok) {
throw new Error(`Erreur lors de la récupération du salarié (${response.status})`);
}
const responseData = await response.json();
if (responseData.data) {
let serviceInfo = { nom: 'Non spécifié' };
if (responseData.data.idService) {
try {
const serviceResponse = await fetch(`/api/services/${responseData.data.idService}`);
if (serviceResponse.ok) {
const serviceData = await serviceResponse.json();
if (serviceData.data && serviceData.data.nom) {
serviceInfo = { nom: serviceData.data.nom };
}
}
} catch (err) {
console.error('Erreur lors de la récupération du service:', err);
}
}
// récupératiom des informations du site
let siteInfo = { ville: 'Non spécifié' };
if (responseData.data.idSite) {
try {
const siteResponse = await fetch(`/api/sites/${responseData.data.idSite}`);
if (siteResponse.ok) {
const siteData = await siteResponse.json();
if (siteData.data && siteData.data.ville) {
siteInfo = { ville: siteData.data.ville };
}
}
} catch (err) {
console.error('Erreur lors de la récupération du site:', err);
}
}
// formatage des données pour qu'elle coincident avec les attentes SalarieCard
setSalarieData({
id: responseData.data.id,
nom: responseData.data.nom,
prenom: responseData.data.prenom,
telephoneFixe: responseData.data.telephoneFixe || '',
telephonePortable: responseData.data.telephonePortable || '',
email: responseData.data.email || '',
service: serviceInfo,
site: siteInfo,
fonctionOfficielle: responseData.data.fonctionOfficielle || '',
fonctionUsuelle: responseData.data.fonctionUsuelle || '',
notesParticulieres: responseData.data.notesParticulieres || ''
});
} else {
setSalarieData(responseData);
}
} catch (error) {
console.error('Erreur lors du chargement du salarié:', error);
setError("Impossible de charger les informations de ce salarié. Veuillez réessayer.");
} finally {
setIsLoading(false);
}
}
if (id) {
fetchSalarieData();
}
}, [id]);
if (isLoading) {
return (
<div className="container mx-auto px-4 py-12">
<div className="animate-pulse max-w-2xl mx-auto">
<div className="h-8 bg-muted rounded-full w-3/4 mb-6"></div>
<div className="h-64 bg-muted rounded-lg w-full mb-6"></div>
</div>
</div>
);
}
if (error || !salarieData) {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto bg-red-50 border border-red-200 rounded-lg p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-12 h-12 mx-auto text-red-500 mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<h2 className="text-2xl font-bold text-red-700 mb-2">Erreur</h2>
<p className="text-red-600 mb-6">{error || "Salarié non trouvé"}</p>
<button
onClick={() => router.back()}
className="button button-outline mx-auto"
>
Retour
</button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto">
<SalarieCard salarie={salarieData} detailed={true} />
<div className="mt-8 flex justify-center">
<button
onClick={() => router.back()}
className="button button-outline"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 mr-1">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Retour
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
export default function Unauthorized() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<motion.div
className="mb-8 relative"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
>
<motion.div
className="w-32 h-32 rounded-full border-8 border-amber-500 relative"
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<motion.div
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-16 h-16 text-amber-500"
initial={{ rotate: 0 }}
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 0.6, delay: 0.8 }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</motion.svg>
</motion.div>
</motion.div>
</motion.div>
<motion.h1
className="text-3xl font-bold mb-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 1.0 }}
>
Accès non autorisé
</motion.h1>
<motion.p
className="text-lg text-muted-foreground mb-8 max-w-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 1.2 }}
>
Vous n'avez pas les permissions nécessaires pour accéder à cette ressource.
</motion.p>
<motion.div
className="flex flex-col sm:flex-row gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<Link
href="/"
className="px-6 py-3 bg-primary text-white rounded-md hover:bg-primary-dark transition duration-200"
>
Retour à l'accueil
</Link>
<Link
href="/admin/login"
className="px-6 py-3 border border-primary text-primary rounded-md hover:bg-primary hover:text-white transition duration-200"
>
Se connecter
</Link>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,91 @@
'use client';
import { createContext, useContext, ReactNode, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
interface User {
id: number;
email: string;
nom: string;
roleNom: string | null;
}
interface AuthContextType {
user: User | null;
login: (userData: User) => void;
logout: () => void;
isLoading: boolean;
}
// création du contexte
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// vérification de l'authentification au chargement
useEffect(() => {
const checkAuth = async () => {
try {
// appel qui permet de vérifier la session
const response = await fetch('/api/admin/me');
console.log('Réponse status /api/admin/me:', response.status);
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
setUser(null);
}
} catch (error) {
console.error(`Erreur pendant la vérification de la session`, error);
setUser(null);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
// fonctyion de connexion
const handleLogin = (userData: User) => {
setUser(userData);
};
// fonction de déconnexion
const handleLogout = async () => {
try {
await fetch('/api/admin/logout', { method: 'POST' });
setUser(null);
router.push('/');
} catch (error) {
console.error('Erreur pendant la déconnexion:', error);
}
};
return (
<AuthContext.Provider
value={{
user,
login: handleLogin,
logout: handleLogout,
isLoading
}}
>
{children}
</AuthContext.Provider>
);
}
// hook pour utiliser ce contexte
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error(`useAuth doit-être utilisé dans l'AuthProvider`);
}
return context;
}

View File

@ -0,0 +1,89 @@
import { useState, useRef, useEffect } from 'react';
interface DropdownOption {
id: number;
nom: string;
}
interface DropdownProps {
options: DropdownOption[];
onSelect: (id: number | '') => void;
label: string;
}
const Dropdown: React.FC<DropdownProps> = ({ options, onSelect, label }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState<DropdownOption | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// ferme le dropdown si on clique en dehors
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleOptionSelect = (option: DropdownOption | null) => {
setSelectedOption(option);
onSelect(option ? option.id : '');
setIsOpen(false);
};
return (
<div ref={dropdownRef} className="relative w-full">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-card border border-border rounded-lg shadow-sm hover:border-primary/50 transition-colors"
>
<span className="text-sm font-medium truncate">
{selectedOption ? selectedOption.nom : label}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`w-5 h-5 ml-2 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{isOpen && (
<div className="absolute z-10 mt-1 w-full bg-card border border-border rounded-lg shadow-lg overflow-hidden animate-in fade-in-10 slide-in-from-top-5">
<ul className="py-1 max-h-60 overflow-auto bg-background/80 backdrop-blur-md">
<li>
<button
onClick={() => handleOptionSelect(null)}
className="w-full text-left px-4 py-2 text-sm hover:bg-primary/10 transition-colors "
>
Tous
</button>
</li>
{options.map((option) => (
<li key={option.id}>
<button
onClick={() => handleOptionSelect(option)}
className="w-full text-left px-4 py-2 text-sm "
>
{option.nom}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default Dropdown;

45
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,45 @@
'use client'
import Link from "next/link";
const Footer = () => {
return (
<footer className="bg-card border-t border-border py-8 mt-auto">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 justify-items-center text-center md:text-left">
{/* première colonne description */}
<div className="flex flex-col items-center md:items-start max-w-xs">
<h3 className="text-lg font-semibold mb-4 bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">Annuaire CESI</h3>
<p className="text-muted-foreground">
L'outil qui connecte tous les collaborateurs de l'entreprise.
</p>
</div>
{/* colonne central contact */}
<div className="flex flex-col items-center md:items-start max-w-xs">
<h3 className="text-lg font-semibold mb-4">Contact</h3>
<p className="text-muted-foreground">
support@cesi.fr<br />
01 23 45 67 89
</p>
</div>
{/*dernière colonne - liens utils (à compléter si amél;ioration ) */}
<div className="flex flex-col items-center md:items-start max-w-xs">
<h3 className="text-lg font-semibold mb-4">Liens rapides</h3>
<ul className="space-y-2 text-muted-foreground text-center md:text-left">
<li><Link href="/" className="hover:text-primary transition-colors">Accueil</Link></li>
</ul>
</div>
</div>
{/* copyrightt - centré en dessous des */}
<div className="mt-8 pt-6 border-t border-border flex justify-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} Annuaire CESI. Tous droits réservés.
</div>
</div>
</footer>
);
};
export default Footer;

104
src/components/Header.tsx Normal file
View File

@ -0,0 +1,104 @@
'use client'
import Link from 'next/link';
import { useState, useEffect } from 'react';
import MobileMenu from '@/components/MobileMenu';
import { useAuth } from '@/components/AuthProvider';
const Header = () => {
const [scrolled, setScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { user, logout } = useAuth();
const isAdmin = !!(user && user.roleNom === 'admin');
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const closeMobileMenu = () => {
setIsMobileMenuOpen(false);
};
const handleLogout = async () => {
await logout();
};
return (
<>
<header className={`sticky top-0 z-50 ${isAdmin ? 'bg-primary/10' : 'bg-background/80'} backdrop-blur-md border-b border-border transition-all duration-300 ${scrolled ? 'shadow-md py-2' : 'py-4'}`}>
<div className="container mx-auto px-4 flex justify-between items-center">
{/* Logo à gauche */}
<Link href={isAdmin ? '/admin/dashboard' : '/'} className="text-2xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text hover:scale-105 transition-transform flex items-center">
<span>Annuaire CESI</span>
{isAdmin && (
<span className="ml-2 text-xs px-2 py-1 bg-primary text-white rounded-md">
ADMIN
</span>
)}
</Link>
<nav className="hidden md:flex space-x-6">
{isAdmin ? (
// liens admin
<>
<Link href="/admin/dashboard" className="text-foreground/80 hover:text-primary transition-colors">
Dashboard
</Link>
<Link href="/admin/salaries" className="text-foreground/80 hover:text-primary transition-colors">
Salariés
</Link>
<Link href="/admin/services" className="text-foreground/80 hover:text-primary transition-colors">
Services
</Link>
<Link href="/admin/sites" className="text-foreground/80 hover:text-primary transition-colors">
Sites
</Link>
<button
onClick={handleLogout}
className="text-red-600 hover:text-red-800 transition-colors"
>
Déconnexion
</button>
</>
) : (
// visu d'un visiteur
<Link href="/" className="text-foreground/80 hover:text-primary transition-colors">
Accueil
</Link>
)}
</nav>
{/* boution hamburger responsiive*/}
<button
className="md:hidden text-foreground hover:text-primary transition-colors p-1 rounded-md"
onClick={toggleMobileMenu}
aria-label="Menu"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
</header>
<MobileMenu
isOpen={isMobileMenuOpen}
onClose={closeMobileMenu}
isAdmin={isAdmin}
onLogout={handleLogout}
/>
</>
);
};
export default Header;

View File

@ -0,0 +1,8 @@
'use client';
import { useAdminKeyboardShortcut } from "@/app/hooks/secretKeyboardShortcut";
export default function KeyboardShortcutHandler() {
useAdminKeyboardShortcut();
return null;
}

View File

@ -0,0 +1,115 @@
'use client'
import { useEffect, useRef } from 'react';
import Link from 'next/link';
interface MobileMenuProps {
isOpen: boolean;
onClose: () => void;
isAdmin?: boolean;
onLogout?: () => void;
}
// affiche d'un menu pour mobile avec un bouton hamburger
const MobileMenu = ({ isOpen, onClose, isAdmin = false, onLogout }: MobileMenuProps) => {
const menuRef = useRef<HTMLDivElement>(null);
// gestion de la fermeture avec la touche echap
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
// empêche le scroll
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
// permet à nouveau de scroll
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
<div
ref={menuRef}
className={`fixed inset-y-0 right-0 w-full max-w-xs ${isAdmin ? 'bg-primary/5' : 'bg-card'} shadow-xl animate-in slide-in-from-right-full`}
>
<div className="flex flex-col h-full p-6">
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent flex items-center">
Annuaire CESI
{isAdmin && (
<span className="ml-2 text-xs px-2 py-1 bg-primary text-white rounded-md">
ADMIN
</span>
)}
</h2>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-muted transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="flex flex-col space-y-6">
{isAdmin ? (
// liens admin
<>
<Link href="/admin/dashboard" onClick={onClose} className="py-2 text-lg font-medium border-b border-border hover:text-primary transition-colors">
Dashboard
</Link>
<Link href="/admin/salaries" onClick={onClose} className="py-2 text-lg font-medium border-b border-border hover:text-primary transition-colors">
Gestion des Salariés
</Link>
<Link href="/admin/services" onClick={onClose} className="py-2 text-lg font-medium border-b border-border hover:text-primary transition-colors">
Gestion des Services
</Link>
<Link href="/admin/sites" onClick={onClose} className="py-2 text-lg font-medium border-b border-border hover:text-primary transition-colors">
Gestion des Sites
</Link>
{onLogout && (
<button
onClick={() => {
onLogout();
onClose();
}}
className="py-2 text-lg font-medium text-red-600 hover:text-red-800 transition-colors"
>
Déconnexion
</button>
)}
</>
) : (
// liens visiteur
<>
<Link href="/" onClick={onClose} className="py-2 text-lg font-medium border-b border-border hover:text-primary transition-colors">
Accueil
</Link>
</>
)}
</nav>
<div className="mt-auto pt-6 border-t border-border">
<p className="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} Annuaire CESI.
</p>
</div>
</div>
</div>
</div>
);
};
export default MobileMenu;

View File

@ -0,0 +1,48 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from './AuthProvider';
// composant qui autorise l'affichage selon les informations de l'utilisatuer : si il a le role admin authorise l'affichage
export default function ProtectedRoute({
children,
adminOnly = true
}: {
children: React.ReactNode,
adminOnly?: boolean
}) {
const { user, isLoading } = useAuth();
const router = useRouter();
const [isAuthorized, setIsAuthorized] = useState(false);
useEffect(() => {
if (!isLoading) {
if (!user) {
router.push('/admin/login');
} else if (adminOnly && (!user.roleNom || user.roleNom !== 'admin')) {
router.push('/unauthorized');
} else {
setIsAuthorized(true);
}
}
}, [user, isLoading, router, adminOnly]);
// affichage d'un loader pendant la vérification ou le chargement
if (isLoading || (!isAuthorized && user)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
// si l'utilisateur n'est pas autorisé on affiche rienm
if (!isLoading && (!user || (adminOnly && (!user.roleNom || user.roleNom !== 'admin')))) {
return null;
}
// affichage du contenu si l'utilisateur est autorisé
return <>{children}</>;
}

View File

@ -0,0 +1,100 @@
import Link from 'next/link';
import { useState } from 'react';
interface SalarieCardProps {
salarie: {
id: number;
nom: string;
prenom: string;
telephoneFixe: string;
telephonePortable: string;
email: string;
service: { nom: string };
site: { ville: string };
};
detailed?: boolean;
}
// composant qui affiche les informations du salarié, si detailed à true. il affichera l'adresse email en plus de toutes les informations de base (site, service, num portable, num fixe)
const SalarieCard: React.FC<SalarieCardProps> = ({ salarie, detailed = false }) => {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className={`card p-6 group ${detailed ? 'h-full' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
{!detailed ? (
<Link href={`/salarie/${salarie.id}`}>
<h2 className="text-xl font-bold group-hover:text-primary transition-colors">
{salarie.prenom} {salarie.nom}
</h2>
</Link>
) : (
<h2 className="text-xl font-bold">
{salarie.prenom} {salarie.nom}
</h2>
)}
<p className="text-sm text-muted-foreground mt-1"> <i>Service</i> : {salarie.service?.nom || 'Non spécifié'}</p>
<p className="text-sm text-muted-foreground"> <i>Site</i> : {salarie.site?.ville || 'Non spécifié'}</p>
</div>
<div className={`h-16 w-16 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xl font-bold transition-transform duration-300 ${isHovered ? 'scale-110' : ''}`}>
{salarie.prenom?.[0] || '?'}{salarie.nom?.[0] || '?'}
</div>
</div>
<div className="space-y-3 mb-4">
{salarie.email && detailed &&(
<div className="flex items-center text-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4 mr-2 text-muted-foreground">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<a href={`mailto:${salarie.email}`} className="hover:text-primary transition-colors">
{salarie.email}
</a>
</div>
)}
{salarie.telephoneFixe && (
<div className="flex items-center text-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4 mr-2 text-muted-foreground">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
<a href={`tel:${salarie.telephoneFixe}`} className="hover:text-primary transition-colors">
{salarie.telephoneFixe}
</a>
</div>
)}
{salarie.telephonePortable && (
<div className="flex items-center text-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4 mr-2 text-muted-foreground">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
<a href={`tel:${salarie.telephonePortable}`} className="hover:text-primary transition-colors">
{salarie.telephonePortable}
</a>
</div>
)}
</div>
{!detailed && (
<div className="pt-4 border-t border-border mt-auto">
<Link href={`/salarie/${salarie.id}`}
className="flex items-center text-sm font-medium text-primary hover:text-primary-dark transition-colors"
>
<span>Voir le profil</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</Link>
</div>
)}
</div>
);
};
export default SalarieCard;

View File

@ -0,0 +1,102 @@
import { useState, useEffect, useRef, useCallback } from 'react';
interface SearchBarProps {
onSearch: (query: string) => void;
placeholder?: string;
className?: string;
initialValue?: string;
}
export const SearchBar = ({
onSearch,
placeholder = "Rechercher...",
className = "",
initialValue = ""
}: SearchBarProps) => {
const [searchTerm, setSearchTerm] = useState(initialValue);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const previousSearchTermRef = useRef(initialValue);
// useCallback dans ce cas est utilisé pour éviter les boucles infinies : change l'état du composant parent qui change l'état de searchBar etc
const regulatedSearch = useCallback(
(term: string) => {
// la recherche se fait si le terme a changé depuis la denrière recherche
if (term !== previousSearchTermRef.current) {
previousSearchTermRef.current = term;
// déclenche uniquement à partir de deux caractères
if (term.length >= 2 || term === '') {
onSearch(term);
}
}
},
[onSearch]
);
// useEffect avec un délai pour limiter les appels d'API
useEffect(() => {
const timer = setTimeout(() => {
regulatedSearch(searchTerm);
}, 300);
return () => {
clearTimeout(timer);
};
}, [searchTerm, regulatedSearch]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchTerm.length < 2 && searchTerm.length > 0) {
// indication de la condition
if (inputRef.current) {
inputRef.current.classList.add('animate-shake');
setTimeout(() => {
inputRef.current?.classList.remove('animate-shake');
}, 500);
}
return;
}
regulatedSearch(searchTerm);
};
return (
<form onSubmit={handleSearch} className={`relative w-full ${className}`}>
<div className={`relative flex items-center overflow-hidden rounded-lg border transition-all duration-300 ${isFocused ? 'border-primary shadow-sm ring-2 ring-primary/20' : 'border-border'}`}>
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 text-muted-foreground">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</div>
<input
ref={inputRef}
type="text"
placeholder={placeholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
className="flex-1 pl-10 pr-4 py-2 bg-transparent outline-none text-foreground placeholder:text-muted-foreground w-full"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute right-0 mr-2 p-1 text-muted-foreground hover:text-foreground"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{searchTerm.length === 1 && (
<p className="text-sm text-red-500 mt-1 absolute">
Veuillez saisir au moins 2 caractères
</p>
)}
</form>
);
};

View File

@ -0,0 +1,54 @@
'use client';
import { X } from 'lucide-react';
interface DeleteConfirmationProps {
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function DeleteConfirmation({
title,
message,
onConfirm,
onCancel
}: DeleteConfirmationProps) {
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-card rounded-lg shadow-lg max-w-md w-full overflow-hidden backdrop-blur-md">
<div className="flex justify-between items-center px-6 py-4 border-b border-border">
<h2 className="text-xl font-semibold">{title}</h2>
<button
onClick={onCancel}
className="text-muted-foreground hover:text-foreground"
>
<X size={24} />
</button>
</div>
<div className="p-6">
<p className="mb-6 text-muted-foreground">{message}</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-border rounded-md hover:bg-muted transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={onConfirm}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,339 @@
'use client';
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { Site } from '@/types/Site';
import { Service } from '@/types/Service';
interface Salarie {
id: number;
nom: string;
prenom: string;
telephoneFixe: string;
telephonePortable: string;
email: string;
siteId: number;
serviceId: number;
service: { id:number, nom: string };
site: { id: number, ville: string };
}
interface SalarieModalProps {
salarie: Salarie | null;
sites: Site[];
services: Service[];
onClose: () => void;
onSave: (salarie: Omit<Salarie, 'id'>) => void;
}
export default function SalarieModal({
salarie,
sites,
services,
onClose,
onSave
}: SalarieModalProps) {
const [formData, setFormData] = useState({
nom: '',
prenom: '',
telephoneFixe: '',
telephonePortable: '',
email: '',
serviceId: 0,
siteId: 0
});
const [errors, setErrors] = useState<{[key: string]: string}>({});
useEffect(() => {
if (salarie) {
const serviceId = salarie.service?.id || 0;
const siteId = salarie.site?.id || 0;
setFormData({
nom: salarie.nom || '',
prenom: salarie.prenom || '',
telephoneFixe: salarie.telephoneFixe || '',
telephonePortable: salarie.telephonePortable || '',
email: salarie.email || '',
serviceId: Number(serviceId),
siteId: Number(siteId)
});
} else if (sites.length > 0 && services.length > 0) {
// valeur par défaut pour un nouveau salarié
setFormData(prev => ({
...prev,
serviceId: services[0].id,
siteId: sites[0].id
}));
}
}, [salarie, sites, services]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'serviceId' || name === 'siteId' ? parseInt(value) : value
}));
// réinitialisation de l'erreur
if (errors[name]) {
setErrors(prev => {
const newErrors = {...prev};
delete newErrors[name];
return newErrors;
});
}
};
// fonction de validation pour les numéros de téléphone portable français :06 ou 07 (10 chiffres qui commencet par 06 ou 07)
const validateMobileNumber = (phone: string) => {
if (!phone) return true;
const cleaned = phone.replace(/\D/g, '');
const frenchPattern = /^0[67][0-9]{8}$/;
// format international: +336 ou +337
const internationalPattern = /^\+33[67][0-9]{8}$/;
return frenchPattern.test(cleaned) || internationalPattern.test(phone);
};
// validation pour les numéros de téléphone fixe français 01, jusque 05 et de 08 à 09
const validateFixedNumber = (phone: string) => {
if (!phone) return true;
const cleaned = phone.replace(/\D/g, '');
const frenchPattern = /^0[1-589][0-9]{8}$/;
// format international: +331, +332..
const internationalPattern = /^\+33[1-589][0-9]{8}$/;
return frenchPattern.test(cleaned) || internationalPattern.test(phone);
};
const validateForm = () => {
const newErrors: {[key: string]: string} = {};
if (!formData.nom.trim()) {
newErrors.nom = "Le nom est obligatoire";
}
if (!formData.prenom.trim()) {
newErrors.prenom = "Le prénom est obligatoire";
}
if (!formData.email.trim()) {
newErrors.email = "L'email est obligatoire";
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = "le format de l'email saisit est invalide";
}
if (formData.telephoneFixe && !validateFixedNumber(formData.telephoneFixe)) {
newErrors.telephoneFixe = "Format de téléphone fixe invalide. Entrer un numéro à 10 chiffres commençant par 01 à 05 ou de 08 à 09";
}
if (formData.telephonePortable && !validateMobileNumber(formData.telephonePortable)) {
newErrors.telephonePortable = "Format de téléphone portable invalide. Entrer un numéro à 10 : 06XXXXXXXX ou 07XXXXXXXX";
}
if (!formData.siteId) {
newErrors.siteId = "Le site est obligatoire";
}
if (!formData.serviceId) {
newErrors.serviceId = "Le service est obligatoire";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
const selectedService = services.find(service => service.id === formData.serviceId);
const selectedSite = sites.find(site => site.id === formData.siteId);
if (!selectedService || !selectedSite) {
console.error("Service ou site non trouvé");
return;
}
const salarieData: Omit<Salarie, "id"> = {
nom: formData.nom,
prenom: formData.prenom,
telephoneFixe: formData.telephoneFixe,
telephonePortable: formData.telephonePortable,
email: formData.email,
serviceId: formData.serviceId,
siteId: formData.siteId,
service: selectedService,
site: selectedSite,
};
onSave(salarieData);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 overflow-y-auto">
<div className="bg-card rounded-lg shadow-lg max-w-2xl w-full overflow-hidden backdrop-blur-md">
<div className="flex justify-between items-center px-6 py-4 border-b border-border">
<h2 className="text-xl font-semibold">
{salarie ? 'Modifier le salarié' : 'Ajouter un salarié'}
</h2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* nom */}
<div>
<label htmlFor="nom" className="block text-sm font-medium mb-2">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="nom"
name="nom"
value={formData.nom}
onChange={handleChange}
className={`w-full px-3 py-2 border ${errors.nom ? 'border-red-500' : 'border-border'} rounded-md focus:outline-none focus:ring-2 focus:ring-primary`}
/>
{errors.nom && <p className="mt-1 text-sm text-red-500">{errors.nom}</p>}
</div>
{/* prénom */}
<div>
<label htmlFor="prenom" className="block text-sm font-medium mb-2">
Prénom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="prenom"
name="prenom"
value={formData.prenom}
onChange={handleChange}
className={`w-full px-3 py-2 border ${errors.prenom ? 'border-red-500' : 'border-border'} rounded-md focus:outline-none focus:ring-2 focus:ring-primary`}
/>
{errors.prenom && <p className="mt-1 text-sm text-red-500">{errors.prenom}</p>}
</div>
{/* email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className={`w-full px-3 py-2 border ${errors.email ? 'border-red-500' : 'border-border'} rounded-md focus:outline-none focus:ring-2 focus:ring-primary`}
/>
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
</div>
{/* fixe */}
<div>
<label htmlFor="telephoneFixe" className="block text-sm font-medium mb-2">
Téléphone Fixe
</label>
<input
type="tel"
id="telephoneFixe"
name="telephoneFixe"
value={formData.telephoneFixe}
onChange={handleChange}
placeholder="0123456789"
className={`w-full px-3 py-2 border ${errors.telephoneFixe ? 'border-red-500' : 'border-border'} rounded-md focus:outline-none focus:ring-2 focus:ring-primary`}
/>
{errors.telephoneFixe && <p className="mt-1 text-sm text-red-500">{errors.telephoneFixe}</p>}
</div>
{/* portable */}
<div>
<label htmlFor="telephonePortable" className="block text-sm font-medium mb-2">
Téléphone Portable
</label>
<input
type="tel"
id="telephonePortable"
name="telephonePortable"
value={formData.telephonePortable}
onChange={handleChange}
placeholder="0612345678"
className={`w-full px-3 py-2 border ${errors.telephonePortable ? 'border-red-500' : 'border-border'} rounded-md focus:outline-none focus:ring-2 focus:ring-primary`}
/>
{errors.telephonePortable && <p className="mt-1 text-sm text-red-500">{errors.telephonePortable}</p>}
</div>
{/* service */}
<div>
<label htmlFor="serviceId" className="block text-sm font-medium mb-2">
Service <span className="text-red-500">*</span>
</label>
<select
id="serviceId"
name="serviceId"
value={String(formData.serviceId)}
onChange={handleChange}
className={`w-full px-3 py-2 border ${errors.serviceId ? 'border-red-500' : 'border-border'} rounded-md focus:outline-none focus:ring-2 focus:ring-primary`}
>
<option value="">Sélectionner un service</option>
{services.map((service) => (
<option key={service.id} value={String(service.id)}>{service.nom}</option>
))}
</select>
{errors.serviceId && <p className="mt-1 text-sm text-red-500">{errors.serviceId}</p>}
</div>
{/* site */}
<div>
<label htmlFor="siteId" className="block text-sm font-medium mb-2">
Site <span className="text-red-500">*</span>
</label>
<select
id="siteId"
name="siteId"
value={String(formData.siteId)}
onChange={handleChange}
className={`w-full px-3 py-2 border ${errors.siteId ? 'border-red-500' : 'border-border'} rounded-md focus:outline-none focus:ring-2 focus:ring-primary`}
>
<option value="">Sélectionner un site</option>
{sites.map((site) => (
<option key={site.id} value={String(site.id)}>{site.ville}</option>
))}
</select>
{errors.siteId && <p className="mt-1 text-sm text-red-500">{errors.siteId}</p>}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border rounded-md hover:bg-muted transition-colors"
>
Annuler
</button>
<button
type="submit"
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors"
>
Enregistrer
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
'use client';
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
interface ServiceModalProps {
service: { id: number; nom: string } | null;
onClose: () => void;
onSave: (service: { nom: string }) => void;
}
export default function ServiceModal({ service, onClose, onSave }: ServiceModalProps) {
const [nom, setNom] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (service) {
setNom(service.nom);
}
}, [service]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!nom.trim()) {
setError('Le nom du service est requis');
return;
}
onSave({ nom });
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-card rounded-lg shadow-lg max-w-md w-full overflow-hidden backdrop-blur-md">
<div className="flex justify-between items-center px-6 py-4 border-b border-border">
<h2 className="text-xl font-semibold">
{service ? 'Modifier le service' : 'Ajouter un service'}
</h2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6">
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
{error}
</div>
)}
<div className="mb-4">
<label htmlFor="nom" className="block text-sm font-medium mb-2">
Nom du service
</label>
<input
type="text"
id="nom"
value={nom}
onChange={(e) => setNom(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Nom du service"
/>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border rounded-md hover:bg-muted transition-colors"
>
Annuler
</button>
<button
type="submit"
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors"
>
Enregistrer
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
'use client';
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
interface SiteModalProps {
site: { id: number; ville: string } | null;
onClose: () => void;
onSave: (site: { ville: string }) => void;
}
export default function SiteModal({ site, onClose, onSave }: SiteModalProps) {
const [ville, setVille] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (site) {
setVille(site.ville);
}
}, [site]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!ville.trim()) {
setError('La ville du site est requise');
return;
}
onSave({ ville });
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-card rounded-lg shadow-lg max-w-md w-full overflow-hidden backdrop-blur-md">
<div className="flex justify-between items-center px-6 py-4 border-b border-border">
<h2 className="text-xl font-semibold">
{site ? 'Modifier le site' : 'Ajouter un site'}
</h2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6">
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
{error}
</div>
)}
<div className="mb-4">
<label htmlFor="ville" className="block text-sm font-medium mb-2">
Ville
</label>
<input
type="text"
id="ville"
value={ville}
onChange={(e) => setVille(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ville du site"
/>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border rounded-md hover:bg-muted transition-colors"
>
Annuler
</button>
<button
type="submit"
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark transition-colors"
>
Enregistrer
</button>
</div>
</form>
</div>
</div>
);
}

29
src/middleware.ts Normal file
View File

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
//protection de la route login si on la rentre en dur
if (request.nextUrl.pathname.startsWith('/admin/login')) {
const hasAccessCookie = request.cookies.get('admin_access')?.value;
if (!hasAccessCookie) {
return NextResponse.redirect(new URL('/restricted', request.url));
}
}
// protection des autres routes admin (sauf login)
if (request.nextUrl.pathname.startsWith('/admin') && !request.nextUrl.pathname.startsWith('/admin/login')) {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'],
};

View File

@ -0,0 +1,9 @@
export interface ApiPaginatedResponse<T> {
data?: T[];
pageNumber?: number;
pageSize?: number;
totalPages?: number;
totalCount?: number;
hasPreviousPage?: boolean;
hasNextPage?: boolean;
}

View File

@ -0,0 +1,10 @@
export interface PaginatedResponse<T> {
data: T[];
totalPages: number;
pageNumber: number;
currentPage?: number;
hasPreviousPage?: boolean;
hasNextPage?: boolean;
pageSize: number;
totalCount: number;
}

7
src/types/Role.ts Normal file
View File

@ -0,0 +1,7 @@
import { Utilisateur } from "./Utilisateur";
export interface Role {
id: number;
nom: string;
utilisateurs: Utilisateur[];
}

12
src/types/Salarie.ts Normal file
View File

@ -0,0 +1,12 @@
export interface Salarie {
id: number;
nom: string;
prenom: string;
telephoneFixe: string;
telephonePortable: string;
email: string;
siteId: number;
serviceId: number;
service: { id: number; nom: string };
site: { id: number; ville: string };
}

7
src/types/Service.ts Normal file
View File

@ -0,0 +1,7 @@
import { Salarie } from "./Salarie";
export interface Service {
id: number;
nom: string;
salaries: Salarie[];
}

4
src/types/Site.ts Normal file
View File

@ -0,0 +1,4 @@
export interface Site {
id: number;
ville: string;
}

12
src/types/Utilisateur.ts Normal file
View File

@ -0,0 +1,12 @@
import { Role } from "./Role";
export interface Utilisateur {
id: number;
nom: string;
prenom: string;
motDePasse: string;
email: string;
idRole: number;
accessToken?: string;
idRoleNavigation: Role;
}

85
src/utils/api.ts Normal file
View File

@ -0,0 +1,85 @@
// app/utils/api.ts
import { Salarie } from '@/types/Salarie';
import { Service } from '@/types/Service';
import { Site } from '@/types/Site';
import { PaginatedResponse } from '@/types/PaginatedResponse';
export const fetchSalaries = async (
pageNumber: number = 1,
pageSize: number = 25,
searchTerm: string = ''
): Promise<PaginatedResponse<Salarie>> => {
try {
const params = new URLSearchParams();
params.append('pageNumber', pageNumber.toString());
params.append('pageSize', pageSize.toString());
const url = searchTerm
? `/api/salaries/search?${params.toString()}&searchTerm=${encodeURIComponent(searchTerm)}`
: `/api/salaries/all?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Erreur lors de la récupération des salariés');
return await response.json();
} catch (error) {
console.error('Erreur lors de la récupération des salariés : ', error);
return {
data: [],
totalPages: 0,
pageNumber: pageNumber,
pageSize: pageSize,
totalCount: 0
};
}
};
export const fetchSites = async (
pageNumber: number = 1,
pageSize: number = 25
): Promise<PaginatedResponse<Site>> => {
const response = await fetch(`/api/sites?pageNumber=${pageNumber}&pageSize=${pageSize}`);
if (!response.ok) throw new Error('Erreur lors de la récupération des sites');
const result = await response.json();
return result;
};
export const fetchServices = async (
pageNumber: number = 1,
pageSize: number = 25
): Promise<PaginatedResponse<Service>> => {
const response = await fetch(`/api/services?pageNumber=${pageNumber}&pageSize=${pageSize}`);
if (!response.ok) throw new Error('Erreur lors de la récupération des services');
const result = await response.json();
return result;
};
export const fetchSalariesBySite = async (
siteId: number,
pageNumber: number = 1,
pageSize: number = 25
): Promise<PaginatedResponse<Salarie>> => {
const params = new URLSearchParams();
params.append('pageNumber', pageNumber.toString());
params.append('pageSize', pageSize.toString());
const response = await fetch(`/api/salaries/site/${siteId}?${params.toString()}`);
if (!response.ok) throw new Error('Erreur lors de la récupération des salariés par site');
const result = await response.json();
return result;
};
export const fetchSalariesByService = async (
serviceId: number,
pageNumber: number = 1,
pageSize: number = 25
): Promise<PaginatedResponse<Salarie>> => {
const params = new URLSearchParams();
params.append('pageNumber', pageNumber.toString());
params.append('pageSize', pageSize.toString());
const response = await fetch(`/api/salaries/service/${serviceId}?${params.toString()}`);
if (!response.ok) throw new Error('Erreur lors de la récupération des salariés par service');
const result = await response.json();
return result;
};

123
tailwind.config.js Normal file
View File

@ -0,0 +1,123 @@
// // /** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class", '[data-theme="dark"]'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
primary: {
DEFAULT: "var(--primary)",
dark: "var(--primary-dark)",
},
secondary: "var(--secondary)",
accent: "var(--accent)",
card: "var(--card)",
"card-foreground": "var(--card-foreground)",
border: "var(--border)",
ring: "var(--ring)",
muted: "var(--muted)",
"muted-foreground": "var(--muted-foreground)",
},
keyframes: {
"animate-in": {
"0%": { opacity: 0, transform: "translateY(10px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
},
"animate-out": {
"0%": { opacity: 1, transform: "translateY(0)" },
"100%": { opacity: 0, transform: "translateY(10px)" },
},
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-2px)' },
'20%, 40%, 60%, 80%': { transform: 'translateX(2px)' },
},
"slide-in-from-top": {
"0%": { transform: "translateY(-5%)" },
"100%": { transform: "translateY(0)" },
},
"slide-in-from-bottom": {
"0%": { transform: "translateY(5%)" },
"100%": { transform: "translateY(0)" },
},
"fade-in": {
"0%": { opacity: 0 },
"100%": { opacity: 1 },
},
},
animation: {
"animate-in": "animate-in 0.3s ease-out",
"animate-out": "animate-out 0.3s ease-in",
shake: 'shake 0.5s cubic-bezier(.36,.07,.19,.97) both',
"slide-in-from-top": "slide-in-from-top 0.3s ease-out, fade-in 0.3s ease-out",
"slide-in-from-bottom": "slide-in-from-bottom 0.3s ease-out, fade-in 0.3s ease-out",
"fade-in": "fade-in 0.3s ease-out",
},
},
},
plugins: [
// Plugin pour les animations
function ({ addUtilities, matchUtilities, theme }) {
addUtilities({
".animate-in": {
animationName: "animate-in",
animationDuration: "0.3s",
animationTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
animationFillMode: "forwards",
},
});
matchUtilities(
{
"fade-in": (value) => ({
opacity: 0,
animation: `fade-in ${value} ease-out forwards`,
}),
},
{ values: theme("transitionDuration") }
);
matchUtilities(
{
"slide-in-from-top": (value) => ({
transform: "translateY(-5%)",
animation: `slide-in-from-top ${value} ease-out forwards, fade-in ${value} ease-out forwards`,
}),
},
{ values: theme("transitionDuration") }
);
matchUtilities(
{
"slide-in-from-bottom": (value) => ({
transform: "translateY(5%)",
animation: `slide-in-from-bottom ${value} ease-out forwards, fade-in ${value} ease-out forwards`,
}),
},
{ values: theme("transitionDuration") }
);
matchUtilities(
{
delay: (value) => ({
animationDelay: value,
}),
},
{ values: theme("transitionDelay") }
);
},
],
};

27
tsconfig.json Normal file
View File

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