Premier commit - front
This commit is contained in:
commit
b01049c1c6
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
36
README.md
Normal 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
21
eslint.config.mjs
Normal 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
14
next.config.ts
Normal 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
5285
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
46
src/app/admin/dashboard/page.tsx
Normal file
46
src/app/admin/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
111
src/app/admin/login/page.tsx
Normal file
111
src/app/admin/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
380
src/app/admin/salaries/page.tsx
Normal file
380
src/app/admin/salaries/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
203
src/app/admin/services/page.tsx
Normal file
203
src/app/admin/services/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
223
src/app/admin/sites/page.tsx
Normal file
223
src/app/admin/sites/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
15
src/app/api/admin/clear-access-cookie/route.ts
Normal file
15
src/app/api/admin/clear-access-cookie/route.ts
Normal 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;
|
||||||
|
}
|
56
src/app/api/admin/login/route.ts
Normal file
56
src/app/api/admin/login/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
47
src/app/api/admin/logout/route.ts
Normal file
47
src/app/api/admin/logout/route.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
69
src/app/api/admin/me/route.ts
Normal file
69
src/app/api/admin/me/route.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
17
src/app/api/admin/set-access-cookie/route.ts
Normal file
17
src/app/api/admin/set-access-cookie/route.ts
Normal 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;
|
||||||
|
}
|
28
src/app/api/salaries/[id]/route.ts
Normal file
28
src/app/api/salaries/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
49
src/app/api/salaries/delete/route.ts
Normal file
49
src/app/api/salaries/delete/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
136
src/app/api/salaries/route.ts
Normal file
136
src/app/api/salaries/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
74
src/app/api/services/delete/route.ts
Normal file
74
src/app/api/services/delete/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
128
src/app/api/services/route.ts
Normal file
128
src/app/api/services/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
77
src/app/api/sites/delete/route.ts
Normal file
77
src/app/api/sites/delete/route.ts
Normal 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
106
src/app/api/sites/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
96
src/app/globals.css
Normal file
96
src/app/globals.css
Normal 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);
|
||||||
|
}
|
34
src/app/hooks/secretKeyboardShortcut.tsx
Normal file
34
src/app/hooks/secretKeyboardShortcut.tsx
Normal 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
45
src/app/layout.tsx
Normal 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
214
src/app/lib/backend-api.ts
Normal 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
111
src/app/not-found.tsx
Normal 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 été 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
376
src/app/page.tsx
Normal 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;
|
84
src/app/restricted/page.tsx
Normal file
84
src/app/restricted/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
156
src/app/salarie/[id]/page.tsx
Normal file
156
src/app/salarie/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
84
src/app/unauthorized/page.tsx
Normal file
84
src/app/unauthorized/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
91
src/components/AuthProvider.tsx
Normal file
91
src/components/AuthProvider.tsx
Normal 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;
|
||||||
|
}
|
89
src/components/Dropdown.tsx
Normal file
89
src/components/Dropdown.tsx
Normal 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
45
src/components/Footer.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} Annuaire CESI. Tous droits réservés.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
104
src/components/Header.tsx
Normal file
104
src/components/Header.tsx
Normal 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;
|
8
src/components/KeyboardShortcutHandler.tsx
Normal file
8
src/components/KeyboardShortcutHandler.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAdminKeyboardShortcut } from "@/app/hooks/secretKeyboardShortcut";
|
||||||
|
|
||||||
|
export default function KeyboardShortcutHandler() {
|
||||||
|
useAdminKeyboardShortcut();
|
||||||
|
return null;
|
||||||
|
}
|
115
src/components/MobileMenu.tsx
Normal file
115
src/components/MobileMenu.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} Annuaire CESI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileMenu;
|
48
src/components/ProtectedRoute.tsx
Normal file
48
src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||||
|
}
|
100
src/components/SalarieCard.tsx
Normal file
100
src/components/SalarieCard.tsx
Normal 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;
|
102
src/components/SearchBar.tsx
Normal file
102
src/components/SearchBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
54
src/components/admin/DeleteConfirmation.tsx
Normal file
54
src/components/admin/DeleteConfirmation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
339
src/components/admin/SalarieModal.tsx
Normal file
339
src/components/admin/SalarieModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
88
src/components/admin/ServiceModal.tsx
Normal file
88
src/components/admin/ServiceModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
88
src/components/admin/SiteModal.tsx
Normal file
88
src/components/admin/SiteModal.tsx
Normal 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
29
src/middleware.ts
Normal 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*'],
|
||||||
|
};
|
9
src/types/ApiPaginatedResponse.ts
Normal file
9
src/types/ApiPaginatedResponse.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface ApiPaginatedResponse<T> {
|
||||||
|
data?: T[];
|
||||||
|
pageNumber?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
hasPreviousPage?: boolean;
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
}
|
10
src/types/PaginatedResponse.ts
Normal file
10
src/types/PaginatedResponse.ts
Normal 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
7
src/types/Role.ts
Normal 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
12
src/types/Salarie.ts
Normal 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
7
src/types/Service.ts
Normal 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
4
src/types/Site.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface Site {
|
||||||
|
id: number;
|
||||||
|
ville: string;
|
||||||
|
}
|
12
src/types/Utilisateur.ts
Normal file
12
src/types/Utilisateur.ts
Normal 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
85
src/utils/api.ts
Normal 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
123
tailwind.config.js
Normal 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
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user