V1_commit_RGC

This commit is contained in:
2026-02-11 13:57:54 +01:00
commit ef397eedac
4901 changed files with 292881 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,11 @@
This file explains how Visual Studio created the project.
The following tools were used to generate this project:
- create-vite
The following steps were used to generate this project:
- Create react project with create-vite: `npm init --yes vite@latest suivireforamteur -- --template=react-ts`.
- Create project file (`suivireforamteur.esproj`).
- Create `launch.json` to enable debugging.
- Add project to solution.
- Write this file.

View File

@@ -0,0 +1,34 @@
FROM node:20
WORKDIR /app
# Copier package.json uniquement
COPY package.json ./
# Installation propre
RUN rm -f package-lock.json && \
npm cache clean --force && \
npm install
# Vérifier les packages installés
RUN echo "=== Packages installés ===" && \
npm ls vite && \
npm ls rollup && \
echo "✓ Installation réussie"
# Copier le code source
COPY . .
# Nettoyer les caches
RUN rm -rf node_modules/.vite .vite dist
# Vérifications
RUN echo "=== Vérification des fichiers ===" && \
test -f src/main.tsx && echo "✓ main.tsx" || echo "✗ main.tsx MANQUANT" && \
test -f src/App.tsx && echo "✓ App.tsx" || echo "✗ App.tsx MANQUANT"
EXPOSE 81
ENV NODE_ENV=development
CMD ["npx", "vite", "--host", "0.0.0.0", "--port", "81", "--force"]

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,39 @@
services:
backend:
image: ouijdaneim/rg-backend:latest
build:
context: ./public/backend
dockerfile: ./DockerfileRG.backend
container_name: rg-backend
hostname: backend
ports:
- "8020:3005"
environment:
- TZ=Europe/Paris # 🔥 Ajout du timezone
networks:
- rg-network
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
image: ouijdaneim/rg-frontend:latest
build:
context: .
dockerfile: ./DockerfileRG.frontend
container_name: rg-frontend
hostname: frontend
ports:
- "3020:81"
environment:
- VITE_API_URL=http://backend:3005
- TZ=Europe/Paris # 🔥 Ajout du timezone
networks:
- rg-network
depends_on:
- backend
restart: unless-stopped
networks:
rg-network:
driver: bridge

View File

@@ -0,0 +1,30 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
])

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RGC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<PackageJsonName Condition="$(PackageJsonName) == ''">suivireforamteur</PackageJsonName>
<PackageJsonPrivate Condition="$(PackageJsonPrivate) == ''">true</PackageJsonPrivate>
<PackageJsonVersion Condition="$(PackageJsonVersion) == ''">0.0.0</PackageJsonVersion>
<PackageJsonType Condition="$(PackageJsonType) == ''">module</PackageJsonType>
<PackageJsonScriptsDev Condition="$(PackageJsonScriptsDev) == ''">vite</PackageJsonScriptsDev>
<PackageJsonScriptsBuild Condition="$(PackageJsonScriptsBuild) == ''">tsc -b &amp;&amp; vite build</PackageJsonScriptsBuild>
<PackageJsonScriptsLint Condition="$(PackageJsonScriptsLint) == ''">eslint .</PackageJsonScriptsLint>
<PackageJsonScriptsPreview Condition="$(PackageJsonScriptsPreview) == ''">vite preview</PackageJsonScriptsPreview>
<PackageJsonDependenciesAzureMsalBrowser Condition="$(PackageJsonDependenciesAzureMsalBrowser) == ''">^3.7.1</PackageJsonDependenciesAzureMsalBrowser>
<PackageJsonDependenciesAzureMsalReact Condition="$(PackageJsonDependenciesAzureMsalReact) == ''">^2.0.11</PackageJsonDependenciesAzureMsalReact>
<PackageJsonDependenciesAxios Condition="$(PackageJsonDependenciesAxios) == ''">^1.6.5</PackageJsonDependenciesAxios>
<PackageJsonDependenciesJspdf Condition="$(PackageJsonDependenciesJspdf) == ''">^2.5.2</PackageJsonDependenciesJspdf>
<PackageJsonDependenciesReact Condition="$(PackageJsonDependenciesReact) == ''">^18.2.0</PackageJsonDependenciesReact>
<PackageJsonDependenciesReactDom Condition="$(PackageJsonDependenciesReactDom) == ''">^18.2.0</PackageJsonDependenciesReactDom>
<PackageJsonDevdependenciesTypesNode Condition="$(PackageJsonDevdependenciesTypesNode) == ''">^20.11.5</PackageJsonDevdependenciesTypesNode>
<PackageJsonDevdependenciesTypesReact Condition="$(PackageJsonDevdependenciesTypesReact) == ''">^18.3.28</PackageJsonDevdependenciesTypesReact>
<PackageJsonDevdependenciesTypesReactDom Condition="$(PackageJsonDevdependenciesTypesReactDom) == ''">^18.3.7</PackageJsonDevdependenciesTypesReactDom>
<PackageJsonDevdependenciesVitejsPluginReact Condition="$(PackageJsonDevdependenciesVitejsPluginReact) == ''">^4.2.1</PackageJsonDevdependenciesVitejsPluginReact>
<PackageJsonDevdependenciesTypescript Condition="$(PackageJsonDevdependenciesTypescript) == ''">^5.3.3</PackageJsonDevdependenciesTypescript>
<PackageJsonDevdependenciesVite Condition="$(PackageJsonDevdependenciesVite) == ''">^5.0.11</PackageJsonDevdependenciesVite>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1 @@
C:\Users\oimer\source\repos\SuiviREForamteur\SuiviREForamteur\obj\Debug\suivireforamteur.esproj.CoreCompileInputs.cache

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "suivireforamteur",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@azure/msal-browser": "^3.7.1",
"@azure/msal-react": "^2.0.11",
"axios": "^1.6.5",
"jspdf": "^2.5.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

View File

@@ -0,0 +1,9 @@
AZURE_CLIENT_ID=1225bb0a-37ec-4b82-be22-bb1f4a1e0231
AZURE_TENANT_ID=9840a2a0-6ae1-4688-b03d-d2ec291be0f9
AZURE_CLIENT_SECRET=77971875-de95-4b54-8aaa-9ef98f733bce
REDIRECT_URI=https://myrgc.ensup-adm.net/
DB_SERVER=192.168.0.3
DB_USER=RG-Competences
DB_PASSWORD=P@ssw0rd2026!
DB_DATABASE=RG-Competences

View File

@@ -0,0 +1,23 @@
FROM node:18-alpine
# Install required tools
RUN apk add --no-cache curl mysql-client python3 make g++
WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./
# Install dependencies
RUN npm install --production
# Copy application code
COPY . .
# Expose the port
EXPOSE 3005
# Start the server
CMD ["node", "server.js"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "suivi-evaluations-backend",
"version": "1.0.0",
"description": "Backend pour authentification Microsoft",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@azure/msal-browser": "^5.1.0",
"@azure/msal-node": "^2.6.0",
"@azure/msal-react": "^5.0.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"mssql": "^10.0.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,87 @@
// App.tsx
import React, { useState } from 'react';
import { MsalProvider } from '@azure/msal-react';
import { msalInstance } from './msalConfig';
import { AuthProvider, useAuth } from './AuthContext';
import type { EvaluationRecord } from './types';
import LoginPage from './LoginPage';
import HistoriquePage from './HistoriquePage';
import SaisiePage from './SaisiePage';
import './styles.css';
const initialRecords: EvaluationRecord[] = [
{
id: 1,
nom: 'Dupont',
prenom: 'Marie',
formation: 'BTS NDRC 1 JV',
autoEvaluation: 'OUI',
dateAction: '25/01/2026',
commentaireFormateur: 'Relance HP',
evaluationTuteur: 'OUI',
dateActionTuteur: '25/01/2026',
commentaireRRE: 'Relance Tuteur',
campus: 'CGY'
}
];
const AppContent: React.FC = () => {
const { user, login, logout, isAuthenticated } = useAuth();
const [currentPage, setCurrentPage] = useState('historique');
const [records, setRecords] = useState<EvaluationRecord[]>(initialRecords);
const handleNewEntry = () => {
setCurrentPage('saisie');
};
const handleSave = (newRecord: EvaluationRecord) => {
setRecords(prev => [...prev, newRecord]);
setCurrentPage('historique');
};
const handleUpdateRecord = (updatedRecord: EvaluationRecord) => {
setRecords(prev =>
prev.map(record =>
record.id === updatedRecord.id ? updatedRecord : record
)
);
};
const handleCancel = () => {
setCurrentPage('historique');
};
// Si pas authentifié, afficher la page de login
if (!isAuthenticated || !user) {
return <LoginPage onLogin={login} />;
}
// Si sur la page de saisie
if (currentPage === 'saisie') {
return <SaisiePage onSave={handleSave} onCancel={handleCancel} user={user} />;
}
// Page historique par défaut
return (
<HistoriquePage
records={records}
onNewEntry={handleNewEntry}
onLogout={logout}
onUpdateRecord={handleUpdateRecord}
user={user}
/>
);
};
const App: React.FC = () => {
return (
<MsalProvider instance={msalInstance}>
<AuthProvider>
<AppContent />
</AuthProvider>
</MsalProvider>
);
};
export default App;

View File

@@ -0,0 +1,89 @@
// AuthContext.tsx - Context pour gérer l'authentification
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { User } from './types';
interface AuthContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
isAuthenticated: boolean;
isRRE: boolean;
isFormateur: boolean;
hasPermission: (permission: keyof User['permissions']) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// ✅ Initialisation lazy pour éviter les problèmes HMR
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
try {
const storedUser = localStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null;
} catch (error) {
console.error('Erreur lors de la lecture du localStorage:', error);
return null;
}
});
// ✅ Synchronisation avec localStorage quand user change
useEffect(() => {
if (user) {
try {
localStorage.setItem('user', JSON.stringify(user));
} catch (error) {
console.error('Erreur lors de l\'écriture dans localStorage:', error);
}
}
}, [user]);
const login = (userData: User) => {
setUser(userData);
try {
localStorage.setItem('user', JSON.stringify(userData));
} catch (error) {
console.error('Erreur lors de la sauvegarde de l\'utilisateur:', error);
}
};
const logout = () => {
setUser(null);
try {
localStorage.removeItem('user');
localStorage.removeItem('accessToken');
} catch (error) {
console.error('Erreur lors de la déconnexion:', error);
}
};
const hasPermission = (permission: keyof User['permissions']) => {
return user?.permissions?.[permission] || false;
};
// ✅ Mémoïsation de la valeur du context pour éviter les re-renders inutiles
const value = React.useMemo(() => ({
user,
login,
logout,
isAuthenticated: !!user,
isRRE: user?.role === 'rre',
isFormateur: user?.role === 'formateur',
hasPermission
}), [user]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,982 @@
/* HistoriquePage.css - VERSION OPTIMISÉE RESPONSIVE */
/* ═══════════════════════════════════════════════════════
VARIABLES
═══════════════════════════════════════════════════════ */
:root {
--primary-color: #0ea5e9;
--primary-dark: #0284c7;
--success-color: #22c55e;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--dark-color: #1e293b;
--gray-color: #64748b;
--light-gray: #f1f5f9;
--border-radius: 12px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
}
/* ═══════════════════════════════════════════════════════
RESET
═══════════════════════════════════════════════════════ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
}
/* Réduction automatique pour les petits écrans */
@media (max-width: 1600px) {
html {
font-size: 15px;
}
}
@media (max-width: 1400px) {
html {
font-size: 14px;
}
}
@media (max-width: 1200px) {
html {
font-size: 13px;
}
}
@media (max-width: 992px) {
html {
font-size: 12px;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
overflow-x: hidden;
}
.page-container {
min-height: 100vh;
display: flex;
flex-direction: column;
max-width: 100vw;
overflow-x: hidden;
}
/* ═══════════════════════════════════════════════════════
NAVBAR - COMPACT
═══════════════════════════════════════════════════════ */
.navbar {
background: white;
padding: 0.75rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 100;
gap: 1rem;
flex-wrap: nowrap;
}
.nav-left {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.nav-logo {
font-size: 1.5rem;
}
.nav-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.nav-title {
font-weight: 700;
font-size: 1.1rem;
color: var(--dark-color);
white-space: nowrap;
}
.nav-subtitle {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--gray-color);
}
.role-badge {
background: var(--primary-color);
color: white;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
}
.nav-right {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.user-name {
font-weight: 600;
color: var(--dark-color);
font-size: 0.9rem;
white-space: nowrap;
}
.logout-button {
background: var(--danger-color);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.4rem;
transition: var(--transition);
font-size: 0.85rem;
white-space: nowrap;
}
.logout-button:hover {
background: #dc2626;
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
/* ═══════════════════════════════════════════════════════
MAIN CONTENT - ADAPTATIF
═══════════════════════════════════════════════════════ */
.main-content {
flex: 1;
padding: 1.25rem;
max-width: 100%;
margin: 0 auto;
width: 100%;
}
.content-header {
margin-bottom: 1.5rem;
}
.content-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* ═══════════════════════════════════════════════════════
SEARCH - COMPACT
═══════════════════════════════════════════════════════ */
.search-section {
background: white;
padding: 1.25rem;
border-radius: var(--border-radius);
margin-bottom: 1.25rem;
box-shadow: var(--shadow-md);
}
.search-wrapper {
position: relative;
max-width: 100%;
}
.search-icon {
position: absolute;
left: 0.9rem;
top: 50%;
transform: translateY(-50%);
font-size: 1.1rem;
color: var(--gray-color);
}
.search-input {
width: 100%;
padding: 0.7rem 2.5rem;
border: 2px solid var(--light-gray);
border-radius: var(--border-radius);
font-size: 0.9rem;
transition: var(--transition);
}
.search-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.search-clear {
position: absolute;
right: 0.9rem;
top: 50%;
transform: translateY(-50%);
background: var(--light-gray);
border: none;
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
color: var(--gray-color);
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
}
.search-clear:hover {
background: var(--danger-color);
color: white;
}
/* ═══════════════════════════════════════════════════════
FILTERS - OPTIMISÉ
═══════════════════════════════════════════════════════ */
.filters-container {
background: white;
padding: 1.25rem;
border-radius: var(--border-radius);
margin-bottom: 1.25rem;
box-shadow: var(--shadow-md);
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.9rem;
margin-bottom: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.filter-label {
font-weight: 600;
color: var(--dark-color);
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.filter-icon {
font-size: 0.9rem;
}
.filter-select {
padding: 0.55rem 0.75rem;
border: 2px solid var(--light-gray);
border-radius: var(--border-radius);
font-size: 0.85rem;
background: white;
cursor: pointer;
transition: var(--transition);
}
.filter-select:focus {
outline: none;
border-color: var(--primary-color);
}
.filter-select:disabled {
background: var(--light-gray);
cursor: not-allowed;
opacity: 0.6;
}
.filter-hint {
color: var(--gray-color);
font-size: 0.7rem;
font-style: italic;
}
.reset-filters-button {
background: var(--gray-color);
color: white;
border: none;
padding: 0.55rem 1rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 0.85rem;
}
.reset-filters-button:hover {
background: var(--dark-color);
}
/* ═══════════════════════════════════════════════════════
TABLE - ULTRA COMPACT
═══════════════════════════════════════════════════════ */
.table-container {
background: white;
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
overflow-x: auto;
max-width: 100%;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 1000px;
}
.data-table thead {
background: var(--primary-color);
color: white;
position: sticky;
top: 0;
z-index: 10;
}
.data-table th {
padding: 0.75rem 0.6rem;
text-align: left;
font-weight: 600;
font-size: 0.8rem;
white-space: nowrap;
}
.data-table th.sortable {
cursor: pointer;
user-select: none;
transition: var(--transition);
}
.data-table th.sortable:hover {
background: var(--primary-dark);
}
.sort-icon {
margin-left: 0.3rem;
opacity: 0.5;
font-size: 0.75rem;
}
.sort-icon.active {
opacity: 1;
}
.data-table td {
padding: 0.75rem 0.6rem;
border-bottom: 1px solid var(--light-gray);
font-size: 0.8rem;
}
.table-row {
transition: var(--transition);
}
.table-row:hover {
background: var(--light-gray);
}
.text-center {
text-align: center;
}
/* ═══════════════════════════════════════════════════════
CELLS - COMPACT
═══════════════════════════════════════════════════════ */
.student-cell {
min-width: 160px;
}
.student-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.student-name {
color: var(--dark-color);
font-weight: 600;
font-size: 0.85rem;
}
.student-id {
color: var(--gray-color);
font-size: 0.7rem;
}
.promo-text {
color: var(--dark-color);
font-weight: 500;
}
.status-badge {
display: inline-block;
padding: 0.3rem 0.6rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
text-align: center;
}
.status-success {
background: #dcfce7;
color: #166534;
}
.status-progress {
background: #fef3c7;
color: #92400e;
}
.status-pending {
background: #fee2e2;
color: #991b1b;
}
.date-cell {
min-width: 100px;
}
.date-text {
color: var(--dark-color);
font-weight: 500;
font-size: 0.8rem;
}
.no-date {
color: var(--gray-color);
}
.commentaire-formateur-cell,
.commentaire-rre-cell {
max-width: 250px;
}
.commentaire-content,
.commentaire-rre-content {
display: flex;
align-items: center;
gap: 0.4rem;
}
.commentaire-icon {
font-size: 0.9rem;
flex-shrink: 0;
}
.commentaire-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
}
.no-comment {
color: var(--gray-color);
text-align: center;
}
.history-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--primary-color);
color: white;
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
font-weight: 600;
font-size: 0.8rem;
}
.actions-cell {
min-width: 100px;
}
.action-buttons {
display: flex;
gap: 0.4rem;
justify-content: center;
}
.action-btn {
padding: 0.4rem;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 1.1rem;
transition: var(--transition);
background: var(--light-gray);
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.action-view:hover {
background: var(--primary-color);
}
.action-export:hover {
background: var(--success-color);
}
/* ═══════════════════════════════════════════════════════
LOADING & EMPTY
═══════════════════════════════════════════════════════ */
.loading-state {
text-align: center;
padding: 3rem 1.5rem;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid var(--light-gray);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: var(--gray-color);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
/* ═══════════════════════════════════════════════════════
MODAL - RESPONSIVE
═══════════════════════════════════════════════════════ */
.modal-header {
padding: 1.2rem 1.5rem;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 20px 20px 0 0;
}
.modal-header h2 {
font-size: 1.3rem;
font-weight: 700;
}
.modal-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 1.8rem;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
.modal-student-info {
padding: 1.2rem 1.5rem;
background: var(--light-gray);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.9rem;
border-bottom: 2px solid #e2e8f0;
}
.info-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.info-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--gray-color);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 0.9rem;
font-weight: 600;
color: var(--dark-color);
}
.column-section-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--dark-color);
margin-bottom: 1.2rem;
display: flex;
align-items: center;
gap: 0.6rem;
padding-bottom: 0.6rem;
border-bottom: 2px solid var(--light-gray);
}
.column-section-title span:first-child {
font-size: 1.3rem;
}
.history-badge {
background: var(--primary-color);
color: white;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
}
.no-history-message {
text-align: center;
padding: 2.5rem 1rem;
color: var(--gray-color);
}
.no-history-icon {
font-size: 3.5rem;
margin-bottom: 1rem;
}
/* ═══════════════════════════════════════════════════════
STATUS & FORM
═══════════════════════════════════════════════════════ */
.current-status {
background: white;
padding: 1.2rem;
border-radius: var(--border-radius);
margin-bottom: 1.2rem;
border: 2px solid var(--light-gray);
}
.current-status h3 {
font-size: 1rem;
font-weight: 700;
color: var(--dark-color);
margin-bottom: 0.9rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.9rem;
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.status-item-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--gray-color);
text-transform: uppercase;
}
.status-item-value {
font-size: 0.8rem;
font-weight: 600;
color: var(--dark-color);
}
.action-form {
background: white;
padding: 1.2rem;
border-radius: var(--border-radius);
border: 2px solid var(--light-gray);
}
.form-grid-2col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.9rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-group-full {
grid-column: 1 / -1;
}
.form-label {
font-weight: 600;
color: var(--dark-color);
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.label-icon {
font-size: 0.9rem;
}
.required-star {
color: var(--danger-color);
margin-left: 0.2rem;
}
.form-select,
.form-input {
padding: 0.65rem;
border: 2px solid var(--light-gray);
border-radius: var(--border-radius);
font-size: 0.8rem;
transition: var(--transition);
}
.form-select:focus,
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.form-textarea {
padding: 0.65rem;
border: 2px solid var(--light-gray);
border-radius: var(--border-radius);
font-size: 0.8rem;
font-family: inherit;
resize: vertical;
transition: var(--transition);
min-height: 80px;
}
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.form-textarea-highlight {
border-color: var(--warning-color);
background: #fffbeb;
}
.form-info-note {
grid-column: 1 / -1;
background: #dbeafe;
color: #1e40af;
padding: 0.65rem 0.9rem;
border-radius: var(--border-radius);
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.rre-readonly-info {
background: var(--light-gray);
padding: 0.9rem;
border-radius: var(--border-radius);
margin-bottom: 1.2rem;
border-left: 4px solid var(--primary-color);
}
.rre-readonly-info h4 {
font-size: 0.9rem;
font-weight: 700;
color: var(--dark-color);
margin-bottom: 0.6rem;
}
.rre-readonly-info p {
font-size: 0.8rem;
color: var(--dark-color);
margin-bottom: 0.4rem;
}
/* ═══════════════════════════════════════════════════════
FOOTER
═══════════════════════════════════════════════════════ */
.modal-footer {
padding: 1.2rem 1.5rem;
background: var(--light-gray);
display: flex;
justify-content: flex-end;
gap: 0.9rem;
border-radius: 0 0 20px 20px;
flex-wrap: wrap;
}
.secondary-button {
background: var(--gray-color);
color: white;
border: none;
padding: 0.65rem 1.2rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
font-size: 0.85rem;
}
.secondary-button:hover {
background: var(--dark-color);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.btn-add-action {
background: var(--success-color);
color: white;
border: none;
padding: 0.65rem 1.2rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
font-size: 0.85rem;
}
.btn-add-action:hover:not(:disabled) {
background: #16a34a;
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.btn-add-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ═══════════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════════ */
.table-container::-webkit-scrollbar {
height: 6px;
}
.table-container::-webkit-scrollbar-track {
background: var(--light-gray);
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: var(--primary-dark);
}
/* ═══════════════════════════════════════════════════════
RESPONSIVE - MOBILE
═══════════════════════════════════════════════════════ */
@media (max-width: 768px) {
html {
font-size: 14px;
}
.navbar {
flex-direction: column;
align-items: flex-start;
padding: 0.9rem;
}
.nav-right {
width: 100%;
justify-content: space-between;
}
.filters-grid {
grid-template-columns: 1fr;
}
.data-table {
min-width: 800px;
}
div[style*="grid-template-columns: 1fr 1fr"] {
grid-template-columns: 1fr !important;
}
}
/* Ajoutez ceci dans la constante styles */
.table-container {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: auto; /* ✅ Changé de overflow-x à overflow */
max-width: 100%;
}
/* Scrollbar verticale */
.table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-container::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb {
background: #0ea5e9;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: #0284c7;
}
/* Corner pour les deux scrollbars */
.table-container::-webkit-scrollbar-corner {
background: #f1f5f9;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
import React from 'react';
import type { HistoryEntry } from './types';
import { formatDateTime } from './types';
interface HistoryTimelineProps {
history: HistoryEntry[];
}
const HistoryTimeline: React.FC<HistoryTimelineProps> = ({ history }) => {
// Trier par date décroissante (plus récent en premier)
const sortedHistory = [...history].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
if (sortedHistory.length === 0) {
return (
<div className="history-empty">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="20" stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4 4" />
<path d="M24 16v8l6 3" stroke="#cbd5e1" strokeWidth="2" strokeLinecap="round" />
</svg>
<p>Aucun historique disponible</p>
</div>
);
}
return (
<div className="history-timeline">
{sortedHistory.map((entry, index) => {
const isLatest = index === 0;
const isCreation = entry.action === 'creation';
return (
<div
key={entry.id}
className={`timeline-entry ${isLatest ? 'latest' : ''} ${isCreation ? 'creation' : 'modification'}`}
>
<div className="timeline-connector">
<div className={`timeline-dot ${isCreation ? 'dot-creation' : 'dot-modification'}`}>
{isCreation ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 3v8M3 7h8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 4L5 9 3 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
{index < sortedHistory.length - 1 && <div className="timeline-line" />}
</div>
<div className="timeline-content">
<div className="timeline-header-simple">
<div className="timeline-action">
<span className={`action-badge ${isCreation ? 'badge-creation' : 'badge-modification'}`}>
{isCreation ? '🎯 Création' : '✏️ Nouvelle Action'}
</span>
{isLatest && <span className="latest-badge"> Actuel</span>}
</div>
<div className="timeline-meta">
<span className="timeline-date">
📅 {formatDateTime(entry.timestamp)}
</span>
<span className="timeline-user">
👤 {entry.userName}
<span className={`role-tag ${entry.userRole}`}>
{entry.userRole === 'formateur' ? 'Formateur' : 'RRE'}
</span>
</span>
</div>
</div>
<div className="timeline-reason">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 4h12M2 8h8M2 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<span className="reason-text">{entry.reason}</span>
</div>
{/* Afficher les anciennes valeurs uniquement */}
{entry.changes.length > 0 && (
<div className="timeline-old-values">
{entry.changes.map((change, changeIndex) => (
<div key={changeIndex} className="old-value-item">
<span className="field-name">{change.fieldLabel}:</span>
<span className="field-old-value">{change.oldValue}</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export default HistoryTimeline;

View File

@@ -0,0 +1,386 @@
// LoginPage.tsx
import React, { useState, useEffect } from 'react';
import { msalInstance, loginRequest } from './msalConfig';
import type { User } from './types';
interface LoginPageProps {
onLogin: (user: User) => void;
}
const LoginPage: React.FC<LoginPageProps> = ({ onLogin }) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Gérer le callback après redirection Microsoft
msalInstance.handleRedirectPromise()
.then(async (response) => {
if (response) {
console.log('✅ Authentification réussie');
await handleAuthSuccess(response.account.username);
}
})
.catch((error) => {
console.error('❌ Erreur auth:', error);
setError('Erreur lors de l\'authentification');
});
}, []);
const handleAuthSuccess = async (email: string) => {
setIsLoading(true);
setError(null);
try {
// Vérifier l'utilisateur dans la base de données via le backend
const response = await fetch('/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok && data.valid) {
localStorage.setItem('user', JSON.stringify(data.user));
onLogin(data.user);
} else {
setError('Utilisateur non autorisé. Contactez l\'administrateur.');
}
} catch (err) {
setError('Erreur de connexion au serveur');
console.error(err);
} finally {
setIsLoading(false);
}
};
const handleMicrosoftLogin = async () => {
setIsLoading(true);
setError(null);
try {
await msalInstance.loginRedirect(loginRequest);
} catch (err: any) {
setError(err.message || 'Erreur lors de la connexion');
setIsLoading(false);
console.error(err);
}
};
return (
<div style={styles.container}>
{/* Panneau gauche - Branding */}
<div style={styles.leftPanel}>
<div style={styles.brandContent}>
<div style={styles.logoContainer}>
<svg width="72" height="72" viewBox="0 0 72 72" fill="none">
<rect width="72" height="72" rx="16" fill="rgba(255,255,255,0.15)" />
<path
d="M22 36L32 46L50 28"
stroke="#fff"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h1 style={styles.brandTitle}>Suivi Évaluations</h1>
<p style={styles.brandSubtitle}>Plateforme de Gestion des Formations</p>
<div style={styles.features}>
{[
'Suivi en temps réel des évaluations',
'Gestion centralisée des apprenants',
'Collaboration formateurs et RRE'
].map((feature, index) => (
<div key={index} style={styles.featureItem}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<circle cx="11" cy="11" r="10" stroke="#fff" strokeWidth="1.5" opacity="0.8" />
<path d="M7 11l3 3 5-5" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>{feature}</span>
</div>
))}
</div>
</div>
</div>
{/* Panneau droit - Connexion */}
<div style={styles.rightPanel}>
<div style={styles.loginCard}>
<div style={styles.cardHeader}>
<h2 style={styles.cardTitle}>Bienvenue</h2>
<p style={styles.cardSubtitle}>
Connectez-vous avec votre compte Microsoft 365 professionnel
</p>
</div>
<div style={styles.cardBody}>
{error && (
<div style={styles.errorAlert}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#ef4444" strokeWidth="2" />
<path d="M10 6v4M10 14h.01" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" />
</svg>
<span>{error}</span>
</div>
)}
<div style={styles.microsoftIconWrapper}>
<svg width="80" height="80" viewBox="0 0 23 23" fill="none">
<rect x="1" y="1" width="10" height="10" fill="#f25022" />
<rect x="12" y="1" width="10" height="10" fill="#7fba00" />
<rect x="1" y="12" width="10" height="10" fill="#00a4ef" />
<rect x="12" y="12" width="10" height="10" fill="#ffb900" />
</svg>
</div>
<button
onClick={handleMicrosoftLogin}
disabled={isLoading}
style={{
...styles.microsoftButton,
...(isLoading ? styles.microsoftButtonLoading : {})
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0, 120, 212, 0.35)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0, 120, 212, 0.25)';
}}
>
{isLoading ? (
<>
<div style={styles.spinner} />
<span>Connexion en cours...</span>
</>
) : (
<>
<svg width="20" height="20" viewBox="0 0 23 23" fill="none">
<rect x="1" y="1" width="10" height="10" fill="#f25022" />
<rect x="12" y="1" width="10" height="10" fill="#7fba00" />
<rect x="1" y="12" width="10" height="10" fill="#00a4ef" />
<rect x="12" y="12" width="10" height="10" fill="#ffb900" />
</svg>
<span>Se connecter avec Microsoft 365</span>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<path
d="M5 10h10M12 7l3 3-3 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</>
)}
</button>
<p style={styles.securityNote}>
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" style={{ flexShrink: 0 }}>
<path
d="M10 2L3 6v4c0 4.5 3 8.5 7 10 4-1.5 7-5.5 7-10V6l-7-4z"
stroke="#22c55e"
strokeWidth="1.5"
fill="none"
/>
<path
d="M7 10l2 2 4-4"
stroke="#22c55e"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Connexion sécurisée via authentification Microsoft</span>
</p>
</div>
<div style={styles.cardFooter}>
<p style={styles.footerText}>
Besoin d'aide ? <a href="#" style={styles.footerLink}>Contactez le support technique</a>
</p>
</div>
</div>
</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
};
const styles: { [key: string]: React.CSSProperties } = {
container: {
display: 'flex',
minHeight: '100vh',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
leftPanel: {
flex: '1',
background: 'linear-gradient(135deg, #0078d4 0%, #005a9e 50%, #004578 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3rem',
position: 'relative',
overflow: 'hidden',
},
brandContent: {
color: '#fff',
maxWidth: '420px',
zIndex: 1,
},
logoContainer: {
marginBottom: '2rem',
},
brandTitle: {
fontSize: '2.5rem',
fontWeight: 700,
marginBottom: '0.75rem',
letterSpacing: '-0.02em',
},
brandSubtitle: {
fontSize: '1.1rem',
opacity: 0.9,
marginBottom: '3rem',
fontWeight: 400,
},
features: {
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
},
featureItem: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
fontSize: '1rem',
opacity: 0.95,
},
rightPanel: {
flex: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3rem',
backgroundColor: '#f8fafc',
},
loginCard: {
backgroundColor: '#fff',
borderRadius: '20px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.1)',
padding: '3rem',
width: '100%',
maxWidth: '440px',
animation: 'fadeIn 0.5s ease-out',
},
cardHeader: {
textAlign: 'center',
marginBottom: '2.5rem',
},
cardTitle: {
fontSize: '2rem',
fontWeight: 700,
color: '#1e293b',
marginBottom: '0.75rem',
},
cardSubtitle: {
fontSize: '1rem',
color: '#64748b',
lineHeight: 1.6,
},
cardBody: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem',
},
errorAlert: {
display: 'flex',
alignItems: 'center',
gap: '10px',
width: '100%',
padding: '12px 16px',
backgroundColor: '#fee2e2',
border: '1px solid #fecaca',
borderRadius: '8px',
color: '#991b1b',
fontSize: '0.875rem',
},
microsoftIconWrapper: {
padding: '1.5rem',
backgroundColor: '#f1f5f9',
borderRadius: '16px',
marginBottom: '0.5rem',
},
microsoftButton: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
width: '100%',
padding: '16px 24px',
fontSize: '1rem',
fontWeight: 600,
color: '#fff',
backgroundColor: '#0078d4',
border: 'none',
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 4px 15px rgba(0, 120, 212, 0.25)',
},
microsoftButtonLoading: {
opacity: 0.8,
cursor: 'not-allowed',
},
spinner: {
width: '22px',
height: '22px',
border: '3px solid rgba(255,255,255,0.3)',
borderTopColor: '#fff',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
},
securityNote: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
color: '#64748b',
marginTop: '0.5rem',
},
cardFooter: {
marginTop: '2.5rem',
paddingTop: '1.5rem',
borderTop: '1px solid #e2e8f0',
textAlign: 'center',
},
footerText: {
fontSize: '0.875rem',
color: '#64748b',
},
footerLink: {
color: '#0078d4',
textDecoration: 'none',
fontWeight: 500,
},
};
export default LoginPage;

View File

@@ -0,0 +1,53 @@
// ProtectedRoute.tsx
import React from 'react';
import { useAuth } from './AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'formateur' | 'rre';
requiredPermission?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredRole,
requiredPermission
}) => {
const { user, hasPermission } = useAuth();
if (!user) {
return <div>Accès non autorisé. Veuillez vous connecter.</div>;
}
if (requiredRole && user.role !== requiredRole) {
return (
<div style={styles.accessDenied}>
<h2>Accès refusé</h2>
<p>Vous n'avez pas les permissions nécessaires pour accéder à cette page.</p>
<p>Rôle requis: {requiredRole}</p>
<p>Votre rôle: {user.role}</p>
</div>
);
}
if (requiredPermission && !hasPermission(requiredPermission as any)) {
return (
<div style={styles.accessDenied}>
<h2>Accès refusé</h2>
<p>Vous n'avez pas la permission requise pour cette action.</p>
</div>
);
}
return <>{children}</>;
};
const styles = {
accessDenied: {
padding: '2rem',
textAlign: 'center' as const,
backgroundColor: '#fee2e2',
borderRadius: '8px',
margin: '2rem',
}
};

View File

@@ -0,0 +1,352 @@
// SaisiePage.tsx - Mise à jour avec user.id
import React, { useState } from 'react';
import type { User, EvaluationRecord, HistoryEntry, FieldChange } from './types';
import { generateId } from './types';
import './styles.css';
interface SaisiePageProps {
onSave: (record: EvaluationRecord) => void;
onCancel: () => void;
user: User;
}
const SaisiePage: React.FC<SaisiePageProps> = ({ onSave, onCancel, user }) => {
const [formData, setFormData] = useState({
nom: '',
prenom: '',
formation: '',
autoEvaluation: '',
dateAction: '',
commentaireFormateur: '',
evaluationTuteur: '',
dateActionTuteur: '',
commentaireRRE: ''
});
const [creationReason, setCreationReason] = useState('');
const [reasonError, setReasonError] = useState(false);
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleApprenantChange = (value: string) => {
if (value) {
const [nom, prenom, formation] = value.split('|');
setFormData(prev => ({ ...prev, nom, prenom, formation }));
} else {
setFormData(prev => ({ ...prev, nom: '', prenom: '', formation: '' }));
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Vérifier la raison
if (!creationReason.trim()) {
setReasonError(true);
return;
}
const now = new Date().toISOString();
const recordId = generateId();
// Créer l'entrée d'historique initiale
const initialHistory: HistoryEntry = {
id: generateId(),
timestamp: now,
userId: user.id || user.email,
userName: user.nomComplet || user.name,
userRole: user.role as 'formateur' | 'rre',
action: 'creation',
reason: creationReason.trim(),
changes: []
};
// Créer l'enregistrement complet
const newRecord: EvaluationRecord = {
...formData,
id: recordId,
campus: user.campus || 'CGY',
createdAt: now,
createdBy: user.id || user.email,
createdByName: user.nomComplet || user.name,
history: [initialHistory],
autoEvaluation: formData.autoEvaluation as 'OUI' | 'NON' | 'EN COURS',
evaluationTuteur: formData.evaluationTuteur as 'OUI' | 'NON' | 'EN COURS'
};
onSave(newRecord);
};
const formations = ['BTS NDRC 1 JV', 'BTS MCO 1', 'BTS GPME 2', 'Bachelor RH', 'Master Management'];
// Liste d'apprenants (normalement cela viendrait d'une API/base de données)
const apprenants = [
{ nom: 'Dupont', prenom: 'Marie', formation: 'BTS NDRC 1 JV' },
{ nom: 'Martin', prenom: 'Pierre', formation: 'BTS MCO 1' },
{ nom: 'Bernard', prenom: 'Sophie', formation: 'BTS GPME 2' },
{ nom: 'Dubois', prenom: 'Lucas', formation: 'Bachelor RH' },
{ nom: 'Thomas', prenom: 'Emma', formation: 'Master Management' }
];
const isFormateur = user.role === 'formateur';
const isRRE = user.role === 'rre';
return (
<div className="page-container">
<nav className="navbar">
<div className="nav-left">
<button onClick={onCancel} className="back-button">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M15 10H5M8 13l-3-3 3-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="nav-info">
<span className="nav-title">Nouvelle Évaluation</span>
<span className="nav-subtitle">Saisie complète</span>
</div>
</div>
<div className="nav-right">
<div className="user-info">
<div className="user-text">
<span className="user-name">{user.nomComplet || user.name}</span>
<span className="user-role">
{isFormateur ? 'Formateur Référent' : 'Responsable RRE'}
</span>
</div>
<div className="user-avatar">{user.name[0].toUpperCase()}</div>
</div>
</div>
</nav>
<main className="form-main">
<form onSubmit={handleSubmit} className="form-container">
{/* Section Apprenant */}
<div className="form-section">
<div className="section-header">
<div className="section-icon student">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" />
<path d="M4 20c0-4 3.58-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<div>
<h2 className="section-title">Informations Apprenant</h2>
<p className="section-subtitle">Identification et formation</p>
</div>
</div>
<div className="form-grid">
<div className="form-field full-width">
<label className="field-label">Sélectionner un apprenant *</label>
<select
value={formData.nom && formData.prenom ? `${formData.nom}|${formData.prenom}|${formData.formation}` : ''}
onChange={(e) => handleApprenantChange(e.target.value)}
className="field-select"
required
>
<option value="">Choisir un apprenant...</option>
{apprenants.map((app, idx) => (
<option key={idx} value={`${app.nom}|${app.prenom}|${app.formation}`}>
{app.prenom} {app.nom} - {app.formation}
</option>
))}
</select>
</div>
{formData.nom && (
<div className="form-field full-width">
<div className="selected-info">
<div className="info-row">
<span className="info-label">Nom:</span>
<span className="info-value">{formData.nom}</span>
</div>
<div className="info-row">
<span className="info-label">Prénom:</span>
<span className="info-value">{formData.prenom}</span>
</div>
<div className="info-row">
<span className="info-label">Formation:</span>
<span className="info-value">{formData.formation}</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Section Formateur */}
<div className={`form-section ${isRRE ? 'disabled-section' : ''}`}>
<div className="section-header">
<div className="section-icon formateur">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
<path d="M8 10h8M8 14h5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<div>
<h2 className="section-title">Saisie Formateur Référent</h2>
<p className="section-subtitle">Suivi et commentaires</p>
{isRRE && <span className="disabled-badge">Section réservée au formateur</span>}
</div>
</div>
<div className="form-grid">
<div className="form-field">
<label className="field-label">Auto-évaluation du jeune</label>
<div className="radio-group">
{['OUI', 'NON', 'EN COURS'].map(opt => (
<label key={opt} className="radio-label">
<input
type="radio"
name="autoEvaluation"
value={opt}
checked={formData.autoEvaluation === opt}
onChange={(e) => handleChange('autoEvaluation', e.target.value)}
className="radio-input"
disabled={isRRE}
/>
<span className={`radio-custom ${formData.autoEvaluation === opt ? 'active' : ''} ${isRRE ? 'disabled' : ''}`}>
{opt}
</span>
</label>
))}
</div>
</div>
<div className="form-field">
<label className="field-label">Date d'action</label>
<input
type="date"
value={formData.dateAction}
onChange={(e) => handleChange('dateAction', e.target.value)}
className="field-input"
disabled={isRRE}
/>
</div>
<div className="form-field full-width">
<label className="field-label">Commentaire du formateur</label>
<textarea
value={formData.commentaireFormateur}
onChange={(e) => handleChange('commentaireFormateur', e.target.value)}
placeholder={isRRE ? "Réservé au formateur" : "Ajoutez vos observations..."}
className="field-textarea"
rows={4}
disabled={isRRE}
/>
</div>
</div>
</div>
{/* Section RRE */}
<div className={`form-section ${isFormateur ? 'disabled-section' : ''}`}>
<div className="section-header">
<div className="section-icon rre">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M9 12l2 2 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<div>
<h2 className="section-title">Saisie Responsable RRE</h2>
<p className="section-subtitle">Validation tuteur</p>
{isFormateur && <span className="disabled-badge">Section réservée au RRE</span>}
</div>
</div>
<div className="form-grid">
<div className="form-field">
<label className="field-label">Évaluation par le tuteur</label>
<div className="radio-group">
{['OUI', 'NON', 'EN COURS'].map(opt => (
<label key={opt} className="radio-label">
<input
type="radio"
name="evaluationTuteur"
value={opt}
checked={formData.evaluationTuteur === opt}
onChange={(e) => handleChange('evaluationTuteur', e.target.value)}
className="radio-input"
disabled={isFormateur}
/>
<span className={`radio-custom ${formData.evaluationTuteur === opt ? 'active' : ''} ${isFormateur ? 'disabled' : ''}`}>
{opt}
</span>
</label>
))}
</div>
</div>
<div className="form-field">
<label className="field-label">Date d'action</label>
<input
type="date"
value={formData.dateActionTuteur}
onChange={(e) => handleChange('dateActionTuteur', e.target.value)}
className="field-input"
disabled={isFormateur}
/>
</div>
<div className="form-field full-width">
<label className="field-label">Commentaire RRE</label>
<textarea
value={formData.commentaireRRE}
onChange={(e) => handleChange('commentaireRRE', e.target.value)}
placeholder={isFormateur ? "Réservé au RRE" : "Ajoutez vos observations..."}
className="field-textarea"
rows={4}
disabled={isFormateur}
/>
</div>
</div>
</div>
{/* Raison de création */}
<div className="detail-section reason-section">
<h3 className="detail-section-title required-title">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M9 2v8M9 14h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
Contexte de création
<span className="required-asterisk">*</span>
</h3>
<div className="form-field full-width">
<textarea
value={creationReason}
onChange={(e) => {
setCreationReason(e.target.value);
if (e.target.value.trim()) setReasonError(false);
}}
className={`field-textarea reason-textarea ${reasonError ? 'error' : ''}`}
rows={3}
placeholder="Décrivez le contexte de cette création (obligatoire pour l'audit)..."
/>
{reasonError && (
<div className="field-error">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 5v3M8 11h.01" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Le contexte de création est obligatoire pour la traçabilité
</div>
)}
</div>
</div>
<div className="form-actions">
<button type="button" onClick={onCancel} className="cancel-button">
Annuler
</button>
<button type="submit" className="submit-button">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M16 5L7 14 3 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Enregistrer l'évaluation
</button>
</div>
</form>
</main>
</div>
);
};
export default SaisiePage;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,5 @@
export type { User, EvaluationRecord } from './types';
export { default as LoginPage } from './LoginPage';
export { default as HistoriquePage } from './HistoriquePage';
export { default as SaisiePage } from './SaisiePage';
export { default as App } from './App';

View File

@@ -0,0 +1,47 @@
/// <reference types="vite/client" />
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './Index.css';
import { msalInstance } from './msalConfig';
// Fonction d'initialisation
const initializeApp = async () => {
try {
// Initialiser MSAL
await msalInstance.initialize();
console.log('✓ MSAL initialisé');
// Gérer la redirection après login
await msalInstance.handleRedirectPromise();
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
} catch (error) {
console.error('❌ Erreur lors de l\'initialisation:', error);
// Fallback: monter l'app même en cas d'erreur
const rootElement = document.getElementById('root');
if (rootElement) {
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
}
};
// Lancer l'initialisation
initializeApp();

View File

@@ -0,0 +1,45 @@
// msalConfig.ts
import { PublicClientApplication, LogLevel } from '@azure/msal-browser';
export const msalConfig = {
auth: {
clientId: '1225bb0a-37ec-4b82-be22-bb1f4a1e0231',
authority: 'https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9',
redirectUri: window.location.origin,
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false,
},
system: {
loggerOptions: {
loggerCallback: (level: LogLevel, message: string, containsPii: boolean) => {
if (containsPii) return;
switch (level) {
case LogLevel.Error:
console.error(message);
return;
case LogLevel.Info:
console.info(message);
return;
case LogLevel.Verbose:
console.debug(message);
return;
case LogLevel.Warning:
console.warn(message);
return;
}
}
}
}
};
export const loginRequest = {
scopes: ['User.Read']
};
// Créer l'instance (ne pas l'initialiser ici)
export const msalInstance = new PublicClientApplication(msalConfig);
// ❌ SUPPRIMER cette ligne - l'initialisation se fait dans main.tsx
// await msalInstance.initialize();

View File

@@ -0,0 +1,408 @@
/* ===== STYLES DE BASE ===== */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
color: #1e293b;
}
/* ===== CONTENEUR PRINCIPAL ===== */
.page-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===== NAVBAR ===== */
.navbar {
background: white;
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.nav-left {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-logo {
display: flex;
align-items: center;
}
.nav-info {
display: flex;
flex-direction: column;
}
.nav-title {
font-weight: 700;
font-size: 1.125rem;
color: #0f172a;
}
.nav-subtitle {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.nav-right {
display: flex;
align-items: center;
gap: 1.5rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-text {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.user-name {
font-weight: 600;
font-size: 0.875rem;
color: #0f172a;
}
.user-role {
font-size: 0.75rem;
color: #64748b;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #0ea5e9, #06b6d4);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 1rem;
}
.logout-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
color: #64748b;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.logout-button:hover {
border-color: #ef4444;
color: #ef4444;
background: #fef2f2;
}
/* ===== CONTENU PRINCIPAL ===== */
.main-content {
flex: 1;
padding: 2rem;
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
}
.page-subtitle {
display: flex;
align-items: center;
gap: 0.5rem;
color: #64748b;
font-size: 0.875rem;
}
.count-badge {
background: linear-gradient(135deg, #0ea5e9, #06b6d4);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-weight: 600;
font-size: 0.75rem;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
.primary-button,
.secondary-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 10px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.primary-button {
background: linear-gradient(135deg, #0ea5e9, #06b6d4);
color: white;
border: none;
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
}
.primary-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.4);
}
.secondary-button {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
}
.secondary-button:hover {
border-color: #0ea5e9;
color: #0ea5e9;
}
/* ===== SECTION RECHERCHE ===== */
.search-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.search-bar {
position: relative;
max-width: 400px;
}
.search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
}
.search-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 0.875rem;
background: white;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.filter-chips {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.chip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 9999px;
background: white;
color: #64748b;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.chip:hover {
border-color: #0ea5e9;
color: #0ea5e9;
}
.chip.active {
background: linear-gradient(135deg, #0ea5e9, #06b6d4);
color: white;
border-color: transparent;
}
.chip-count {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.1);
}
.chip.active .chip-count {
background: rgba(255, 255, 255, 0.2);
}
/* ===== TABLEAU ===== */
.table-container {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table thead {
background: #f8fafc;
}
.table th {
padding: 1rem;
text-align: left;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
}
.table td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.875rem;
}
.table tbody tr:hover {
background: #f8fafc;
}
.th-actions {
text-align: center;
}
.name-cell {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, #e0f2fe, #cffafe);
display: flex;
align-items: center;
justify-content: center;
color: #0ea5e9;
font-weight: 600;
font-size: 0.75rem;
}
.name-primary {
font-weight: 600;
color: #0f172a;
}
.name-secondary {
font-size: 0.75rem;
color: #94a3b8;
}
.formation-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #f1f5f9;
border-radius: 6px;
font-size: 0.8125rem;
color: #475569;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.success {
background: #dcfce7;
color: #16a34a;
}
.status-badge.error {
background: #fee2e2;
color: #dc2626;
}
.status-badge.warning {
background: #fef3c7;
color: #d97706;
}
.date-cell {
color: #64748b;
font-size: 0.8125rem;
}
.comment-text {
display: block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #64748b;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,177 @@
// types.ts - Types pour le système de suivi des évaluations avec audit complet et authentification
// Interface User étendue avec authentification Microsoft
export interface User {
id: string;
email: string;
name: string;
nomComplet: string;
campus: string | null;
role: 'formateur' | 'rre' | 'super'|'lecteur'; // ✅ Changé 'admin' en 'super'
permissions?: {
peut_valider_evaluations?: boolean;
peut_gerer_formateurs?: boolean;
peut_saisir_evaluations?: boolean;
peut_voir_tous_campus?: boolean;
peut_exporter_donnees?: boolean;
peut_usurper_identite?: boolean;
};
}
// Permissions utilisateur
export interface UserPermissions {
peut_valider_evaluations: boolean;
peut_gerer_formateurs: boolean;
peut_saisir_evaluations: boolean;
peut_voir_tous_campus: boolean;
peut_exporter_donnees: boolean;
}
// Entrée d'historique pour l'audit complet
export interface HistoryEntry {
id: string;
timestamp: string;
userId: string;
userName: string;
userRole: 'formateur' | 'rre' | 'super' | 'lecteur'; // ✅ Ajout de 'super'
action: 'creation' | 'Nouvelle Action'; // ✅ Changé 'modification' en 'Nouvelle Action'
reason: string;
changes: FieldChange[];
}
// Détail d'un changement de champ
export interface FieldChange {
field: keyof EvaluationRecord;
fieldLabel: string;
oldValue: string;
newValue: string;
}
// Enregistrement d'évaluation avec historique
export interface EvaluationRecord {
id: string;
nom: string;
prenom: string;
email: string;
campus: string;
campusPromo?: string;
formation: string;
nomDip?: string;
annee?: string;
autoEvaluation: string;
evaluationTuteur: string;
dateAction?: string;
dateActionRRE?: string;
commentaireFormateur?: string;
dateEvalTuteur?: string;
commentaireRRE?: string;
commentaire?: string;
lastUpdate?: string;
formateurEmail?: string;
history?: HistoryEntry[];
}
// Labels français pour les champs
export const FIELD_LABELS: Record<string, string> = {
auto_evaluation: 'Auto-évaluation',
evaluation_tuteur: 'Évaluation tuteur',
date_action: 'Date d\'action formateur',
date_action_rre: 'Date d\'action RRE',
commentaire_formateur: 'Commentaire formateur',
date_eval_tuteur: 'Date évaluation tuteur',
commentaire_rre: 'Commentaire RRE',
commentaire: 'Commentaire général',
autoEvaluation: 'Auto-évaluation',
evaluationTuteur: 'Évaluation tuteur',
dateAction: 'Date d\'action formateur',
dateActionRRE: 'Date d\'action RRE',
commentaireFormateur: 'Commentaire formateur',
dateEvalTuteur: 'Date évaluation tuteur',
commentaireRRE: 'Commentaire RRE'
};
// Fonction utilitaire pour générer un ID unique
export const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
// Fonction pour formater une date ISO en format lisible (avec protection)
export const formatDateTime = (isoString: string | null | undefined): string => {
// Protection contre les valeurs nulles ou undefined
if (!isoString) {
return 'Date non disponible';
}
try {
const date = new Date(isoString);
// Vérifier si la date est valide
if (isNaN(date.getTime())) {
console.warn('Date invalide reçue:', isoString);
return 'Date invalide';
}
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
} catch (error) {
console.error('Erreur formatage date:', error, 'Valeur:', isoString);
return 'Date invalide';
}
};
// Fonction pour formater une date courte (avec protection)
export const formatDateShort = (isoString: string | null | undefined): string => {
// Protection contre les valeurs nulles ou undefined
if (!isoString) {
return 'Date non disponible';
}
try {
const date = new Date(isoString);
// Vérifier si la date est valide
if (isNaN(date.getTime())) {
console.warn('Date invalide reçue:', isoString);
return 'Date invalide';
}
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
} catch (error) {
console.error('Erreur formatage date:', error, 'Valeur:', isoString);
return 'Date invalide';
}
};
// Fonction pour calculer les différences entre deux versions
export const calculateChanges = (
oldRecord: Partial<EvaluationRecord>,
newRecord: Partial<EvaluationRecord>,
fieldsToCompare: (keyof EvaluationRecord)[]
): FieldChange[] => {
const changes: FieldChange[] = [];
for (const field of fieldsToCompare) {
const oldValue = String(oldRecord[field] ?? '');
const newValue = String(newRecord[field] ?? '');
if (oldValue !== newValue) {
changes.push({
field,
fieldLabel: FIELD_LABELS[field] || field,
oldValue: oldValue || '(vide)',
newValue: newValue || '(vide)',
});
}
}
return changes;
};

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/1.0.2752196">
<PropertyGroup>
<StartupCommand>npm run dev</StartupCommand>
<JavaScriptTestRoot>src\</JavaScriptTestRoot>
<JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
<!-- Allows the build (or compile) script located on package.json to run on Build -->
<ShouldRunBuildScript>false</ShouldRunBuildScript>
<!-- Folder where production build objects will be placed -->
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>LaunchJsonDebugger</DebuggerFlavor>
<LaunchJsonTarget>{
"name": "localhost (Chrome)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:61628",
"webRoot": "C:\\Users\\oimer\\source\\repos\\SuiviREForamteur\\SuiviREForamteur"
}</LaunchJsonTarget>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [ "ES2022", "DOM", "DOM.Iterable" ],
"module": "ESNext",
"types": [ "vite/client" ],
"skipLibCheck": true,
/* Ajoutez ces lignes pour React */
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [ "src", "vite.config.js" ]
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [ "ES2020", "DOM", "DOM.Iterable" ],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [ "src" ],
"references": [ { "path": "./tsconfig.node.json" } ]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [ "vite.config.ts" ]
}

View File

@@ -0,0 +1,45 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 81,
strictPort: true,
allowedHosts: [
'myrgc.ensup-adm.net',
'localhost',
'.ensup-adm.net'
],
watch: {
usePolling: true
},
proxy: {
'/api': {
target: 'http://backend:3005',
changeOrigin: true,
secure: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
console.log('🔀 Proxy /api:', req.method, req.url);
});
}
},
'/auth': {
target: 'http://backend:3005',
changeOrigin: true,
secure: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
console.log('🔀 Proxy /auth:', req.method, req.url);
});
}
}
}
},
build: {
outDir: 'dist',
sourcemap: true
}
});