Ajoutez des fichiers projet.

This commit is contained in:
2026-04-30 12:51:31 +02:00
parent d17cbe4df0
commit 72f4beeb61
57 changed files with 26895 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+52
View File
@@ -0,0 +1,52 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import Dashboard from './pages/Dashboard';
import AuthCallback from './pages/AuthCallback';
import PrivateRoute from './components/PrivateRoute';
import UploadMobile from './pages/UploadMobile';
import RoleSelector from './components/RoleSelector';
import Login from './pages/Login';
// ── Garde intermédiaire pour la sélection de rôle ─────
const AppRoutes = (): JSX.Element => {
const { user, needsRoleSelection, selectRole, allRoles } = useAuth();
if (user && needsRoleSelection) {
return (
<RoleSelector
roles={allRoles}
userName={user.name}
onSelect={selectRole}
/>
);
}
return (
<Router>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<Login />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/dashboard" element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
} />
<Route path="/upload/:token" element={<UploadMobile />} />
</Routes>
</Router>
);
};
function App(): JSX.Element {
return (
<ThemeProvider>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</ThemeProvider>
);
}
export default App;
+1
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

+370
View File
@@ -0,0 +1,370 @@
.dashboard-container {
min-height: 100vh;
background: #f5f7fa;
}
.navbar {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 100;
}
.navbar-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar h1 {
font-size: 1.5rem;
color: #667eea;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.user-name {
font-weight: 500;
color: #333;
}
.logout-btn {
padding: 8px 16px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.logout-btn:hover {
background: #5568d3;
}
.dashboard-content {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 20px;
}
.stats-card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #667eea;
}
.add-btn {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s;
}
.add-btn:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.form-card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.form-card h2 {
margin-bottom: 1.5rem;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #667eea;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.cancel-btn {
padding: 12px 24px;
background: #f0f0f0;
color: #333;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
}
.cancel-btn:hover {
background: #e0e0e0;
}
.submit-btn {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s;
}
.submit-btn:hover {
background: #5568d3;
}
.expenses-list {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.expenses-list h2 {
margin-bottom: 1.5rem;
color: #333;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #666;
}
.empty-state p {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.expenses-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.expense-card {
border: 2px solid #f0f0f0;
border-radius: 12px;
padding: 1.5rem;
transition: all 0.3s;
position: relative;
}
.expense-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
transform: translateY(-4px);
}
.expense-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.expense-category {
display: flex;
align-items: center;
gap: 0.5rem;
}
.category-icon {
font-size: 1.5rem;
}
.category-label {
font-size: 0.9rem;
color: #666;
font-weight: 500;
}
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
}
.status-pending {
background: #fff4e6;
color: #d97706;
}
.status-approved {
background: #d1fae5;
color: #059669;
}
.status-rejected {
background: #fee2e2;
color: #dc2626;
}
.expense-card h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: #333;
}
.expense-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.expense-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
margin-bottom: 1rem;
}
.expense-amount {
font-size: 1.3rem;
font-weight: 700;
color: #667eea;
}
.expense-date {
font-size: 0.85rem;
color: #999;
}
.delete-btn {
width: 100%;
padding: 8px;
background: #fee2e2;
color: #dc2626;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.delete-btn:hover {
background: #fecaca;
}
/* Responsive */
@media (max-width: 768px) {
.navbar-content {
flex-direction: column;
gap: 1rem;
}
.user-section {
flex-direction: column;
width: 100%;
}
.logout-btn {
width: 100%;
}
.stats-card {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.expenses-grid {
grid-template-columns: 1fr;
}
}
+143
View File
@@ -0,0 +1,143 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 100%;
overflow: hidden;
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%);
color: white;
padding: 40px 30px;
text-align: center;
}
.login-header h1 {
font-size: 2rem;
margin-bottom: 10px;
font-weight: 700;
}
.login-header p {
font-size: 1rem;
opacity: 0.9;
}
.login-content {
padding: 40px 30px;
}
.feature-list {
margin-bottom: 30px;
}
.feature-item {
display: flex;
align-items: center;
gap: 15px;
padding: 12px 0;
font-size: 0.95rem;
color: #333;
}
.feature-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.microsoft-login-btn {
width: 100%;
padding: 16px 24px;
background: white;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
color: #5e5e5e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.microsoft-login-btn:hover {
border-color: #667eea;
background: #f8f9ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.microsoft-icon {
width: 21px;
height: 21px;
}
.login-info {
text-align: center;
color: #666;
font-size: 0.85rem;
line-height: 1.5;
}
/* Responsive */
@media (max-width: 600px) {
.login-card {
margin: 10px;
}
.login-header {
padding: 30px 20px;
}
.login-header h1 {
font-size: 1.5rem;
}
.login-content {
padding: 30px 20px;
}
.feature-item {
font-size: 0.9rem;
}
.microsoft-login-btn {
padding: 14px 20px;
font-size: 0.95rem;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { ReactNode } from 'react';
interface PrivateRouteProps {
children: ReactNode;
/** Si fourni, accès restreint aux rôles listés */
roles?: string[];
}
const PrivateRoute = ({ children, roles }: PrivateRouteProps): JSX.Element => {
const { user, hasRole } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
if (roles && roles.length > 0 && !hasRole(...roles)) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
export default PrivateRoute;
+293
View File
@@ -0,0 +1,293 @@
import { useState } from 'react';
// ── TYPES ──────────────────────────────────────────────
interface RoleSelectorProps {
roles: string[];
userName: string;
onSelect: (role: string) => void;
}
// ── CONFIG DES RÔLES ───────────────────────────────────
const ROLE_CONFIG: Record<string, {
label: string;
sublabel: string;
icon: string;
gradient: string;
accent: string;
features: string[];
}> = {
Collaborateur: {
label: 'Collaborateur',
sublabel: 'Soumettre & suivre mes notes',
icon: '👤',
gradient: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
accent: '#6366f1',
features: ['Créer des notes de frais', 'Suivre mes remboursements', 'Consulter mon historique'],
},
Collaboratrice: {
label: 'Collaboratrice',
sublabel: 'Soumettre & suivre mes notes',
icon: '👤',
gradient: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
accent: '#6366f1',
features: ['Créer des notes de frais', 'Suivre mes remboursements', 'Consulter mon historique'],
},
Validateur: {
label: 'Validateur',
sublabel: 'Gérer les demandes à valider',
icon: '✅',
gradient: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
accent: '#0ea5e9',
features: ['Valider / refuser des notes', 'Consulter les justificatifs', 'Historique de mes décisions'],
},
Validatrice: {
label: 'Validatrice',
sublabel: 'Gérer les demandes à valider',
icon: '✅',
gradient: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
accent: '#0ea5e9',
features: ['Valider / refuser des notes', 'Consulter les justificatifs', 'Historique de mes décisions'],
},
Finance: {
label: 'Finance',
sublabel: 'Paiements & administration',
icon: '💼',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
accent: '#10b981',
features: ['Gestion des paiements', 'Export XML virements', 'Paramètres TVA & km', 'Synchronisation Entra ID'],
},
superUtilisateur: {
label: 'Super Utilisateur',
sublabel: 'Supervision globale — lecture seule',
icon: '🛡️',
gradient: 'linear-gradient(135deg, #7c3aed 0%, #4c1d95 100%)',
accent: '#7c3aed',
features: ['Voir toutes les notes de l\'organisation', 'Filtrer par collaborateur, statut, mois', 'Visualiser les montants globaux'],
},
};
// Normalise un rôle vers sa clé de config
const normalizeRole = (role: string): string => {
const map: Record<string, string> = {
collaborateur: 'Collaborateur',
collaboratrice: 'Collaboratrice',
validateur: 'Validateur',
validatrice: 'Validatrice',
finance: 'Finance',
};
return map[role.toLowerCase()] ?? role;
};
// ── COMPOSANT ──────────────────────────────────────────
const RoleSelector = ({ roles, userName, onSelect }: RoleSelectorProps) => {
const [hovered, setHovered] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const uniqueRoles = [...new Set(roles.map(normalizeRole))];
const initials = userName.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
const handleSelect = (role: string) => {
setSelected(role);
setTimeout(() => onSelect(role), 280);
};
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(160deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: '"DM Sans", "Segoe UI", system-ui, sans-serif',
padding: '32px 16px',
position: 'relative',
overflow: 'hidden',
}}>
{/* Fond décoratif */}
<div style={{
position: 'absolute', inset: 0, zIndex: 0,
background: 'radial-gradient(ellipse 60% 40% at 50% 0%, rgba(99,102,241,0.18) 0%, transparent 70%)',
pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
height: 320,
background: 'radial-gradient(ellipse 60% 40% at 50% 100%, rgba(16,185,129,0.08) 0%, transparent 70%)',
pointerEvents: 'none',
}} />
{/* Contenu */}
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: 680 }}>
{/* En-tête */}
<div style={{ textAlign: 'center', marginBottom: 48 }}>
<div style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 56, height: 56,
background: 'rgba(99,102,241,0.2)',
border: '1px solid rgba(99,102,241,0.4)',
borderRadius: 16, marginBottom: 24, fontSize: 24,
}}>📋</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 10,
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 100, padding: '6px 16px 6px 10px',
marginBottom: 20,
}}>
<div style={{
width: 28, height: 28,
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 11, fontWeight: 800,
}}>{initials}</div>
<span style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, fontWeight: 500 }}>
{userName}
</span>
</div>
<h1 style={{
color: '#fff', fontSize: 28, fontWeight: 800,
margin: '0 0 8px', letterSpacing: '-0.5px',
}}>
Choisir votre mode
</h1>
<p style={{ color: 'rgba(255,255,255,0.45)', fontSize: 14, margin: 0 }}>
Votre compte dispose de {uniqueRoles.length} profils sélectionnez celui à activer
</p>
</div>
{/* Cartes de rôles */}
<div style={{
display: 'grid',
gridTemplateColumns: uniqueRoles.length === 2 ? '1fr 1fr' : uniqueRoles.length === 3 ? '1fr 1fr 1fr' : '1fr 1fr',
gap: 16,
}}>
{uniqueRoles.map(role => {
const cfg = ROLE_CONFIG[role] ?? {
label: role, sublabel: '', icon: '🔑',
gradient: 'linear-gradient(135deg,#64748b,#475569)',
accent: '#64748b', features: [],
};
const isHovered = hovered === role;
const isSelected = selected === role;
return (
<button
key={role}
onClick={() => handleSelect(role)}
onMouseEnter={() => setHovered(role)}
onMouseLeave={() => setHovered(null)}
style={{
background: isSelected
? cfg.gradient
: isHovered
? 'rgba(255,255,255,0.09)'
: 'rgba(255,255,255,0.05)',
border: `1.5px solid ${isHovered || isSelected ? cfg.accent : 'rgba(255,255,255,0.1)'}`,
borderRadius: 20,
padding: '28px 24px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.22s cubic-bezier(0.34,1.56,0.64,1)',
transform: isSelected ? 'scale(1.03)' : isHovered ? 'translateY(-3px)' : 'none',
boxShadow: isHovered || isSelected
? `0 20px 40px rgba(0,0,0,0.4), 0 0 0 1px ${cfg.accent}33`
: '0 4px 16px rgba(0,0,0,0.2)',
fontFamily: 'inherit',
outline: 'none',
}}
>
{/* Icône + label */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 16 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: isSelected ? 'rgba(255,255,255,0.2)' : `${cfg.accent}22`,
border: `1px solid ${isSelected ? 'rgba(255,255,255,0.3)' : cfg.accent + '44'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20,
transition: 'all 0.2s',
}}>{cfg.icon}</div>
<div>
<div style={{
color: '#fff', fontSize: 16, fontWeight: 700,
marginBottom: 3, letterSpacing: '-0.2px',
}}>{cfg.label}</div>
<div style={{
color: isSelected ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.4)',
fontSize: 12, fontWeight: 500,
transition: 'color 0.2s',
}}>{cfg.sublabel}</div>
</div>
</div>
{/* Features */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{cfg.features.map((f, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 8,
}}>
<div style={{
width: 5, height: 5, borderRadius: '50%',
background: isSelected ? 'rgba(255,255,255,0.7)' : cfg.accent,
flexShrink: 0,
transition: 'background 0.2s',
}} />
<span style={{
color: isSelected ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.35)',
fontSize: 12, fontWeight: 400,
transition: 'color 0.2s',
}}>{f}</span>
</div>
))}
</div>
{/* CTA */}
<div style={{
marginTop: 20,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{
fontSize: 12, fontWeight: 700,
color: isSelected ? '#fff' : cfg.accent,
textTransform: 'uppercase', letterSpacing: '0.8px',
transition: 'color 0.2s',
}}>
{isSelected ? 'Ouverture...' : 'Accéder →'}
</span>
{isSelected && (
<div style={{
width: 18, height: 18, borderRadius: '50%',
border: '2px solid rgba(255,255,255,0.6)',
borderTopColor: '#fff',
animation: 'spin 0.6s linear infinite',
}} />
)}
</div>
</button>
);
})}
</div>
{/* Footer */}
<p style={{
textAlign: 'center', color: 'rgba(255,255,255,0.2)',
fontSize: 11, marginTop: 32,
}}>
Vous pourrez changer de mode à tout moment depuis l'interface
</p>
</div>
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
`}</style>
</div>
);
};
export default RoleSelector;
export { ROLE_CONFIG, normalizeRole };
export type { RoleSelectorProps };
+191
View File
@@ -0,0 +1,191 @@
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
// ── CONFIG ROLES (couleurs & icônes) ──────────────────
const ROLE_STYLE: Record<string, { icon: string; color: string; bg: string; label: string }> = {
Collaborateur: { icon: '👤', color: '#a5b4fc', bg: 'rgba(99,102,241,0.2)', label: 'Collaborateur' },
Collaboratrice: { icon: '👤', color: '#a5b4fc', bg: 'rgba(99,102,241,0.2)', label: 'Collaboratrice' },
Validateur: { icon: '✅', color: '#7dd3fc', bg: 'rgba(14,165,233,0.2)', label: 'Validateur' },
Validatrice: { icon: '✅', color: '#7dd3fc', bg: 'rgba(14,165,233,0.2)', label: 'Validatrice' },
Finance: { icon: '💼', color: '#6ee7b7', bg: 'rgba(16,185,129,0.2)', label: 'Finance' },
superUtilisateur: { icon: '🛡️', color: '#c4b5fd', bg: 'rgba(124,58,237,0.2)', label: 'Super Utilisateur' },
VerificateurFinance: { icon: '🔍', color: '#c4b5fd', bg: 'rgba(124,58,237,0.2)', label: 'Vérificateur Finance' },
ValidateurFinance: { icon: '💳', color: '#fca5a5', bg: 'rgba(185,28,28,0.2)', label: 'Validateur Finance' },
};
const getStyle = (role: string) =>
ROLE_STYLE[role] ?? { icon: '🔑', color: '#cbd5e1', bg: 'rgba(100,116,139,0.2)', label: role };
// ══════════════════════════════════════════════════════
// VERSION SIDEBAR (intégrée dans la sidebar existante)
// ══════════════════════════════════════════════════════
export const RoleSwitcherSidebar = () => {
const { activeRole, allRoles, switchRole, isMultiRole } = useAuth();
const [open, setOpen] = useState(false);
if (!isMultiRole || !activeRole) return null;
const current = getStyle(activeRole);
const others = allRoles.filter(r => r !== activeRole);
return (
<div style={{ padding: '0 10px', marginBottom: 4, position: 'relative' }}>
{/* Bouton mode actif */}
<button
onClick={() => setOpen(!open)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 8, border: `1px solid ${current.color}44`,
background: current.bg, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 12, textAlign: 'left',
transition: 'all 0.15s',
}}
>
<span style={{ fontSize: 14 }}>{current.icon}</span>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.6px' }}>Mode actif</div>
<div style={{ color: current.color, fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{current.label}
</div>
</div>
<span style={{
color: 'rgba(255,255,255,0.4)', fontSize: 10,
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s', display: 'inline-block',
}}></span>
</button>
{/* Dropdown des autres rôles */}
{open && (
<div style={{
position: 'absolute', left: 10, right: 10, top: '100%', marginTop: 4,
background: '#1e293b', border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 10, overflow: 'hidden', zIndex: 300,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
<div style={{ padding: '8px 12px 6px', fontSize: 10, fontWeight: 700, color: 'rgba(255,255,255,0.3)', textTransform: 'uppercase', letterSpacing: '0.6px' }}>
Changer de mode
</div>
{others.map(role => {
const s = getStyle(role);
return (
<button
key={role}
onClick={() => { switchRole(role); setOpen(false); }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', border: 'none', background: 'transparent',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.06)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
>
<div style={{
width: 30, height: 30, borderRadius: 8, background: s.bg,
border: `1px solid ${s.color}44`,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, flexShrink: 0,
}}>{s.icon}</div>
<div>
<div style={{ color: '#fff', fontSize: 13, fontWeight: 600 }}>{s.label}</div>
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: 10 }}>Basculer vers ce mode</div>
</div>
<span style={{ marginLeft: 'auto', color: s.color, fontSize: 11 }}></span>
</button>
);
})}
</div>
)}
</div>
);
};
// ══════════════════════════════════════════════════════
// VERSION BADGE (dans le header, compact)
// ══════════════════════════════════════════════════════
export const RoleSwitcherBadge = () => {
const { activeRole, allRoles, switchRole, isMultiRole } = useAuth();
const [open, setOpen] = useState(false);
if (!activeRole) return null;
const current = getStyle(activeRole);
const others = allRoles.filter(r => r !== activeRole);
return (
<div style={{ position: 'relative' }}>
<button
onClick={() => isMultiRole && setOpen(!open)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px 6px 8px',
background: current.bg,
border: `1px solid ${current.color}55`,
borderRadius: 20, cursor: isMultiRole ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
color: current.color, transition: 'all 0.15s',
}}
>
<span style={{ fontSize: 14 }}>{current.icon}</span>
<span>{current.label}</span>
{isMultiRole && (
<span style={{
fontSize: 8, opacity: 0.6,
transform: open ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
}}></span>
)}
</button>
{open && isMultiRole && (
<>
{/* Overlay pour fermer */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 199 }}
onClick={() => setOpen(false)}
/>
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6,
minWidth: 200, background: '#fff',
border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden',
boxShadow: '0 8px 24px rgba(0,0,0,0.12)', zIndex: 200,
}}>
<div style={{ padding: '8px 12px 6px', fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.6px', borderBottom: '1px solid #f1f5f9' }}>
Changer de mode
</div>
{others.map(role => {
const s = getStyle(role);
return (
<button
key={role}
onClick={() => { switchRole(role); setOpen(false); }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', border: 'none', background: 'transparent',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = '#f8fafc'; }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: s.bg.replace('0.2', '0.15'),
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, flexShrink: 0,
}}>{s.icon}</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a' }}>{s.label}</div>
<div style={{ fontSize: 11, color: '#94a3b8' }}>Basculer vers ce mode</div>
</div>
</button>
);
})}
</div>
</>
)}
</div>
);
};
// Export default = version sidebar (la plus courante)
export default RoleSwitcherSidebar;
+101
View File
@@ -0,0 +1,101 @@
import React, { useRef, useState } from 'react';
import { Upload, FileText, Receipt } from 'lucide-react';
interface FileUploadZoneProps {
files: File[];
setFiles: (f: File[]) => void;
}
const fileSize = (b: number) =>
b < 1048576 ? `${(b / 1024).toFixed(1)} Ko` : `${(b / 1048576).toFixed(1)} Mo`;
export const FileUploadZone = ({ files, setFiles }: FileUploadZoneProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'];
const add = (list: FileList | null) => {
if (!list) return;
const filtered = Array.from(list).filter(f => allowed.includes(f.type));
setFiles([...files, ...filtered]);
};
return (
<div>
<div
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); add(e.dataTransfer.files); }}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragOver ? '#6366f1' : '#cbd5e1'}`,
borderRadius: 10, padding: '28px 20px', textAlign: 'center',
cursor: 'pointer',
background: dragOver ? 'rgba(99,102,241,.05)' : '#f8fafc',
transition: 'all 0.2s',
}}>
<div style={{ fontSize: 28, marginBottom: 8, display: 'flex', justifyContent: 'center', color: 'var(--text-muted)' }}>
<Upload size={32} />
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 4 }}>
Glissez vos fichiers ici
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
ou <span style={{ color: '#6366f1', fontWeight: 600 }}>cliquez pour parcourir</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>
PDF, JPG, PNG max 10 Mo
</div>
<input ref={inputRef} type="file" multiple accept=".pdf,.jpg,.jpeg,.png"
onChange={e => add(e.target.files)} style={{ display: 'none' }} />
</div>
<button type="button" onClick={() => cameraRef.current?.click()} style={{
marginTop: 10, width: '100%', display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 8, padding: '11px 14px',
background: '#f1f5f9', border: '1px solid #e2e8f0', borderRadius: 8,
cursor: 'pointer', fontFamily: '"Inter","Segoe UI",sans-serif',
fontSize: 13, color: 'var(--text-primary)', fontWeight: 600,
}}>
📷 Photo mobile
</button>
<input ref={cameraRef} type="file" accept="image/*" capture="environment"
onChange={e => add(e.target.files)} style={{ display: 'none' }} />
{files.length > 0 && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{files.map((f, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', background: 'var(--bg-input)',
border: '1px solid #e2e8f0', borderRadius: 8,
}}>
<span style={{ fontSize: 16, display: 'flex', alignItems: 'center' }}>
{f.type === 'application/pdf'
? <FileText size={18} color="#6366f1" />
: <Receipt size={18} color="#10b981" />}
</span>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{f.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{fileSize(f.size)}</div>
</div>
{f.type.startsWith('image/') && (
<img src={URL.createObjectURL(f)} alt={f.name}
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 6, border: '1px solid #e2e8f0' }} />
)}
<button onClick={() => setFiles(files.filter((_, j) => j !== i))}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#ef4444', fontSize: 16, padding: '2px 6px',
}}></button>
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,88 @@
import React from 'react';
interface JustificatifPopupProps {
file: File | null;
onClose: () => void;
}
export const JustificatifPopup = ({ file, onClose }: JustificatifPopupProps) => {
if (!file) return null;
const url = URL.createObjectURL(file);
const isPDF = file.type === 'application/pdf';
const isImage = file.type.startsWith('image/');
return (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(4px)',
}}>
<div onClick={e => e.stopPropagation()} style={{
background: '#fff', borderRadius: 16,
boxShadow: '0 24px 80px rgba(0,0,0,.4)',
maxWidth: '90vw', maxHeight: '90vh',
width: isPDF ? 800 : 'auto',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 20px', borderBottom: '1px solid #e2e8f0',
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 18 }}>{isPDF ? '📄' : '🖼️'}</span>
<div>
<div style={{ color: '#fff', fontWeight: 700, fontSize: 14 }}>{file.name}</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
{file.type} {file.size < 1048576
? `${(file.size / 1024).toFixed(1)} Ko`
: `${(file.size / 1048576).toFixed(1)} Mo`}
</div>
</div>
</div>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.2)', border: 'none',
borderRadius: 8, width: 34, height: 34, cursor: 'pointer',
color: '#fff', fontSize: 18, display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}></button>
</div>
{/* Body */}
<div style={{
flex: 1, overflow: 'auto', display: 'flex',
alignItems: 'center', justifyContent: 'center',
padding: 16, background: '#f8fafc',
}}>
{isPDF && (
<iframe src={url}
style={{ width: '100%', height: '75vh', border: 'none', borderRadius: 8 }}
title={file.name} />
)}
{isImage && (
<img src={url} alt={file.name} style={{
maxWidth: '80vw', maxHeight: '75vh',
objectFit: 'contain', borderRadius: 8,
boxShadow: '0 4px 24px rgba(0,0,0,0.15)',
}} />
)}
</div>
{/* Footer */}
<div style={{
padding: '12px 20px', borderTop: '1px solid #e2e8f0',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: '#fff',
}}>
<span style={{ fontSize: 12, color: '#64748b' }}>Cliquez en dehors pour fermer</span>
<a href={url} download={file.name} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 14px', background: '#eef2ff',
border: '1px solid #c7d2fe', borderRadius: 8,
color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none',
}}> Télécharger</a>
</div>
</div>
</div>
);
};
+769
View File
@@ -0,0 +1,769 @@
import { useState, useRef } from "react";
const BAREME_KM = [
{ cv: 3, label: "3 CV et moins", t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
{ cv: 4, label: "4 CV", t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
{ cv: 5, label: "5 CV", t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
{ cv: 6, label: "6 CV", t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
{ cv: 7, label: "7 CV et plus", t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
];
const CATEGORIES_DEFAULT = ["Deplacement kilometrique", "Repas", "Hebergement", "Transport", "Autre"];
const TAUX_TVA = [
{ taux: 0, libelle: "Exonéré (0%)" },
{ taux: 10, libelle: "Taux réduit (10%)" },
{ taux: 15, libelle: "Taux intermédiaire (15%)" },
{ taux: 20, libelle: "Taux normal (20%)" },
];
function fmt(val) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(val);
}
function getHT(ttc, taux) { return ttc / (1 + taux / 100); }
function getTVA(ttc, taux) { return ttc - getHT(ttc, taux); }
function calcIndemnite(km, cv) {
const b = BAREME_KM.find(r => r.cv === cv) ?? BAREME_KM[4];
if (km <= 0) return 0;
if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
return parseFloat((km * b.t3).toFixed(2));
}
function getTranche(km) {
if (km <= 0) return 0;
if (km <= 5000) return 1;
if (km <= 20000) return 2;
return 3;
}
function newDepense() {
return {
id: Math.random(),
categorie: "Repas",
date: "",
libelle: "",
description: "",
km: "",
// FIX 1 : chevaux stocké en number pour cohérence avec BAREME_KM
chevaux: 7,
// FIX 2 : taux initial aligné avec les options TAUX_TVA (on utilise "" pour forcer un choix explicite)
tvaItems: [{ taux: "", montantTTC: "" }],
nombreParticipants: "",
participants: [{ nom: "", prenom: "", societe: "" }],
files: [],
};
}
const css = `
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f4f5;
--bg-card: #ffffff;
--bg-input: #f9f9fa;
--bg-sidebar: #ffffff;
--border: rgba(0,0,0,0.07);
--border-light: rgba(0,0,0,0.11);
--text-primary: #111111;
--text-secondary: #6b7280;
--accent-orange: #c8933a;
--accent-purple: #7c3aed;
--accent-green: #16a34a;
--accent-indigo: #4f46e5;
--accent-yellow: #d97706;
font-family: 'DM Sans', sans-serif;
}
body { background: var(--bg); color: var(--text-primary); min-height: 100vh; }
.app { display: flex; min-height: 100vh; }
/* ── Main ── */
.main { flex: 1; padding: 28px; max-width: 740px; }
/* ── Header ── */
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; }
.header-left { display: flex; align-items: center; gap: 14px; }
.header-title { font-size: 20px; font-weight: 800; color: var(--text-primary); }
.badge-brouillon {
background: rgba(200,147,58,0.12);
color: var(--accent-orange);
border: 1px solid rgba(200,147,58,0.35);
border-radius: 20px;
padding: 4px 14px;
font-size: 12px;
font-weight: 700;
}
.btn-save {
background: #111111;
color: #ffffff;
border: none;
border-radius: 10px;
padding: 10px 24px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.btn-save:hover { opacity: 0.85; }
/* ── Meta fields ── */
.meta-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 100px;
gap: 12px;
align-items: start;
margin-bottom: 24px;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: 14px;
padding: 18px;
}
.meta-label { font-size: 11px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; line-height: 1.3; margin-bottom: 6px; }
.meta-label span { color: #ef4444; }
.input-field {
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
font-family: inherit;
color: var(--text-primary);
width: 100%;
outline: none;
transition: border-color 0.15s;
}
.input-field:focus { border-color: rgba(0,0,0,0.25); }
.input-field::placeholder { color: var(--text-secondary); }
.logo-box {
width: 52px; height: 52px;
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
font-size: 22px;
transition: border-color 0.15s;
}
.logo-box:hover { border-color: rgba(0,0,0,0.25); }
/* ── Section Dépenses ── */
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.section-title { font-size: 11px; font-weight: 800; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.1em; }
.btn-add-depense {
background: #111111;
color: #ffffff;
border: none;
border-radius: 10px;
padding: 10px 20px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.btn-add-depense:hover { opacity: 0.85; }
/* ── Dépense card ── */
.depense-card {
border: 1px solid var(--border-light);
border-radius: 14px;
overflow: hidden;
background: var(--bg-card);
margin-bottom: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.card-header-left { display: flex; align-items: center; gap: 10px; }
.dot-orange { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-orange); flex-shrink: 0; }
.card-title { font-size: 13px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.card-subtitle { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
.card-header-right { display: flex; align-items: center; gap: 10px; }
.status-chip { font-size: 12px; color: var(--text-secondary); font-weight: 500; }
.card-body { padding: 18px; display: flex; flex-direction: column; gap: 16px; }
.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-label { font-size: 11px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; display: block; margin-bottom: 6px; }
select.input-field { appearance: none; cursor: pointer; padding-right: 32px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; }
/* ── TVA block ── */
.tva-block {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
}
.tva-block-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.tva-block-title { font-size: 12px; font-weight: 700; color: var(--text-secondary); }
.btn-ajouter-taux {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px;
background: rgba(255,255,255,0.07);
border: 1px solid var(--border-light);
border-radius: 7px;
cursor: pointer;
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
font-family: inherit;
transition: background 0.15s;
}
.btn-ajouter-taux:hover { background: rgba(0,0,0,0.05); }
.tva-cols { display: grid; grid-template-columns: 1fr 1fr 80px 80px 32px; gap: 8px; margin-bottom: 6px; align-items: center; }
.tva-col-label { font-size: 10px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.tva-val { display: flex; align-items: center; justify-content: center; height: 38px; font-size: 13px; font-weight: 700; }
.tva-val.green { color: #7c3aed; }
.tva-val.indigo { color: #4f46e5; }
.tva-val.muted { color: var(--text-secondary); }
.btn-remove {
width: 28px; height: 28px; border-radius: 6px; border: none;
background: rgba(220,38,38,0.1);
color: #dc2626;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 13px;
transition: background 0.15s;
font-family: inherit;
}
.btn-remove:hover { background: rgba(220,38,38,0.2); }
.btn-remove:disabled { background: transparent; color: rgba(0,0,0,0.15); cursor: default; }
.totals-row {
margin-top: 12px; padding: 10px 14px;
background: rgba(0,0,0,0.02);
border: 1px solid var(--border);
border-radius: 8px;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
.total-col { text-align: center; }
.total-col + .total-col { border-left: 1px solid var(--border); }
.total-col-label { font-size: 9px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; margin-bottom: 3px; }
.total-col-val { font-size: 14px; font-weight: 900; }
/* ── Repas block ── */
.repas-block {
background: rgba(124,58,237,0.05);
border: 1.5px solid rgba(124,58,237,0.2);
border-radius: 10px; padding: 14px;
}
.repas-title { font-size: 12px; font-weight: 700; color: #7c3aed; margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between; }
/* ── KM block ── */
.km-block {
background: rgba(124,58,237,0.05);
border: 1.5px solid rgba(124,58,237,0.2);
border-radius: 10px; padding: 16px;
}
.km-title { font-size: 12px; font-weight: 800; color: #7c3aed; margin-bottom: 14px; }
.km-table { width: 100%; border-collapse: collapse; font-size: 12px; overflow: hidden; border-radius: 8px; margin-bottom: 16px; }
.km-table thead tr { background: #7c3aed; }
.km-table thead th { padding: 8px 12px; color: #fff; font-weight: 700; text-align: left; }
.km-table thead th:not(:first-child) { text-align: center; }
.km-table tbody tr { cursor: pointer; transition: background 0.1s; }
.km-table tbody td { padding: 10px 12px; color: var(--text-secondary); }
.km-table tbody td:not(:first-child) { text-align: center; }
.km-table tbody tr.selected { background: rgba(124,58,237,0.12); outline: 2px solid #7c3aed; outline-offset: -1px; }
.km-table tbody tr.selected td { color: #7c3aed; font-weight: 800; }
.km-active-badge { font-size: 9px; color: #7c3aed; font-weight: 700; }
.indemnite-box {
padding: 10px 16px;
min-height: 42px;
border-radius: 8px;
border: 1.5px solid rgba(124,58,237,0.3);
display: flex; align-items: center; justify-content: space-between;
}
.indemnite-box.active { background: linear-gradient(135deg, #7c3aed, #5b21b6); }
.indemnite-box.empty { background: rgba(124,58,237,0.05); }
/* ── Justificatif ── */
.drop-zone {
border: 1.5px dashed rgba(0,0,0,0.15);
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
transition: all 0.2s;
}
.drop-zone:hover { border-color: rgba(0,0,0,0.3); }
.drop-zone.has-files { border-color: rgba(22,163,74,0.5); color: var(--accent-green); }
/* ── Btn supprimer ── */
.btn-supprimer {
width: 100%; padding: 12px;
background: rgba(0,0,0,0.02);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 13px; font-weight: 600;
font-family: inherit;
transition: background 0.15s;
}
.btn-supprimer:hover { background: rgba(220,38,38,0.06); border-color: rgba(220,38,38,0.3); color: #dc2626; }
/* ── Sidebar ── */
.sidebar {
width: 280px;
background: var(--bg-sidebar);
border-left: 1px solid var(--border);
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 12px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-title { font-size: 11px; font-weight: 800; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 8px; }
.recap-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(0,0,0,0.03);
}
.recap-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-orange); flex-shrink: 0; margin-top: 3px; }
.recap-label { font-size: 13px; font-weight: 700; color: var(--text-primary); }
.recap-cat { font-size: 11px; color: var(--text-secondary); margin-top: 1px; }
.sidebar-totals { margin-top: auto; }
.sidebar-totals-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.sidebar-totals-main { display: flex; justify-content: space-between; padding: 12px 0; font-size: 15px; font-weight: 800; color: var(--text-primary); }
.btn-soumettre {
width: 100%;
padding: 13px;
background: #111111;
color: #ffffff;
border: none;
border-radius: 10px;
font-size: 14px; font-weight: 800;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
margin-top: 8px;
}
.btn-soumettre:hover { opacity: 0.85; }
.btn-soumettre-note { font-size: 11px; color: var(--text-secondary); text-align: center; margin-top: 6px; }
.input-with-suffix { position: relative; }
.input-with-suffix .suffix { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 11px; color: var(--accent-purple); font-weight: 700; pointer-events: none; }
.input-with-suffix .suffix-eur { color: var(--text-secondary); }
`;
function DepenseCard({ depense, total, onChange, onDelete }) {
const fileRef = useRef();
const isKm = depense.categorie.toLowerCase().includes("kilom");
const isRepas = depense.categorie.toLowerCase().includes("repas");
const kmNum = parseFloat(depense.km) || 0;
// FIX 1 : chevaux est maintenant un number, parseInt reste en sécurité
const cv = typeof depense.chevaux === "number" ? depense.chevaux : parseInt(depense.chevaux) || 7;
const tranche = getTranche(kmNum);
const indemnite = calcIndemnite(kmNum, cv);
const tvaItems = depense.tvaItems?.length ? depense.tvaItems : [{ taux: "", montantTTC: "" }];
const totalTTC = isKm ? indemnite : tvaItems.reduce((s, i) => s + (parseFloat(i.montantTTC) || 0), 0);
const totalHT = isKm ? indemnite : tvaItems.reduce((s, i) => {
const t = parseFloat(i.montantTTC) || 0; const tx = parseFloat(i.taux) || 0;
return s + (tx > 0 ? getHT(t, tx) : t);
}, 0);
const totalTVA = isKm ? 0 : tvaItems.reduce((s, i) => {
const t = parseFloat(i.montantTTC) || 0; const tx = parseFloat(i.taux) || 0;
return s + (tx > 0 ? getTVA(t, tx) : 0);
}, 0);
const updateTvaItem = (idx, field, val) => {
const copy = [...tvaItems];
copy[idx] = { ...copy[idx], [field]: val };
// FIX 2 : suppression du onChange("montant", ...) — ce champ n'existe pas dans le modèle
onChange("tvaItems", copy);
};
const addTvaItem = () => onChange("tvaItems", [...tvaItems, { taux: "", montantTTC: "" }]);
const removeTvaItem = (idx) => {
if (tvaItems.length <= 1) return;
const copy = tvaItems.filter((_, i) => i !== idx);
// FIX 2 (suite) : suppression du onChange("montant", ...) orphelin
onChange("tvaItems", copy);
};
const updateParticipant = (i, field, val) => {
const updated = [...depense.participants];
updated[i] = { ...updated[i], [field]: val };
onChange("participants", updated);
};
const addFiles = (list) => {
if (!list) return;
const allowed = ["image/jpeg", "image/jpg", "image/png", "application/pdf"];
onChange("files", [...depense.files, ...Array.from(list).filter(f => allowed.includes(f.type))]);
};
return (
<div className="depense-card">
{/* Header */}
<div className="card-header">
<div className="card-header-left">
<div className="dot-orange" />
<div>
<div className="card-title">Nouvelle dépense</div>
<div className="card-subtitle">{depense.categorie || "Sans catégorie"}</div>
</div>
</div>
<div className="card-header-right">
<span className="status-chip">À saisir</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#888" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</div>
</div>
{/* Body */}
<div className="card-body">
{/* Catégorie + Date */}
<div className="form-grid-2">
<div>
<label className="form-label">Catégorie *</label>
<select className="input-field" value={depense.categorie}
onChange={e => {
onChange("categorie", e.target.value);
onChange("km", "");
onChange("tvaItems", [{ taux: "", montantTTC: "" }]);
}}>
{CATEGORIES_DEFAULT.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="form-label">Date *</label>
<input type="date" className="input-field" value={depense.date} onChange={e => onChange("date", e.target.value)} />
</div>
</div>
{/* Libellé */}
<div>
<label className="form-label">Libellé *</label>
<input className="input-field" value={depense.libelle} onChange={e => onChange("libelle", e.target.value)} placeholder="Ex: Déjeuner client, Train Paris-Lyon..." />
</div>
{/* Commentaire */}
<div>
<label className="form-label">Commentaire (optionnel)</label>
<input className="input-field" value={depense.description} onChange={e => onChange("description", e.target.value)} placeholder="Précision, contexte..." />
</div>
{/* KM block */}
{isKm && (
<div className="km-block">
<div className="km-title">🚗 Barème kilométrique fiscal</div>
<div style={{ overflowX: "auto" }}>
<table className="km-table">
<thead>
<tr>
<th>Puissance fiscale</th>
<th> 5 000 km</th>
<th>5 001 20 000 km</th>
<th>&gt; 20 000 km</th>
</tr>
</thead>
<tbody>
{BAREME_KM.map((b) => {
const sel = cv === b.cv;
return (
<tr key={b.cv} className={sel ? "selected" : ""}
// FIX 1 : on stocke un number, pas une string
onClick={() => onChange("chevaux", b.cv)}>
<td>{b.label}</td>
<td>d × {b.t1.toString().replace(".", ",")}{sel && tranche === 1 && <div className="km-active-badge"> active</div>}</td>
<td>(d × {b.t2_a.toString().replace(".", ",")}) + {b.t2_b.toLocaleString("fr-FR")}{sel && tranche === 2 && <div className="km-active-badge"> active</div>}</td>
<td>d × {b.t3.toString().replace(".", ",")}{sel && tranche === 3 && <div className="km-active-badge"> active</div>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="form-grid-2">
<div>
<label className="form-label" style={{ color: "#7c3aed" }}>Nombre de kilomètres *</label>
<div className="input-with-suffix">
<input type="number" min="0" className="input-field" style={{ borderColor: "rgba(124,58,237,0.4)", paddingRight: 40 }} value={depense.km} onChange={e => onChange("km", e.target.value)} placeholder="0" />
<span className="suffix">km</span>
</div>
{kmNum > 0 && tranche > 0 && (
<div style={{ marginTop: 5, fontSize: 10, color: "#7c3aed", fontWeight: 600 }}>
🔢 Tranche {tranche} = <strong>{fmt(indemnite)}</strong>
</div>
)}
</div>
<div>
<label className="form-label" style={{ color: "#7c3aed" }}>Indemnité calculée</label>
<div className={`indemnite-box ${indemnite > 0 ? "active" : "empty"}`}>
<span style={{ fontSize: 18, fontWeight: 900, color: indemnite > 0 ? "#fff" : "#6b7280" }}>
{indemnite > 0 ? fmt(indemnite) : "—"}
</span>
{kmNum > 0 && tranche > 0 && (
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.8)", fontWeight: 600, textAlign: "right" }}>
{cv} CV<br />Tranche {tranche}
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* TVA block */}
{!isKm && (
<div className="tva-block">
<div className="tva-block-header">
<span className="tva-block-title">Montant &amp; TVA</span>
<button type="button" className="btn-ajouter-taux" onClick={addTvaItem}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 5v14M5 12h14" /></svg>
+ Ajouter un taux
</button>
</div>
<div className="tva-cols" style={{ marginBottom: 8 }}>
<div className="tva-col-label">Taux TVA</div>
<div className="tva-col-label">Montant TTC</div>
<div className="tva-col-label">HT</div>
<div className="tva-col-label">TVA</div>
<div />
</div>
{tvaItems.map((item, idx) => {
const ttc = parseFloat(item.montantTTC) || 0;
const taux = parseFloat(item.taux) || 0;
const ht = taux > 0 ? getHT(ttc, taux) : ttc;
const tva = taux > 0 ? getTVA(ttc, taux) : 0;
return (
<div key={idx} className="tva-cols" style={{ marginBottom: 8, alignItems: "center" }}>
{/* FIX 2 : option vide cohérente avec le state initial taux: "" */}
<select className="input-field" value={item.taux} onChange={e => updateTvaItem(idx, "taux", e.target.value)}>
<option value=""> Choisir un taux </option>
{TAUX_TVA.map((t, i) => <option key={i} value={String(t.taux)}>{t.taux}% {t.libelle}</option>)}
</select>
<div className="input-with-suffix">
<input type="number" min="0" step="0.01" className="input-field" style={{ paddingRight: 28 }} value={item.montantTTC} onChange={e => updateTvaItem(idx, "montantTTC", e.target.value)} placeholder="0,00" />
<span className="suffix suffix-eur"></span>
</div>
<div className={`tva-val ${ttc > 0 ? "green" : "muted"}`}>{ttc > 0 ? fmt(ht) : "—"}</div>
<div className={`tva-val ${tva > 0 ? "indigo" : "muted"}`}>{tva > 0 ? fmt(tva) : "—"}</div>
<button type="button" className="btn-remove" disabled={tvaItems.length <= 1} onClick={() => removeTvaItem(idx)}></button>
</div>
);
})}
{tvaItems.length > 1 && totalTTC > 0 && (
<div className="totals-row">
<div className="total-col"><div className="total-col-label">Total HT</div><div className="total-col-val" style={{ color: "#7c3aed" }}>{fmt(totalHT)}</div></div>
<div className="total-col"><div className="total-col-label">Total TVA</div><div className="total-col-val" style={{ color: "#4f46e5" }}>{fmt(totalTVA)}</div></div>
<div className="total-col"><div className="total-col-label">Total TTC</div><div className="total-col-val" style={{ color: "var(--text-primary)" }}>{fmt(totalTTC)}</div></div>
</div>
)}
</div>
)}
{/* Participants (repas) */}
{isRepas && (
<div className="repas-block">
<div className="repas-title">
🍽 Participants au repas
</div>
<div style={{ marginBottom: 10 }}>
<label className="form-label" style={{ color: "#7c3aed" }}>Nombre de participants *</label>
<input type="number" min="1" className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={depense.nombreParticipants} onChange={e => onChange("nombreParticipants", e.target.value)} placeholder="Ex: 3" />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{depense.participants.map((p, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, alignItems: "end" }}>
<div>
{i === 0 && <div className="form-label" style={{ color: "#7c3aed" }}>Nom *</div>}
<input className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={p.nom} onChange={e => updateParticipant(i, "nom", e.target.value)} placeholder="Nom" />
</div>
<div>
{i === 0 && <div className="form-label" style={{ color: "#7c3aed" }}>Prénom *</div>}
<input className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={p.prenom} onChange={e => updateParticipant(i, "prenom", e.target.value)} placeholder="Prénom" />
</div>
<div>
{i === 0 && <div className="form-label" style={{ color: "#7c3aed" }}>Société</div>}
<input className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={p.societe || ""} onChange={e => updateParticipant(i, "societe", e.target.value)} placeholder="Société (opt.)" />
</div>
{depense.participants.length > 1 && (
<button className="btn-remove" onClick={() => onChange("participants", depense.participants.filter((_, j) => j !== i))}></button>
)}
</div>
))}
</div>
<button type="button" onClick={() => onChange("participants", [...depense.participants, { nom: "", prenom: "", societe: "" }])}
style={{ marginTop: 8, background: "none", border: "1px dashed rgba(124,58,237,0.35)", borderRadius: 6, padding: "6px 12px", cursor: "pointer", fontSize: 11, color: "#7c3aed", fontWeight: 600, fontFamily: "inherit" }}>
+ Ajouter un participant
</button>
</div>
)}
{/* Justificatif */}
<div>
<label className="form-label">Justificatif{isKm ? " (facultatif)" : ""}</label>
<div className={`drop-zone ${depense.files.length > 0 ? "has-files" : ""}`}
onClick={() => fileRef.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); addFiles(e.dataTransfer.files); }}>
{depense.files.length > 0 ? `${depense.files.length} fichier${depense.files.length > 1 ? "s" : ""} joint${depense.files.length > 1 ? "s" : ""}` : "Glisser un fichier ou cliquer — PDF, JPG, PNG"}
<input ref={fileRef} type="file" multiple accept=".pdf,.jpg,.jpeg,.png" onChange={e => addFiles(e.target.files)} style={{ display: "none" }} />
</div>
{isKm && depense.files.length === 0 && (
<div style={{ marginTop: 8, background: "rgba(124,58,237,0.05)", border: "1px solid rgba(124,58,237,0.2)", borderRadius: 8, padding: "8px 12px", fontSize: 11, color: "#7c3aed", fontWeight: 600 }}>
Déplacement kilométrique le justificatif est facultatif
</div>
)}
</div>
{/* Supprimer */}
{total > 1 && (
<button className="btn-supprimer" onClick={onDelete}>Supprimer cette dépense</button>
)}
</div>
</div>
);
}
export default function App() {
// FIX 3 : titre est maintenant lié à un vrai <input> dans le JSX
const [titre, setTitre] = useState("");
const [dateDebut, setDateDebut] = useState("");
const [dateFin, setDateFin] = useState("");
const [commentaire, setCommentaire] = useState("");
const [depenses, setDepenses] = useState([newDepense()]);
const updateDepense = (id, field, val) => {
setDepenses(prev => prev.map(d => d.id === id ? { ...d, [field]: val } : d));
};
const deleteDepense = (id) => setDepenses(prev => prev.filter(d => d.id !== id));
const addDepense = () => setDepenses(prev => [...prev, newDepense()]);
// Totaux sidebar
const totalTTC = depenses.reduce((s, d) => {
const isKm = d.categorie.toLowerCase().includes("kilom");
if (isKm) return s + calcIndemnite(parseFloat(d.km) || 0, typeof d.chevaux === "number" ? d.chevaux : parseInt(d.chevaux) || 7);
return s + (d.tvaItems || []).reduce((ss, i) => ss + (parseFloat(i.montantTTC) || 0), 0);
}, 0);
const totalHT = depenses.reduce((s, d) => {
const isKm = d.categorie.toLowerCase().includes("kilom");
if (isKm) return s + calcIndemnite(parseFloat(d.km) || 0, typeof d.chevaux === "number" ? d.chevaux : parseInt(d.chevaux) || 7);
return s + (d.tvaItems || []).reduce((ss, i) => {
const t = parseFloat(i.montantTTC) || 0; const tx = parseFloat(i.taux) || 0;
return ss + (tx > 0 ? getHT(t, tx) : t);
}, 0);
}, 0);
const totalTVA = totalTTC - totalHT;
return (
<>
<style>{css}</style>
<div className="app">
{/* Main */}
<div className="main">
{/* Header */}
<div className="header">
<div className="header-left">
<span className="header-title">Nouvelle note de frais</span>
<span className="badge-brouillon">Brouillon</span>
</div>
<button className="btn-save">Enregistrer</button>
</div>
{/* Meta — FIX 3 : champ titre désormais fonctionnel avec input lié */}
<div className="meta-grid">
<div>
<div className="meta-label">Titre <span>*</span></div>
<input
className="input-field"
value={titre}
onChange={e => setTitre(e.target.value)}
placeholder="Ex: Mission Lyon mars"
/>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-secondary)", marginBottom: 6, textTransform: "uppercase" }}>Période</div>
<div style={{ display: "flex", gap: 8 }}>
<input type="date" className="input-field" value={dateDebut} onChange={e => setDateDebut(e.target.value)} style={{ flex: 1 }} />
<input type="date" className="input-field" value={dateFin} onChange={e => setDateFin(e.target.value)} style={{ flex: 1 }} />
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-secondary)", marginBottom: 6, textTransform: "uppercase" }}>Commentaire</div>
<input className="input-field" value={commentaire} onChange={e => setCommentaire(e.target.value)} placeholder="Projet" />
</div>
<div>
<div className="logo-box" title="Ajouter un logo">🏢</div>
</div>
</div>
{/* Dépenses */}
<div className="section-header">
<span className="section-title">Dépenses</span>
<button className="btn-add-depense" onClick={addDepense}>+ Ajouter une dépense</button>
</div>
{depenses.map(d => (
<DepenseCard
key={d.id}
depense={d}
total={depenses.length}
onChange={(field, val) => updateDepense(d.id, field, val)}
onDelete={() => deleteDepense(d.id)}
/>
))}
</div>
{/* Sidebar */}
<div className="sidebar">
<div className="sidebar-title">Récapitulatif</div>
{depenses.map(d => (
<div className="recap-item" key={d.id}>
<div className="recap-dot" />
<div>
<div className="recap-label">{d.libelle || "Sans libellé"}</div>
<div className="recap-cat">{d.categorie}</div>
</div>
</div>
))}
<div className="sidebar-totals">
<div className="sidebar-totals-row">
<span>Total HT</span>
<span style={{ color: "var(--text-primary)", fontWeight: 700 }}>{totalHT > 0 ? fmt(totalHT) : "—"}</span>
</div>
<div className="sidebar-totals-row">
<span>Total TVA</span>
<span style={{ color: "var(--text-primary)", fontWeight: 700 }}>{totalTVA > 0 ? fmt(totalTVA) : "—"}</span>
</div>
<div className="sidebar-totals-main">
<span>Total TTC</span>
<span>{totalTTC > 0 ? fmt(totalTTC) : "—"}</span>
</div>
<button className="btn-soumettre">Soumettre la note </button>
<div className="btn-soumettre-note">Justificatifs vérifiés avant validation</div>
</div>
</div>
</div>
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import type { Note } from './types';
import { fmt } from './helpers';
import { tagStatut } from './TagStatut';
const API = '';
interface NotesTableProps {
notes: Note[];
}
const getJustificatifUrl = (note: Note): string | null => {
try {
const isKmOnly = note.lignesJson
? JSON.parse(note.lignesJson || '[]').every((l: any) =>
(l.categorie || '').toLowerCase().includes('kilom') &&
parseFloat(l.montant || 0) === 0)
: (note.km ?? 0) > 0 && parseFloat(String(note.montantTTC || 0)) === 0;
if (isKmOnly) return 'KILOMETRIQUE';
} catch { }
let files: { fileName: string; uploadUrl: string }[] = note.sharepointFiles || [];
if (!files.length && note.fichiers) {
try { files = JSON.parse(note.fichiers); } catch { files = []; }
}
if (!Array.isArray(files)) files = [];
const isApprouve = ['approuve', 'payee'].includes(note.statut || '');
const file = files.find(f =>
f.fileName.includes(isApprouve ? 'signe_approuve' : 'soumission')
);
return file?.uploadUrl || null;
};
export const NotesTable = ({ notes }: NotesTableProps) => {
if (!notes.length) return (
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
<div style={{ fontSize: 36, marginBottom: 12 }}></div>
<p style={{ fontSize: 14 }}>Aucune note de frais</p>
</div>
);
const HEADERS = ['Référence', 'Libellé', 'Catégorie', 'Date', 'Montant TTC', 'HT', 'TVA', 'Statut', 'Justificatif'];
return (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--bg-thead)' }}>
{HEADERS.map(h => (
<th key={h} style={{
padding: '10px 16px', textAlign: 'left', fontSize: 11,
fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase',
letterSpacing: '0.6px', borderBottom: '1px solid var(--border-divider)',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{notes.map((n, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--border-divider)' }}>
<td style={{ padding: '13px 16px', color: '#6366f1', fontWeight: 700, fontFamily: 'monospace', fontSize: 12 }}>
{n.reference || `#${n.id}`}
</td>
<td style={{ padding: '13px 16px', fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
{n.libelle || '—'}
{n.nombreParticipants && (
<span style={{ marginLeft: 6, fontSize: 10, background: '#fef3c7', color: '#92400e', padding: '1px 6px', borderRadius: 4 }}>
{n.nombreParticipants} pers.
</span>
)}
{n.km && (
<span style={{ marginLeft: 6, fontSize: 10, background: '#ede9fe', color: '#7c3aed', padding: '1px 6px', borderRadius: 4 }}>
{n.km} km
</span>
)}
</td>
<td style={{ padding: '13px 16px', fontSize: 13, color: 'var(--text-secondary)' }}>{n.categorie || '—'}</td>
<td style={{ padding: '13px 16px', fontSize: 12, color: 'var(--text-muted)' }}>
{n.date ? new Date(n.date).toLocaleDateString('fr-FR') : '—'}
</td>
<td style={{ padding: '13px 16px', fontWeight: 800, fontSize: 14, color: 'var(--text-primary)' }}>
{fmt(n.montant || 0)}
</td>
<td style={{ padding: '13px 16px', fontSize: 12, color: '#15803d' }}>
{n.montantHT ? fmt(n.montantHT) : '—'}
</td>
<td style={{ padding: '13px 16px', fontSize: 12, color: '#6366f1' }}>
{n.tauxTVA ? `${n.tauxTVA}%` : n.km ? 'Km' : '—'}
</td>
<td style={{ padding: '13px 16px' }}>{tagStatut(n.statut || 'brouillon')}</td>
<td style={{ padding: '13px 16px' }}>
{(() => {
const url = getJustificatifUrl(n);
if (url === 'KILOMETRIQUE')
return <span style={{ color: '#7c3aed', fontSize: 12, fontWeight: 600 }}>Kilométrique</span>;
if (url)
return (
<a href={url} target="_blank" rel="noreferrer"
style={{ color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none' }}>
{['approuve', 'payee'].includes(n.statut || '')
? 'Justificatif approuvé'
: 'Justificatif soumission'}
</a>
);
if (n.justificatif)
return (
<a href={`${API}/uploads/${n.justificatif}`} target="_blank" rel="noreferrer"
style={{ color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none' }}>
Justificatif de soumission
</a>
);
return <span style={{ color: '#cbd5e1', fontSize: 12 }}></span>;
})()}
</td>
</tr>
))}
</tbody>
</table>
);
};
+90
View File
@@ -0,0 +1,90 @@
import React from 'react';
import { normalizeStatut } from './helpers';
const STEPS = [
{ key: 'enattente', label: 'Soumise', icon: '\u{1F4CB}' },
{ key: 'validen1', label: 'Validée N1', icon: '\u2705' },
{ key: 'validen2', label: 'Validée N2', icon: '\u2705' },
{ key: 'approuve', label: 'Approuvée', icon: '\u{1F389}' },
{ key: 'paiementenattente', label: 'En att. paiement', icon: '\u23F3' },
{ key: 'payee', label: 'Payée', icon: '\u{1F4B6}' },
];
function getActiveIndex(s: string): number {
if (s === 'refuse' || s === 'refus') return -1;
if (s === 'payee') return 5;
if (s === 'paiementenattente' || s === 'paiement_en_attente') return 4;
if (s === 'approuve') return 3;
if (['validen2', 'valide_n2', 'validn2'].includes(s)) return 2;
if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 1;
return 0;
}
export const StatutStepper = ({ statut }: { statut: string }) => {
const s = normalizeStatut(statut);
const active = getActiveIndex(s);
if (active === -1) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 16px', background: '#fef2f2',
border: '1.5px solid #fecaca', borderRadius: 12, marginBottom: 16,
}}>
<span style={{ fontSize: 18 }}>\u274C</span>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#dc2626' }}>Note refusée</div>
<div style={{ fontSize: 11, color: '#ef4444' }}>Consultez le motif de refus ci-dessous</div>
</div>
</div>
);
}
return (
<div style={{
padding: '14px 20px', background: 'var(--bg-card)',
border: '1px solid var(--border-card)', borderRadius: 14,
marginBottom: 16, overflowX: 'auto',
}}>
<div style={{ display: 'flex', alignItems: 'center', minWidth: 500 }}>
{STEPS.map((step, i) => {
const isDone = i < active;
const isCurrent = i === active;
return (
<div key={step.key} style={{ display: 'flex', alignItems: 'center', flex: i < STEPS.length - 1 ? 1 : 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: isCurrent ? 18 : 14,
background: isDone ? '#dcfce7' : isCurrent ? 'linear-gradient(135deg,#6366f1,#4f46e5)' : 'var(--bg-input)',
color: isDone ? '#15803d' : isCurrent ? '#fff' : 'var(--text-muted)',
border: isCurrent ? '2px solid #4f46e5' : isDone ? '2px solid #86efac' : '2px solid var(--border-input)',
boxShadow: isCurrent ? '0 0 0 4px rgba(99,102,241,0.15)' : 'none',
flexShrink: 0, transition: 'all 0.3s',
}}>
{isDone ? '\u2713' : step.icon}
</div>
<span style={{
fontSize: 10, fontWeight: isCurrent ? 700 : 500,
color: isDone ? '#15803d' : isCurrent ? '#4f46e5' : 'var(--text-muted)',
whiteSpace: 'nowrap', textAlign: 'center', letterSpacing: '0.2px',
}}>
{step.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div style={{
flex: 1, height: 3, marginBottom: 22, marginLeft: 4, marginRight: 4,
borderRadius: 99,
background: i < active ? 'linear-gradient(90deg,#86efac,#4ade80)' : 'var(--border-input)',
transition: 'background 0.3s',
}} />
)}
</div>
);
})}
</div>
</div>
);
};
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { normalizeStatut } from './helpers';
const STATUT_MAP: Record<string, { bg: string; color: string; label: string }> = {
enattente: { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
en_attente: { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
'en attente': { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
validn1: { bg: '#dbeafe', color: '#1d4ed8', label: 'Validé N1' },
validen1: { bg: '#dbeafe', color: '#1d4ed8', label: 'Validé N1' },
valide_n1: { bg: '#dbeafe', color: '#1d4ed8', label: 'Validé N1' },
validn2: { bg: '#ede9fe', color: '#7c3aed', label: 'Validé N2' },
validen2: { bg: '#ede9fe', color: '#7c3aed', label: 'Validé N2' },
valide_n2: { bg: '#ede9fe', color: '#7c3aed', label: 'Validé N2' },
approuve: { bg: '#dcfce7', color: '#15803d', label: 'Approuvé' },
paiementenattente: { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' },
paiement_en_attente: { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' },
payee: { bg: '#f0fdf4', color: '#16a34a', label: 'Payée' },
refuse: { bg: '#fee2e2', color: '#dc2626', label: 'Refusée' },
refus: { bg: '#fee2e2', color: '#dc2626', label: 'Refusée' },
brouillon: { bg: '#f1f5f9', color: 'var(--text-secondary)', label: 'Brouillon' },
valide: { bg: '#dcfce7', color: '#15803d', label: 'Validé' },
};
export const TagStatut = ({ statut }: { statut: string }) => {
const key = normalizeStatut(statut);
const s = STATUT_MAP[key] ?? STATUT_MAP['brouillon'];
return (
<span style={{
display: 'inline-block', fontSize: 11, fontWeight: 700,
padding: '3px 10px', borderRadius: 20, whiteSpace: 'nowrap',
background: s.bg, color: s.color,
}}>
{s.label}
</span>
);
};
/** Alias fonction pour compatibilité avec l'ancien code inline */
export const tagStatut = (statut: string) => <TagStatut statut={statut} />;
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
const API = '';
interface UrlPreviewPopupProps {
item: { url: string; name: string } | null;
onClose: () => void;
}
export const UrlPreviewPopup = ({ item, onClose }: UrlPreviewPopupProps) => {
if (!item) return null;
const isPDF = /\.pdf$/i.test(item.name);
const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(item.name);
return (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(6px)',
}}>
<div onClick={e => e.stopPropagation()} style={{
background: '#fff', borderRadius: 16,
boxShadow: '0 32px 100px rgba(0,0,0,.5)',
width: isPDF ? 860 : 'auto',
maxWidth: '94vw', maxHeight: '94vh',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 20px',
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{ fontSize: 20, flexShrink: 0 }}>
{isPDF ? '📄' : isImage ? '🖼️' : '📎'}
</span>
<div style={{
color: '#fff', fontWeight: 700, fontSize: 13,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{item.name}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, marginLeft: 16 }}>
<a href={item.url} target="_blank" rel="noreferrer" style={{
padding: '6px 14px', background: 'rgba(255,255,255,0.2)',
border: '1px solid rgba(255,255,255,0.35)', borderRadius: 8,
color: '#fff', fontSize: 12, fontWeight: 600, textDecoration: 'none',
whiteSpace: 'nowrap',
}}> Ouvrir dans un onglet</a>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.2)', border: 'none',
borderRadius: 8, width: 36, height: 36, cursor: 'pointer',
color: '#fff', fontSize: 20, display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}></button>
</div>
</div>
{/* Body */}
<div style={{
flex: 1, overflow: 'auto',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: isPDF ? 0 : 24, background: '#f1f5f9', minHeight: 300,
}}>
{isPDF && (
<iframe
src={`${API}/api/proxy-pdf?url=${encodeURIComponent(item.url)}`}
style={{ width: '100%', height: '80vh', border: 'none' }}
title={item.name} />
)}
{isImage && (
<img src={item.url} alt={item.name} style={{
maxWidth: '85vw', maxHeight: '78vh',
objectFit: 'contain', borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}} />
)}
{!isPDF && !isImage && (
<div style={{ textAlign: 'center', padding: 48 }}>
<div style={{ fontSize: 56, marginBottom: 20 }}>📎</div>
<div style={{ fontSize: 14, color: '#64748b', marginBottom: 20, fontWeight: 600 }}>{item.name}</div>
<a href={item.url} target="_blank" rel="noreferrer" style={{
padding: '12px 24px',
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
color: '#fff', borderRadius: 10, textDecoration: 'none',
fontWeight: 700, fontSize: 14,
}}>Ouvrir le fichier</a>
</div>
)}
</div>
{/* Footer */}
<div style={{
padding: '10px 20px', borderTop: '1px solid #e2e8f0',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: '#fff', flexShrink: 0,
}}>
<span style={{ fontSize: 11, color: '#94a3b8' }}>Cliquez en dehors pour fermer</span>
<a href={item.url} download style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 14px', background: '#eef2ff',
border: '1px solid #c7d2fe', borderRadius: 8,
color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none',
}}> Télécharger</a>
</div>
</div>
</div>
);
};
+60
View File
@@ -0,0 +1,60 @@
// ── HELPERS & UTILITAIRES NDF ──────────────────────────────────────────────
import type { LigneNote } from './types';
export const fmt = (n: number) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n || 0);
export function getHT(ttc: number, taux: number): number {
if (!taux) return ttc;
return parseFloat((ttc / (1 + taux / 100)).toFixed(2));
}
export function getMontantTVA(ttc: number, taux: number): number {
return parseFloat((ttc - getHT(ttc, taux)).toFixed(2));
}
export const normalizeStatut = (s?: string): string =>
s?.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') ?? '';
export const BAREME_KM: Record<number, { t1: number; t2_a: number; t2_b: number; t3: number; label: string }> = {
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369, label: '3 CV et moins' },
4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408, label: '4 CV' },
5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427, label: '5 CV' },
6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448, label: '6 CV' },
7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470, label: '7 CV et plus' },
};
export function getIndemniteKm(kmTotal: number, chevaux: number | string): number {
const cv = Math.min(Math.max(parseInt(String(chevaux)) || 7, 3), 7) as keyof typeof BAREME_KM;
const b = BAREME_KM[cv];
if (kmTotal <= 0) return 0;
if (kmTotal <= 5000) return parseFloat((kmTotal * b.t1).toFixed(2));
if (kmTotal <= 20000) return parseFloat((kmTotal * b.t2_a + b.t2_b).toFixed(2));
return parseFloat((kmTotal * b.t3).toFixed(2));
}
export function getTarifUnitaireKm(kmTotal: number, chevaux: number | string): number {
if (kmTotal <= 0) return 0;
return parseFloat((getIndemniteKm(kmTotal, chevaux) / kmTotal).toFixed(4));
}
export function newLigne(date: string): LigneNote {
return {
idTemp: crypto.randomUUID(),
date,
categorie: '',
libelle: '',
montant: '',
tvaItems: [{ taux: '', montantTTC: '' }],
tauxTVA: '',
km: '',
chevaux: '7',
description: '',
files: [],
participants: [{ nom: '', prenom: '', societe: '' }],
nombreParticipants: '',
qrLink: null,
qrUploaded: false,
qrNoteRef: '',
};
}
+145
View File
@@ -0,0 +1,145 @@
// ── TYPES & INTERFACES NDF ─────────────────────────────────────────────────
export interface Note {
id: number;
reference?: string;
libelle?: string;
categorie?: string;
date?: string;
montant?: number;
statut?: string;
collaborateur?: string;
collaborateurEmail?: string;
departement?: string;
campus?: string;
justificatif?: string;
sharepointUrl?: string;
fichiers?: string;
participants?: string;
nombreParticipants?: number;
validateurN1Id?: number;
validateurN2Id?: number;
nomValidateurN1?: string;
nomValidateurN2?: string;
datePaiement?: string;
moisPaiement?: number;
anneePaiement?: number;
commentaireN1?: string;
commentaireN2?: string;
motifRefus?: string;
montantHT?: number;
tauxTVA?: number;
km?: number;
indemniteKm?: number;
lignesJson?: string;
montantTTC?: number;
sharepointFiles?: { fileName: string; uploadUrl: string }[];
}
export interface Profile {
prenom?: string;
nom?: string;
email?: string;
role?: string;
departement?: string;
campus?: string;
poste?: string;
TypeContrat?: string;
DateEntree?: string;
}
export interface Categorie {
id: number;
Nom: string;
Detail?: string;
Icone?: string;
PlafondParPersonne?: number;
DescriptionPlafond?: string;
}
export interface TauxTVA {
taux: number;
libelle: string;
categorie: string | null;
}
export interface Notification {
id: number;
Type: string;
Titre: string;
Message: string;
NoteDeFraisId?: number;
Lu: boolean;
DateCreation: string;
}
export interface Participant {
nom: string;
prenom: string;
societe?: string;
}
export interface Exception {
id: number;
reference?: string;
montant?: number;
libelle?: string;
collaborateur?: string;
email?: string;
Motif?: string;
Statut?: string;
DateDemande?: string;
}
export interface PaiementConfig {
JourPaiement: number;
}
export interface HistoriqueValidation {
id: number;
reference: string;
libelle: string;
montant: number;
categorie: string;
collaborateur: string;
departement?: string;
campus?: string;
Niveau: string;
Action: string;
Commentaire?: string;
MotifRefus?: string;
NouveauStatut: string;
DateAction: string;
}
export interface LigneNote {
idTemp: string;
date: string;
categorie: string;
libelle: string;
montant: string;
/** TVA multiple : tableau de { taux, montantTTC } */
tvaItems: { taux: string; montantTTC: string }[];
/** Rétrocompatibilité anciens brouillons */
tauxTVA?: string;
km: string;
/** Puissance fiscale 3-7 CV */
chevaux: string;
description: string;
files: File[];
participants: Participant[];
nombreParticipants: string;
qrLink: string | null;
qrUploaded: boolean;
qrNoteRef: string;
}
export type DocStatut = 'en_attente' | 'valide' | 'refuse' | 'absent';
export type DocInfo = {
fileName: string;
sharepointUrl: string;
updatedAt: string;
statut: DocStatut;
commentaire?: string | null;
} | null;
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
export const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px',
border: '1.5px solid var(--border-input)', borderRadius: 8,
background: 'var(--bg-input)', fontFamily: '"Inter","Segoe UI",sans-serif',
fontSize: 14, color: 'var(--text-primary)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.2s',
};
export const inputSm: React.CSSProperties = {
...inputStyle,
padding: '9px 12px',
fontSize: 13,
background: 'var(--bg-input-sm)',
};
export const labelStyle: React.CSSProperties = {
display: 'block', fontSize: 12, fontWeight: 700,
color: 'var(--text-secondary)', textTransform: 'uppercase',
letterSpacing: '0.6px', marginBottom: 6,
};
export const labelSm: React.CSSProperties = {
...labelStyle,
fontSize: 10,
color: 'var(--text-muted)',
marginBottom: 4,
};
export const cardStyle: React.CSSProperties = {
background: 'var(--bg-card)', borderRadius: 16,
border: '1px solid var(--border-card)',
boxShadow: 'var(--shadow-card)',
overflow: 'hidden',
};
export const btnPrimary: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 8,
padding: '11px 22px',
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
color: '#fff', border: 'none', borderRadius: 10,
cursor: 'pointer', fontFamily: '"Inter","Segoe UI",sans-serif',
fontSize: 14, fontWeight: 600,
boxShadow: '0 4px 14px rgba(99,102,241,.4)',
whiteSpace: 'nowrap',
};
export const btnSuccess: React.CSSProperties = {
padding: '7px 16px', background: '#dcfce7', color: '#15803d',
border: '1px solid #bbf7d0', borderRadius: 7, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
};
export const btnDanger: React.CSSProperties = {
padding: '7px 16px', background: '#fee2e2', color: '#dc2626',
border: '1px solid #fecaca', borderRadius: 7, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
};
+233
View File
@@ -0,0 +1,233 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
// ── TYPES ──────────────────────────────────────────────
interface User {
id: number;
name: string;
email: string;
role: string; // chaîne brute ex: "Collaboratrice,Finance"
roles: string[]; // tableau ex: ["Collaboratrice", "Finance"]
}
interface AuthContextType {
user: User | null;
activeRole: string | null;
allRoles: string[];
isMultiRole: boolean;
needsRoleSelection: boolean;
handleCallback: (token: string) => void;
logout: () => void;
selectRole: (role: string) => void;
switchRole: (role: string) => void;
hasRole: (...roles: string[]) => boolean;
isCollaborateur: boolean;
isValidateur: boolean;
isFinance: boolean;
isSuperUser: boolean;
isVerificateurFinance: boolean;
isValidateurFinance: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const API = 'myndf.ensup-adm.net';
const VALID_ROLES = [
'Collaborateur', 'Collaboratrice',
'Validateur', 'Validatrice',
'Finance', 'VerificateurFinance', 'ValidateurFinance',
'superUtilisateur'
];
function parseRoles(input: string | string[]): string[] {
// ✅ Gère les deux cas : tableau JWT ou chaîne BDD
if (Array.isArray(input)) {
return input.filter(r => VALID_ROLES.includes(r));
}
if (!input) return [];
return input
.split(',')
.map(r => r.trim())
.filter(r => VALID_ROLES.includes(r));
}
// ── PROVIDER ───────────────────────────────────────────
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(() => {
const saved = localStorage.getItem('user');
if (!saved) return null;
const u = JSON.parse(saved);
if (!u.roles || !u.roles.length) u.roles = parseRoles(u.role || '');
return u;
});
const [activeRole, setActiveRole] = useState<string | null>(() => {
return localStorage.getItem('activeRole');
});
const [needsRoleSelection, setNeedsRoleSelection] = useState<boolean>(() => {
const saved = localStorage.getItem('user');
const savedRole = localStorage.getItem('activeRole');
if (!saved) return false;
const u = JSON.parse(saved);
const roles = parseRoles(u.roles || u.role || '');
return roles.length > 1 && !savedRole;
});
// ── Revalidation du profil au démarrage ───────────
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) return;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 < Date.now()) {
_clearSession();
return;
}
} catch {
_clearSession();
return;
}
fetch(`${API}/api/profile`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.ok ? r.json() : null)
.then(profile => {
if (!profile) return;
// ✅ On lit profile.roles (tableau depuis UtilisateurRoles)
// avec fallback sur profile.role (ancienne colonne)
const roles = parseRoles(profile.roles || profile.role || '');
const rawRole = roles.join(',');
const refreshed: User = {
id: profile.id,
name: `${profile.prenom} ${profile.nom}`.trim(),
email: profile.email,
role: rawRole,
roles,
};
setUser(refreshed);
localStorage.setItem('user', JSON.stringify(refreshed));
const savedRole = localStorage.getItem('activeRole');
if (savedRole && !roles.includes(savedRole)) {
localStorage.removeItem('activeRole');
setActiveRole(null);
setNeedsRoleSelection(roles.length > 1);
}
})
.catch(() => { });
}, []);
// ── handleCallback ────────────────────────────────
const handleCallback = (token: string) => {
localStorage.setItem('token', token);
const payload = JSON.parse(atob(token.split('.')[1]));
// ✅ Le JWT contient `roles` (tableau), pas `role` (chaîne)
const roles = parseRoles(payload.roles || payload.role || '');
const rawRole = roles.join(',');
const newUser: User = {
id: payload.id,
name: `${payload.prenom} ${payload.nom}`.trim(),
email: payload.email,
role: rawRole,
roles,
};
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
if (roles.length > 1) {
localStorage.removeItem('activeRole');
setActiveRole(null);
setNeedsRoleSelection(true);
} else {
const single = roles[0] ?? 'Collaborateur';
setActiveRole(single);
localStorage.setItem('activeRole', single);
setNeedsRoleSelection(false);
}
};
// ── selectRole ────────────────────────────────────
const selectRole = (role: string) => {
setActiveRole(role);
localStorage.setItem('activeRole', role);
setNeedsRoleSelection(false);
setUser(prev => prev ? { ...prev, role } : prev);
};
// ── switchRole ────────────────────────────────────
const switchRole = (role: string) => {
selectRole(role);
};
// ── logout ────────────────────────────────────────
const logout = () => {
_clearSession();
window.location.href = '/login';
};
const _clearSession = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('activeRole');
setUser(null);
setActiveRole(null);
setNeedsRoleSelection(false);
};
// ── Dérivés ───────────────────────────────────────
const allRoles = user?.roles ?? [];
const isMultiRole = allRoles.length > 1;
const hasRole = (...roles: string[]): boolean =>
!!activeRole && roles.includes(activeRole);
const isCollaborateur = hasRole('Collaborateur', 'Collaboratrice');
const isValidateur = hasRole('Validateur', 'Validatrice');
const isFinance = hasRole('Finance');
const isSuperUser = hasRole('superUtilisateur');
const isVerificateurFinance = hasRole('VerificateurFinance');
const isValidateurFinance = hasRole('ValidateurFinance');
return (
<AuthContext.Provider value={{
user,
activeRole,
allRoles,
isMultiRole,
needsRoleSelection,
handleCallback,
logout,
selectRole,
switchRole,
hasRole,
isCollaborateur,
isValidateur,
isFinance,
isSuperUser,
isVerificateurFinance,
isValidateurFinance
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+162
View File
@@ -0,0 +1,162 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Sun, Moon } from 'lucide-react';
// ── TYPES ──────────────────────────────────────────────
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// ── TOKENS CSS PAR THÈME ──────────────────────────────
const THEMES = {
light: {
// Backgrounds
'--bg-app': '#f1f5f9',
'--bg-main': '#ffffff',
'--bg-card': '#ffffff',
'--bg-input': '#f8fafc',
'--bg-input-sm': '#ffffff',
'--bg-hover': '#f8fafc',
'--bg-thead': '#f8fafc',
'--bg-row-hover': '#f8fafc',
'--bg-notif-unread': '#f0f9ff',
'--bg-sidebar': 'linear-gradient(160deg,#6366f1 0%,#4f46e5 100%)',
// Borders
'--border-main': '#e2e8f0',
'--border-input': '#e2e8f0',
'--border-card': '#e2e8f0',
'--border-divider': '#f1f5f9',
// Text
'--text-primary': '#0f172a',
'--text-secondary': '#64748b',
'--text-muted': '#94a3b8',
'--text-heading': '#0f172a',
// Shadows
'--shadow-card': '0 1px 3px rgba(0,0,0,.06), 0 4px 16px rgba(0,0,0,.04)',
'--shadow-notif': '0 8px 32px rgba(0,0,0,.12)',
// Stat cards
'--stat-bg': '#ffffff',
// Header
'--header-bg': 'transparent',
'--header-btn-bg': '#ffffff',
'--header-btn-border': '#e2e8f0',
},
dark: {
// Backgrounds
'--bg-app': '#0f172a',
'--bg-main': '#1e293b',
'--bg-card': '#1e293b',
'--bg-input': '#0f172a',
'--bg-input-sm': '#0f172a',
'--bg-hover': '#334155',
'--bg-thead': '#0f172a',
'--bg-row-hover': '#0f172a',
'--bg-notif-unread': '#1e3a5f',
'--bg-sidebar': 'linear-gradient(160deg,#312e81 0%,#1e1b4b 100%)',
// Borders
'--border-main': '#334155',
'--border-input': '#334155',
'--border-card': '#334155',
'--border-divider': '#1e293b',
// Text
'--text-primary': '#f1f5f9',
'--text-secondary': '#94a3b8',
'--text-muted': '#64748b',
'--text-heading': '#f8fafc',
// Shadows
'--shadow-card': '0 1px 3px rgba(0,0,0,.3), 0 4px 16px rgba(0,0,0,.2)',
'--shadow-notif': '0 8px 32px rgba(0,0,0,.5)',
// Stat cards
'--stat-bg': '#1e293b',
// Header
'--header-bg': 'transparent',
'--header-btn-bg': '#1e293b',
'--header-btn-border': '#334155',
},
};
// ── INJECTION CSS VARIABLES ───────────────────────────
function applyTheme(theme: Theme) {
const root = document.documentElement;
const tokens = THEMES[theme];
Object.entries(tokens).forEach(([key, val]) => {
root.style.setProperty(key, val);
});
root.setAttribute('data-theme', theme);
}
// ── PROVIDER ──────────────────────────────────────────
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme') as Theme | null;
if (saved === 'light' || saved === 'dark') return saved;
// Détecte la préférence système
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
// Applique les tokens au montage et à chaque changement
useEffect(() => {
applyTheme(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
const isDark = theme === 'dark';
return (
<ThemeContext.Provider value={{ theme, toggleTheme, isDark }}>
{children}
</ThemeContext.Provider>
);
};
// ── HOOK ──────────────────────────────────────────────
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
};
// ── BOUTON TOGGLE (réutilisable partout) ──────────────
export const ThemeToggleButton = ({ style }: { style?: React.CSSProperties }) => {
const { toggleTheme, isDark } = useTheme();
return (
<button
onClick={toggleTheme}
title={isDark ? 'Passer en mode clair' : 'Passer en mode sombre'}
style={{
background: 'var(--header-btn-bg)',
border: '1px solid var(--header-btn-border)',
borderRadius: 10,
width: 40, height: 40,
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-secondary)',
transition: 'all 0.2s',
flexShrink: 0,
...style,
}}
>
{isDark
? <Sun size={18} color="#fbbf24" />
: <Moon size={18} color="#6366f1" />
}
</button>
);
};
+21
View File
@@ -0,0 +1,21 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%);
min-height: 100vh;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
#root {
min-height: 100vh;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const AuthCallback = (): JSX.Element => {
const [searchParams] = useSearchParams();
const { handleCallback } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('token');
const error = searchParams.get('error');
if (error) {
navigate(`/login?error=${error}`);
return;
}
if (token) {
handleCallback(token);
navigate('/dashboard', { replace: true });
} else {
navigate('/login?error=no_token');
}
}, []);
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'sans-serif',
fontSize: '18px',
color: '#f5f5dc',
}}>
Connexion en cours...
</div>
);
};
export default AuthCallback;
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
const API = '';
export default function UploadMobile() {
const { token } = useParams<{ token: string }>();
const [status, setStatus] = useState<'checking' | 'ready' | 'uploading' | 'done' | 'error'>('checking');
const [noteRef, setNoteRef] = useState('');
const [nomPrenom, setNomPrenom] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
fetch(`${API}/api/upload/check/${token}`)
.then(r => r.json())
.then(d => {
if (d.valid) {
setNoteRef(d.noteRef);
setNomPrenom(d.nomPrenom.replace('_', ' '));
setStatus('ready');
} else {
setErrorMsg(d.error || 'Lien invalide');
setStatus('error');
}
})
.catch(() => { setErrorMsg('Erreur réseau'); setStatus('error'); });
}, [token]);
const addFiles = (newFiles: FileList | null) => {
if (!newFiles) return;
const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'];
const filtered = Array.from(newFiles).filter(f => allowed.includes(f.type));
setFiles(prev => [...prev, ...filtered]);
};
const handleSubmit = async () => {
if (!files.length) return;
setStatus('uploading');
const form = new FormData();
files.forEach(f => form.append('files', f));
try {
const res = await fetch(`${API}/api/upload/submit/${token}`, { method: 'POST', body: form });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStatus('done');
} catch (e: any) {
setErrorMsg(e.message);
setStatus('error');
}
};
// ── Écrans ──
if (status === 'checking') return (
<Screen><div style={{ textAlign: 'center' }}><Spinner />Vérification du lien...</div></Screen>
);
if (status === 'error') return (
<Screen>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 56, marginBottom: 12 }}></div>
<h2 style={{ color: '#dc2626', margin: '0 0 8px' }}>Lien invalide</h2>
<p style={{ color: '#64748b', fontSize: 14 }}>{errorMsg}</p>
<p style={{ color: '#94a3b8', fontSize: 12 }}>Ce lien a peut-être expiré (30 min) ou a déjà é utilisé.</p>
</div>
</Screen>
);
if (status === 'done') return (
<Screen>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 64, marginBottom: 12 }}></div>
<h2 style={{ color: '#15803d', margin: '0 0 8px' }}>Justificatif envoyé !</h2>
<p style={{ color: '#374151', fontSize: 15 }}>
Document joint à la note <strong style={{ color: '#6366f1' }}>{noteRef}</strong>
</p>
<p style={{ color: '#64748b', fontSize: 13 }}>Vous pouvez fermer cette page.</p>
</div>
</Screen>
);
return (
<Screen>
<div style={{ marginBottom: 24 }}>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#0f172a' }}>
📎 Justificatif
</h2>
<p style={{ margin: 0, fontSize: 13, color: '#64748b' }}>
Pour <strong>{nomPrenom}</strong> note <strong style={{ color: '#6366f1' }}>{noteRef}</strong>
</p>
</div>
{/* Bouton caméra arrière */}
<label style={uploadZone('#eef2ff', '#6366f1')}>
<span style={{ fontSize: 40, display: 'block', marginBottom: 8 }}>📷</span>
<strong style={{ fontSize: 16, color: '#4f46e5' }}>Prendre une photo</strong>
<span style={{ fontSize: 12, color: '#64748b', display: 'block', marginTop: 4 }}>
Caméra arrière ticket, facture, reçu
</span>
<input type="file" accept="image/*" capture="environment" multiple
style={{ display: 'none' }} onChange={e => addFiles(e.target.files)} />
</label>
{/* Bouton galerie / PDF */}
<label style={{ ...uploadZone('#f8fafc', '#cbd5e1'), marginTop: 12 }}>
<span style={{ fontSize: 32, display: 'block', marginBottom: 4 }}>📁</span>
<strong style={{ fontSize: 14, color: '#374151' }}>Depuis la galerie ou PDF</strong>
<input type="file" accept="image/*,application/pdf" multiple
style={{ display: 'none' }} onChange={e => addFiles(e.target.files)} />
</label>
{/* Aperçu fichiers */}
{files.length > 0 && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
{files.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: '#f0fdf4', border: '1px solid #a7f3d0', borderRadius: 12 }}>
<span style={{ fontSize: 20 }}>{f.type === 'application/pdf' ? '📄' : '🖼️'}</span>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</div>
<div style={{ fontSize: 11, color: '#64748b' }}>
{f.size < 1048576 ? `${(f.size / 1024).toFixed(0)} Ko` : `${(f.size / 1048576).toFixed(1)} Mo`}
</div>
</div>
{f.type.startsWith('image/') && (
<img src={URL.createObjectURL(f)} alt="" style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8 }} />
)}
<button onClick={() => setFiles(files.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', fontSize: 20, padding: '0 4px' }}></button>
</div>
))}
</div>
)}
<button
onClick={handleSubmit}
disabled={files.length === 0 || status === 'uploading'}
style={{
marginTop: 24, width: '100%', padding: '16px',
background: files.length === 0 ? '#e2e8f0' : 'linear-gradient(135deg,#6366f1,#4f46e5)',
color: files.length === 0 ? '#94a3b8' : '#fff',
border: 'none', borderRadius: 14, fontSize: 16, fontWeight: 700,
cursor: files.length === 0 ? 'not-allowed' : 'pointer',
fontFamily: '"Inter","Segoe UI",sans-serif',
}}>
{status === 'uploading' ? '⏳ Envoi en cours...' : `📤 Envoyer${files.length > 0 ? ` (${files.length} fichier${files.length > 1 ? 's' : ''})` : ''}`}
</button>
</Screen>
);
}
// ── Helpers ──
function Screen({ children }: { children: React.ReactNode }) {
return (
<div style={{ minHeight: '100vh', background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontFamily: '"Inter","Segoe UI",sans-serif' }}>
<div style={{ background: '#fff', borderRadius: 24, padding: 32, maxWidth: 420, width: '100%', boxShadow: '0 8px 32px rgba(0,0,0,.12)' }}>
{children}
</div>
</div>
);
}
function Spinner() {
return <div style={{ width: 32, height: 32, border: '3px solid #e2e8f0', borderTop: '3px solid #6366f1', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 12px' }} />;
}
function uploadZone(bg: string, border: string): React.CSSProperties {
return { display: 'block', background: bg, border: `2px dashed ${border}`, borderRadius: 16, padding: '24px 20px', textAlign: 'center', cursor: 'pointer' };
}
+859
View File
@@ -0,0 +1,859 @@
import { useState, useEffect } from 'react';
import { Eye, ChevronDown, ChevronUp, Clock, CheckCircle, XCircle, History, FileText, Car, Utensils, Package, MapPin } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Fichier { fileName: string; uploadUrl: string; }
interface LigneDepense {
qrNoteRef?: string; categorie?: string; libelle?: string;
date?: string; montant?: string; km?: string; chevaux?: string;
tauxTVA?: string; description?: string;
nombreParticipants?: string; participants?: { nom: string; prenom: string; societe?: string }[];
tvaItems?: { taux: string; montantTTC: string }[];
}
interface NonConforme {
fileName: string;
motif: string;
statut: string;
dateSignalement: string;
}
interface NoteAVerifier {
id: number; reference?: string; libelle?: string; collaborateur?: string;
campus?: string; departement?: string; date?: string; montant?: number;
statut?: string; lignesJson?: string; fichiers?: string;
nonConformes?: NonConforme[];
}
interface JustifState { status: 'ok' | 'nok' | 'pending'; comment?: string; }
interface ModalNok { noteId: number; fileKey: string; fileName: string; }
interface VerifHistorique {
id: number; reference?: string; libelle?: string; collaborateur?: string;
campus?: string; departement?: string; montant?: number;
dateVerification: string; commentaire?: string;
nbJustifs: number; nbConformes: number; nbNonConformes: number;
lignesJson?: string; fichiers?: string;
nonConformes?: NonConforme[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n);
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
const getIndemniteKm = (km: number, cv: number) => {
const BAREME: Record<number, number> = { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
};
const buildKey = (noteId: number, scope: string, idx: number) => `${noteId}-${scope}-${idx}`;
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
const isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
const isApprovalFile = (f: Fichier) => {
const n = (f.fileName ?? '').toLowerCase();
return (n.includes('signe') || n.includes('signé')) && (n.includes('approuve') || n.includes('approuvé'));
};
const getCatMeta = (cat?: string) => {
const c = (cat || '').toLowerCase();
if (c.includes('kilom')) return { icon: Car, color: '#7c3aed', bg: '#ede9fe', emoji: '🚗' };
if (c.includes('repas') || c.includes('restaurant')) return { icon: Utensils, color: '#d97706', bg: '#fef3c7', emoji: '🍽️' };
if (c.includes('transport') || c.includes('avion') || c.includes('train')) return { icon: MapPin, color: '#0369a1', bg: '#e0f2fe', emoji: '🚆' };
if (c.includes('hebergement') || c.includes('hotel')) return { icon: Package, color: '#059669', bg: '#dcfce7', emoji: '🏨' };
return { icon: Package, color: '#6366f1', bg: '#eef2ff', emoji: '📋' };
};
interface Props {
notesAVerifier: NoteAVerifier[];
onVerified: (noteId: number) => void;
API: string;
hdrs: Record<string, string>;
proxyUrl?: (url: string) => string;
setPreviewUrl?: (v: { url: string; name: string }) => void;
}
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
.vf2-root {
--purple:#5b21b6;--purple-light:#ede9fe;--purple-xlight:#f5f3ff;
--green:#15803d;--green-light:#dcfce7;--red:#dc2626;--red-light:#fee2e2;
--border:#e5e7eb;--muted:#6b7280;--text:#111827;--card:#ffffff;
font-family:'DM Sans',system-ui,sans-serif;display:flex;flex-direction:column;gap:0;
}
.vf2-tabs{display:flex;gap:2px;background:#f3f4f6;border-radius:12px;padding:4px;margin-bottom:20px;}
.vf2-tab{flex:1;padding:9px 16px;border:none;border-radius:9px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600;background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center;gap:7px;transition:all .2s;}
.vf2-tab.active{background:#fff;color:var(--purple);box-shadow:0 1px 4px rgba(0,0,0,.1);}
.vf2-tab-badge{background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;border-radius:20px;}
.vf2-tab.active .vf2-tab-badge{background:var(--purple);}
.vf2-banner{display:flex;align-items:center;gap:14px;padding:14px 18px;background:linear-gradient(135deg,#f5f3ff,#ede9fe);border-radius:14px;border:1px solid #d8b4fe;margin-bottom:16px;}
.vf2-banner-icon{width:40px;height:40px;border-radius:12px;background:var(--purple);display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.vf2-banner-title{font-size:13px;font-weight:700;color:var(--purple);}
.vf2-banner-sub{font-size:12px;color:#6d28d9;margin-top:2px;}
.vf2-banner-count{margin-left:auto;background:var(--purple);color:#fff;font-size:13px;font-weight:700;padding:4px 14px;border-radius:20px;white-space:nowrap;}
.vf2-card{background:var(--card);border-radius:14px;border:1px solid var(--border);overflow:hidden;box-shadow:0 1px 6px rgba(0,0,0,.06);transition:box-shadow .2s;margin-bottom:12px;}
.vf2-card:hover{box-shadow:0 4px 16px rgba(91,33,182,.1);}
.vf2-card-trigger{width:100%;display:flex;align-items:center;padding:0;background:none;border:none;cursor:pointer;text-align:left;font-family:inherit;}
.vf2-card-header{flex:1;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
.vf2-card-chevron{padding:16px 18px;display:flex;align-items:center;border-left:1px solid var(--border);color:var(--muted);}
.vf2-ref-badge{font-size:11px;font-weight:700;color:var(--purple);background:var(--purple-light);padding:2px 9px;border-radius:20px;font-family:'DM Mono',monospace;display:inline-block;margin-bottom:5px;}
.vf2-card-title{font-size:15px;font-weight:700;color:var(--text);}
.vf2-card-meta{font-size:12px;color:var(--muted);margin-top:3px;}
.vf2-card-amount{font-size:22px;font-weight:800;color:var(--purple);text-align:right;}
.vf2-card-depcount{font-size:11px;color:var(--muted);text-align:right;margin-top:2px;}
.vf2-progress-wrap{padding:10px 20px;border-top:1px solid #f3f4f6;background:var(--purple-xlight);}
.vf2-progress-bar{height:5px;background:#e5e7eb;border-radius:99px;overflow:hidden;margin-top:6px;}
.vf2-progress-fill{height:100%;border-radius:99px;transition:width .4s ease;}
.vf2-pills{display:flex;gap:6px;flex-wrap:wrap;}
.vf2-pill{font-size:10px;font-weight:700;padding:2px 9px;border-radius:20px;white-space:nowrap;}
.vf2-pill-ok{background:var(--green-light);color:var(--green);}
.vf2-pill-nok{background:var(--red-light);color:var(--red);}
.vf2-pill-wait{background:#f3f4f6;color:var(--muted);}
.vf2-body{padding:12px 16px 16px;display:flex;flex-direction:column;gap:12px;}
.vf2-section-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding-bottom:6px;border-bottom:1px solid #f0f0f0;margin-bottom:8px;}
/* ── Bloc dépense ── */
.vfc-bloc{border:1px solid rgba(0,0,0,.08);border-radius:10px;overflow:hidden;margin-bottom:0;}
.vfc-bloc.all-ok{border-color:#86efac;}
.vfc-bloc.has-nok{border-color:#fca5a5;}
.vfc-dep-row{display:flex;align-items:center;gap:8px;padding:9px 12px;background:#f9fafb;}
.vfc-num{width:20px;height:20px;border-radius:50%;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.vfc-icon{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;}
.vfc-info{flex:1;min-width:0;}
.vfc-label{font-size:12px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.vfc-sub{font-size:10px;color:var(--muted);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.vfc-price{font-size:13px;font-weight:800;flex-shrink:0;font-family:'DM Mono',monospace;}
.vfc-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
.vfc-badge-km{background:#ede9fe;color:#7c3aed;}
.vfc-badge-miss{background:#fee2e2;color:#dc2626;}
/* ── Justifs inline (toujours visibles) ── */
.vfc-justifs{border-top:1px solid #f0f0f0;display:flex;flex-direction:column;}
.vfc-jrow{display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid #f5f5f5;}
.vfc-jrow:last-child{border-bottom:none;}
.vfc-jrow.ok{background:#f0fdf4;}
.vfc-jrow.nok{background:#fff5f5;}
.vfc-jrow.pending{background:#fff;}
.vfc-jicon{font-size:14px;flex-shrink:0;}
.vfc-jinfo{flex:1;min-width:0;}
.vfc-jname{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vfc-jname.ok{color:var(--green);}
.vfc-jname.nok{color:var(--red);}
.vfc-jname.pending{color:var(--text);}
.vfc-jreason{font-size:10px;color:var(--red);font-style:italic;margin-top:1px;}
.vfc-jstatus{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
.vfc-jstatus.ok{background:var(--green-light);color:var(--green);}
.vfc-jstatus.nok{background:var(--red-light);color:var(--red);}
.vfc-jstatus.pending{background:#f3f4f6;color:var(--muted);}
.vfc-no-km{font-size:11px;color:var(--green);background:var(--green-light);padding:6px 12px;font-weight:600;}
/* Boutons */
.vf2-btn-voir{display:flex;align-items:center;gap:3px;padding:4px 8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:5px;cursor:pointer;font-family:inherit;font-size:10px;font-weight:600;color:#374151;flex-shrink:0;white-space:nowrap;}
.vf2-btn-voir:hover{background:#f0f0f0;}
.vf2-btn-check{width:26px;height:26px;border-radius:6px;flex-shrink:0;border:1.5px solid;cursor:pointer;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;transition:all .12s;background:none;}
.vf2-btn-ok-on{border-color:#16a34a;background:#f0fdf4 !important;color:#16a34a;}
.vf2-btn-ok-off{border-color:#d1d5db;color:#d1d5db;}
.vf2-btn-nok-on{border-color:var(--red);background:#fff5f5 !important;color:var(--red);}
.vf2-btn-nok-off{border-color:#d1d5db;color:#d1d5db;}
/* Approbation */
.vf2-approval-box{border:1px solid #d8b4fe;border-radius:10px;overflow:hidden;}
.vf2-approval-head{display:flex;align-items:center;gap:10px;padding:9px 14px;background:linear-gradient(90deg,#f5f3ff,#ede9fe);border-bottom:1px solid #e9d5ff;}
.vf2-approval-label{font-size:12px;font-weight:700;color:var(--purple);}
.vf2-approval-files{padding:6px 10px;display:flex;flex-direction:column;gap:4px;}
/* Footer */
.vf2-footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:12px 16px;border-top:1.5px solid #f3f4f6;background:var(--purple-xlight);}
.vf2-footer-msg{font-size:12px;color:var(--muted);}
.vf2-footer-msg.ok{color:var(--green);font-weight:700;}
.vf2-footer-msg.nok{color:var(--red);font-weight:700;}
.vf2-btn-submit{padding:10px 20px;background:var(--purple);color:#fff;border:none;border-radius:9px;font-family:inherit;font-size:13px;font-weight:700;white-space:nowrap;box-shadow:0 4px 14px rgba(91,33,182,.3);cursor:pointer;transition:all .15s;}
.vf2-btn-submit:hover:not(:disabled){background:#4c1d95;transform:translateY(-1px);}
.vf2-btn-submit:disabled{opacity:.35;cursor:not-allowed;}
.vf2-modal-backdrop{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);padding:1rem;}
.vf2-modal{background:#fff;border-radius:16px;width:100%;max-width:460px;border:1px solid #e5e7eb;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.18);}
.vf2-empty{background:#fafafa;border:1px solid var(--border);border-radius:14px;padding:64px 24px;text-align:center;}
.vf2-hist-card{background:#fff;border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);}
.vf2-hist-head{padding:14px 18px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;background:#fafafa;border-bottom:1px solid #f0f0f0;}
.vf2-hist-body{padding:12px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
.vf2-hist-stat{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;}
.vf2-hist-comment{font-size:12px;color:var(--muted);font-style:italic;padding:8px 11px;background:#f9fafb;border-radius:8px;border:1px solid #f0f0f0;margin-top:6px;}
`;
export default function VerificateurFinanceLight({
notesAVerifier: initialNotes, onVerified, API, hdrs,
proxyUrl = u => u, setPreviewUrl,
}: Props) {
const [notes, setNotes] = useState<NoteAVerifier[]>(initialNotes);
const [justifStates, setJustifStates] = useState<Record<string, JustifState>>({});
const [modalNok, setModalNok] = useState<ModalNok | null>(null);
const [nokReason, setNokReason] = useState('');
const [nokSending, setNokSending] = useState(false);
const [notifyOnNok, setNotifyOnNok] = useState(true);
const [expandedNotes, setExpandedNotes] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [history, setHistory] = useState<VerifHistorique[]>([]);
const [historyLoaded, setHistoryLoaded] = useState(false);
const [expandedHistory, setExpandedHistory] = useState<Set<number>>(new Set());
const toggleHistory = (id: number) => setExpandedHistory(prev => {
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
});
// ✅ Restaurer les états non-conformes depuis la BDD au chargement
useEffect(() => {
setNotes(initialNotes);
if (!initialNotes.length) return;
const newStates: Record<string, JustifState> = {};
for (const note of initialNotes) {
const ncs = note.nonConformes;
if (!ncs || ncs.length === 0) continue;
let tousLesFichiers: Fichier[] = [];
try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
const justificatifsReels = tousLesFichiers.filter(f =>
!SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw))
);
let lignesData: LigneDepense[] = [];
try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
const fichiersGlobaux = allQrRefs.length > 0
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
: justificatifsReels;
for (const nc of ncs) {
// Chercher dans les fichiers globaux (distribués par ligne)
const idxGlobal = fichiersGlobaux.findIndex(f => f.fileName === nc.fileName);
if (idxGlobal !== -1) {
for (let li = 0; li < lignesData.length; li++) {
const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
if (idxGlobal >= start && idxGlobal < end) {
newStates[buildKey(note.id, `global${li}`, idxGlobal - start)] = { status: 'nok', comment: nc.motif };
break;
}
}
continue;
}
// Chercher dans les fichiers par ligne (scope ligne{li})
for (let li = 0; li < lignesData.length; li++) {
const qr = lignesData[li].qrNoteRef || '';
if (!qr) continue;
const filesLigne = justificatifsReels.filter(f =>
f.fileName?.includes(qr) || f.uploadUrl?.includes(qr)
);
const idxLigne = filesLigne.findIndex(f => f.fileName === nc.fileName);
if (idxLigne !== -1) {
newStates[buildKey(note.id, `ligne${li}`, idxLigne)] = { status: 'nok', comment: nc.motif };
break;
}
}
// Chercher dans les fichiers d'approbation
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
const idxApprob = fichierApprobation.findIndex(f => f.fileName === nc.fileName);
if (idxApprob !== -1) {
newStates[buildKey(note.id, 'approbation', idxApprob)] = { status: 'nok', comment: nc.motif };
}
}
}
if (Object.keys(newStates).length > 0) {
setJustifStates(prev => ({ ...prev, ...newStates }));
}
}, [initialNotes]);
const toggleNote = (id: number) => setExpandedNotes(prev => {
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
});
const setJustif = (key: string, status: JustifState['status'], comment?: string) =>
setJustifStates(prev => ({ ...prev, [key]: { status, comment } }));
const openNok = (noteId: number, fileKey: string, fileName: string) => {
setModalNok({ noteId, fileKey, fileName });
setNokReason(justifStates[fileKey]?.comment || '');
};
const loadHistory = async () => {
if (historyLoaded) return;
try {
const res = await fetch(`${API}/api/verificateur/historique`, { headers: hdrs });
if (res.ok) { const d = await res.json(); if (Array.isArray(d)) setHistory(d); }
} catch { }
setHistoryLoaded(true);
};
const handleTabChange = (tab: 'pending' | 'history') => {
setActiveTab(tab);
if (tab === 'history') loadHistory();
};
// ── Justificatif inline toujours visible ─────────────────────────────────
const renderJRow = (f: Fichier, stateKey: string, noteId: number) => {
const state = justifStates[stateKey] ?? { status: 'pending' };
const cls = state.status;
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
const label = cls === 'ok' ? 'Conforme' : cls === 'nok' ? 'Non conforme' : 'En attente';
return (
<div key={stateKey} className={`vfc-jrow ${cls}`}>
<span className="vfc-jicon">{isImg ? '🖼️' : '📄'}</span>
<div className="vfc-jinfo">
<div className={`vfc-jname ${cls}`}>{short}</div>
{cls === 'nok' && state.comment && <div className="vfc-jreason"> {state.comment}</div>}
</div>
<span className={`vfc-jstatus ${cls}`}>{label}</span>
{setPreviewUrl && (
<button className="vf2-btn-voir"
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
<Eye size={11} /> Voir
</button>
)}
<button className={`vf2-btn-check ${cls === 'ok' ? 'vf2-btn-ok-on' : 'vf2-btn-ok-off'}`}
title="Conforme" onClick={() => setJustif(stateKey, 'ok')}></button>
<button className={`vf2-btn-check ${cls === 'nok' ? 'vf2-btn-nok-on' : 'vf2-btn-nok-off'}`}
title="Non conforme" onClick={() => openNok(noteId, stateKey, f.fileName)}></button>
</div>
);
};
// ── Bloc dépense ─────────────────────────────────────────────────────────
// ✅ FIX : lignesData est maintenant passé en paramètre
const renderBloc = (
l: LigneDepense, li: number, note: NoteAVerifier,
justificatifsReels: Fichier[], fichiersGlobaux: Fichier[], allQrRefs: string[],
lignesData: LigneDepense[] // ← paramètre ajouté
) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km || '0') || 0;
const cv = parseInt(l.chevaux || '7') || 7;
const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
const taux = parseFloat(l.tauxTVA || '0') || 0;
const ht = (!isKm && taux > 0) ? price / (1 + taux / 100) : null;
const cat = getCatMeta(l.categorie);
const qrRef = l.qrNoteRef || '';
// ✅ FIX : distribution équitable des fichiers globaux entre les dépenses
const files: Fichier[] = qrRef
? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
: (allQrRefs.length === 0
? fichiersGlobaux.slice(
Math.floor(li * fichiersGlobaux.length / lignesData.length),
Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
)
: []);
// ✅ FIX : scope différent par ligne même en mode global
const scope = qrRef ? `ligne${li}` : `global${li}`;
const depOk = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'ok').length;
const depNok = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'nok').length;
const blockCls = depNok > 0 ? 'has-nok' : (depOk === files.length && files.length > 0 ? 'all-ok' : '');
const sub: string[] = [];
if (l.categorie) sub.push(l.categorie);
if (l.date) sub.push(fmtDate(l.date));
if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
if (!isKm && taux > 0 && ht !== null) sub.push(`HT ${fmt(ht)} · TVA ${taux}%`);
if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
if (l.description) sub.push(l.description);
return (
<div key={li} className={`vfc-bloc ${blockCls}`}>
<div className="vfc-dep-row">
<div className="vfc-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
<div className="vfc-icon" style={{ background: cat.bg }}>{cat.emoji}</div>
<div className="vfc-info">
<div className="vfc-label">{l.libelle || l.categorie || '—'}</div>
<div className="vfc-sub">{sub.join(' · ')}</div>
</div>
<span className="vfc-price" style={{ color: cat.color }}>{fmt(price)}</span>
{files.length === 0 && isKm && <span className="vfc-badge vfc-badge-km">km</span>}
{files.length === 0 && !isKm && <span className="vfc-badge vfc-badge-miss"> manquant</span>}
</div>
{files.length > 0 && (
<div className="vfc-justifs">
{files.map((f, fi) => renderJRow(f, buildKey(note.id, scope, fi), note.id))}
</div>
)}
{files.length === 0 && isKm && (
<div className="vfc-no-km"> Indemnité kilométrique aucun justificatif requis</div>
)}
</div>
);
};
return (
<>
<style>{CSS}</style>
<div className="vf2-root">
{/* Tabs */}
<div className="vf2-tabs">
<button className={`vf2-tab ${activeTab === 'pending' ? 'active' : ''}`} onClick={() => handleTabChange('pending')}>
<Eye size={14} /> À vérifier
{notes.length > 0 && <span className="vf2-tab-badge">{notes.length}</span>}
</button>
<button className={`vf2-tab ${activeTab === 'history' ? 'active' : ''}`} onClick={() => handleTabChange('history')}>
<History size={14} /> Historique
{history.length > 0 && <span className="vf2-tab-badge" style={{ background: activeTab === 'history' ? '#5b21b6' : '#6b7280' }}>{history.length}</span>}
</button>
</div>
{/* ── PENDING ── */}
{activeTab === 'pending' && (
<>
{notes.length === 0 ? (
<div className="vf2-empty">
<Eye size={44} color="#a78bfa" style={{ display: 'block', margin: '0 auto 14px' }} />
<p style={{ fontSize: 14, fontWeight: 700, color: '#5b21b6', marginBottom: 6 }}>Aucune note en attente</p>
<span style={{ fontSize: 12, color: '#6b7280' }}>Les notes approuvées apparaîtront ici</span>
</div>
) : notes.map(note => {
const isOpen = expandedNotes.has(note.id);
let lignesData: LigneDepense[] = [];
try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
let tousLesFichiers: Fichier[] = [];
try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
const justificatifsReels = tousLesFichiers.filter(
f => !isSystemFile(f) && !isApprovalFile(f)
);
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
const fichiersGlobaux = allQrRefs.length > 0
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
: justificatifsReels;
// ✅ FIX : allKeys cohérent avec la distribution par ligne
const allKeys: string[] = [];
if (allQrRefs.length === 0) {
lignesData.forEach((_, li) => {
const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
fichiersGlobaux.slice(start, end).forEach((_, fi) =>
allKeys.push(buildKey(note.id, `global${li}`, fi))
);
});
} else {
lignesData.forEach((l, li) => {
const qr = l.qrNoteRef || '';
if (qr) {
justificatifsReels
.filter(f => f.fileName?.includes(qr) || f.uploadUrl?.includes(qr))
.forEach((_, fi) => allKeys.push(buildKey(note.id, `ligne${li}`, fi)));
}
});
fichiersGlobaux.forEach((_, fi) => allKeys.push(buildKey(note.id, 'global0', fi)));
}
fichierApprobation.forEach((_, fi) => allKeys.push(buildKey(note.id, 'approbation', fi)));
const okAll = allKeys.filter(k => justifStates[k]?.status === 'ok').length;
const nokAll = allKeys.filter(k => justifStates[k]?.status === 'nok').length;
const pendingAll = allKeys.length - okAll - nokAll;
const pct = allKeys.length > 0 ? Math.round(((okAll + nokAll) / allKeys.length) * 100) : 0;
const allChecked = allKeys.length > 0 && pendingAll === 0;
return (
<div key={note.id} className="vf2-card">
<button className="vf2-card-trigger" onClick={() => toggleNote(note.id)}>
<div className="vf2-card-header">
<div>
<div className="vf2-ref-badge">{note.reference}</div>
<div className="vf2-card-title">{note.libelle}</div>
<div className="vf2-card-meta">
{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}
</div>
</div>
<div>
<div className="vf2-card-amount">{fmt(note.montant || 0)}</div>
<div className="vf2-card-depcount">{lignesData.length} dépense{lignesData.length > 1 ? 's' : ''}</div>
</div>
</div>
<div className="vf2-card-chevron">
{isOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</div>
</button>
{allKeys.length > 0 && (
<div className="vf2-progress-wrap">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 10, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '.5px' }}>
Conformité {pct}%
</span>
<div className="vf2-pills">
{okAll > 0 && <span className="vf2-pill vf2-pill-ok">{okAll} conforme{okAll > 1 ? 's' : ''}</span>}
{nokAll > 0 && <span className="vf2-pill vf2-pill-nok">{nokAll} non conforme{nokAll > 1 ? 's' : ''}</span>}
{pendingAll > 0 && <span className="vf2-pill vf2-pill-wait">{pendingAll} en attente</span>}
</div>
</div>
<div className="vf2-progress-bar">
<div className="vf2-progress-fill" style={{ width: `${pct}%`, background: nokAll > 0 ? '#ef4444' : '#5b21b6' }} />
</div>
</div>
)}
{isOpen && (
<div className="vf2-body">
<div>
<div className="vf2-section-label">Dépenses & justificatifs</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* ✅ FIX : lignesData passé en dernier argument */}
{lignesData.map((l, li) =>
renderBloc(l, li, note, justificatifsReels, fichiersGlobaux, allQrRefs, lignesData)
)}
</div>
{lignesData.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', marginTop: 8, background: 'linear-gradient(90deg,#eef2ff,#f0fdf4)', border: '1px solid #c7d2fe', borderRadius: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280' }}>TOTAL {lignesData.length} dépenses</span>
<span style={{ fontSize: 15, fontWeight: 900, color: '#111827', fontFamily: 'DM Mono, monospace' }}>{fmt(note.montant || 0)}</span>
</div>
)}
</div>
{fichierApprobation.length > 0 && (
<div>
<div className="vf2-section-label">Document d'approbation</div>
<div className="vf2-approval-box">
<div className="vf2-approval-head">
<FileText size={15} color="#7c3aed" />
<span className="vf2-approval-label">PDF d'approbation signé</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#6b7280' }}>{fichierApprobation.length} fichier{fichierApprobation.length > 1 ? 's' : ''}</span>
</div>
<div className="vf2-approval-files">
{fichierApprobation.map((f, fi) => renderJRow(f, buildKey(note.id, 'approbation', fi), note.id))}
</div>
</div>
</div>
)}
<div className="vf2-footer">
<div className={`vf2-footer-msg ${allChecked ? 'ok' : nokAll > 0 ? 'nok' : ''}`}>
{allChecked && nokAll === 0 ? '✓ Tous les justificatifs sont conformes'
: allChecked && nokAll > 0 ? `⚠️ ${nokAll} non conforme${nokAll > 1 ? 's' : ''} — vérification possible`
: nokAll > 0 && pendingAll > 0 ? `${nokAll} non conforme${nokAll > 1 ? 's' : ''} · ${pendingAll} en attente`
: pendingAll > 0 ? `${pendingAll} justificatif${pendingAll > 1 ? 's' : ''} encore en attente`
: allKeys.length === 0 ? 'Aucun justificatif à vérifier' : ''}
</div>
<button className="vf2-btn-submit" disabled={!allChecked && allKeys.length > 0}
onClick={async () => {
const commentaire = window.prompt('Commentaire de vérification (optionnel)', '') ?? '';
if (commentaire === null) return;
try {
const res = await fetch(`${API}/api/verificateur/notes/${note.id}/verifier`, {
method: 'PUT', headers: hdrs, body: JSON.stringify({ commentaire }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
onVerified(note.id);
setNotes(prev => prev.filter(n => n.id !== note.id));
setJustifStates(prev => {
const next = { ...prev };
allKeys.forEach(k => delete next[k]);
return next;
});
setHistoryLoaded(false);
setHistory([]);
setActiveTab('history');
handleTabChange('history');
} catch (e: any) { alert(e.message); }
}}>
Marquer comme vérifié Notifier Validateur Finance
</button>
</div>
</div>
)}
</div>
);
})}
</>
)}
{/* ── HISTORY ── */}
{activeTab === 'history' && (
<div>
<div className="vf2-banner" style={{ background: 'linear-gradient(135deg,#f0fdf4,#dcfce7)', borderColor: '#86efac' }}>
<div className="vf2-banner-icon" style={{ background: '#15803d' }}><History size={20} color="#fff" /></div>
<div>
<div className="vf2-banner-title" style={{ color: '#15803d' }}>Historique des vérifications</div>
<div className="vf2-banner-sub" style={{ color: '#166534' }}>Traçabilité complète de vos contrôles Finance</div>
</div>
{history.length > 0 && <div className="vf2-banner-count" style={{ background: '#15803d' }}>{history.length} vérifiée{history.length > 1 ? 's' : ''}</div>}
</div>
{!historyLoaded ? (
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#6b7280' }}>
<Clock size={36} style={{ display: 'block', margin: '0 auto 12px', opacity: .4 }} />
<p style={{ fontSize: 13 }}>Chargement de l'historique…</p>
</div>
) : history.length === 0 ? (
<div className="vf2-empty">
<History size={44} color="#a78bfa" style={{ display: 'block', margin: '0 auto 14px' }} />
<p style={{ fontSize: 14, fontWeight: 700, color: '#5b21b6', marginBottom: 6 }}>Aucune vérification effectuée</p>
<span style={{ fontSize: 12, color: '#6b7280' }}>Les notes vérifiées apparaîtront ici</span>
</div>
) : history.map((h, i) => {
const isOpen = expandedHistory.has(h.id);
let lignesData: LigneDepense[] = [];
try { if (h.lignesJson) lignesData = JSON.parse(h.lignesJson); } catch { }
let tousLesFichiers: Fichier[] = [];
try { tousLesFichiers = h.fichiers ? JSON.parse(h.fichiers as string) : []; } catch { }
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
const justificatifsReels = tousLesFichiers.filter(f => !isSystemFile(f) && !isApprovalFile(f));
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
const fichiersGlobaux = allQrRefs.length > 0
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
: justificatifsReels;
return (
<div key={i} className="vf2-hist-card">
{/* ── Header cliquable ── */}
<button
onClick={() => toggleHistory(h.id)}
style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: 0, fontFamily: 'inherit' }}>
<div className="vf2-hist-head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontFamily: 'DM Mono,monospace', fontSize: 11, fontWeight: 700, color: '#5b21b6', background: '#ede9fe', padding: '2px 8px', borderRadius: 20 }}>{h.reference}</span>
<CheckCircle size={13} color="#15803d" />
<span style={{ fontSize: 11, fontWeight: 700, color: '#15803d' }}>Vérifiée</span>
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{h.libelle}</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
</div>
<div style={{ textAlign: 'right', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#15803d' }}>{fmt(h.montant || 0)}</div>
<div style={{ fontSize: 11, color: '#6b7280' }}>
<Clock size={10} style={{ display: 'inline', marginRight: 4 }} />
{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b7280' }}>
{isOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
<span>{isOpen ? 'Réduire' : 'Voir les justificatifs'}</span>
</div>
</div>
</div>
</button>
{/* ── Stats toujours visibles ── */}
<div className="vf2-hist-body">
<div className="vf2-hist-stat"><FileText size={13} color="#6b7280" /><span style={{ color: '#6b7280' }}>{h.nbJustifs} justificatif{h.nbJustifs > 1 ? 's' : ''}</span></div>
{h.nbConformes > 0 && <div className="vf2-hist-stat"><CheckCircle size={13} color="#15803d" /><span style={{ color: '#15803d' }}>{h.nbConformes} conforme{h.nbConformes > 1 ? 's' : ''}</span></div>}
{h.nbNonConformes > 0 && <div className="vf2-hist-stat"><XCircle size={13} color="#dc2626" /><span style={{ color: '#dc2626' }}>{h.nbNonConformes} non conforme{h.nbNonConformes > 1 ? 's' : ''}</span></div>}
{h.commentaire && <div style={{ width: '100%' }}><div className="vf2-hist-comment">💬 {h.commentaire}</div></div>}
</div>
{/* ── Détail dépenses + justificatifs (accordéon) ── */}
{isOpen && (
<div style={{ padding: '0 16px 16px', borderTop: '1px solid #f0f0f0', display: 'flex', flexDirection: 'column', gap: 12 }}>
{lignesData.length > 0 && (
<div style={{ marginTop: 12 }}>
<div className="vf2-section-label">Dépenses & justificatifs</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{lignesData.map((l, li) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km || '0') || 0;
const cv = parseInt(l.chevaux || '7') || 7;
const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
const cat = getCatMeta(l.categorie);
const qrRef = l.qrNoteRef || '';
const files: Fichier[] = qrRef
? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
: (allQrRefs.length === 0
? fichiersGlobaux.slice(
Math.floor(li * fichiersGlobaux.length / lignesData.length),
Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
)
: []);
const nonConformesLigne = (h.nonConformes || []).filter(nc =>
files.some(f => f.fileName === nc.fileName)
);
const sub: string[] = [];
if (l.categorie) sub.push(l.categorie);
if (l.date) sub.push(fmtDate(l.date));
if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
if (l.description) sub.push(l.description);
return (
<div key={li} className={`vfc-bloc ${nonConformesLigne.length > 0 ? 'has-nok' : files.length > 0 ? 'all-ok' : ''}`}>
<div className="vfc-dep-row">
<div className="vfc-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
<div className="vfc-icon" style={{ background: cat.bg }}>{cat.emoji}</div>
<div className="vfc-info">
<div className="vfc-label">{l.libelle || l.categorie || ''}</div>
<div className="vfc-sub">{sub.join(' · ')}</div>
</div>
<span className="vfc-price" style={{ color: cat.color }}>{fmt(price)}</span>
{files.length === 0 && isKm && <span className="vfc-badge vfc-badge-km">km</span>}
{files.length === 0 && !isKm && <span className="vfc-badge vfc-badge-miss">⚠ manquant</span>}
</div>
{files.length > 0 && (
<div className="vfc-justifs">
{files.map((f, fi) => {
const nc = nonConformesLigne.find(nc => nc.fileName === f.fileName);
const status = nc ? 'nok' : 'ok';
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '' : f.fileName;
return (
<div key={fi} className={`vfc-jrow ${status}`}>
<span className="vfc-jicon">{isImg ? '🖼' : '📄'}</span>
<div className="vfc-jinfo">
<div className={`vfc-jname ${status}`}>{short}</div>
{nc && <div className="vfc-jreason">↳ {nc.motif}</div>}
</div>
<span className={`vfc-jstatus ${status}`}>{status === 'ok' ? 'Conforme' : 'Non conforme'}</span>
{setPreviewUrl && (
<button className="vf2-btn-voir"
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
<Eye size={11} /> Voir
</button>
)}
</div>
);
})}
</div>
)}
{files.length === 0 && isKm && (
<div className="vfc-no-km">✓ Indemnité kilométrique — aucun justificatif requis</div>
)}
</div>
);
})}
</div>
{lignesData.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', marginTop: 8, background: 'linear-gradient(90deg,#eef2ff,#f0fdf4)', border: '1px solid #c7d2fe', borderRadius: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280' }}>TOTAL — {lignesData.length} dépenses</span>
<span style={{ fontSize: 15, fontWeight: 900, color: '#111827', fontFamily: 'DM Mono, monospace' }}>{fmt(h.montant || 0)}</span>
</div>
)}
</div>
)}
{fichierApprobation.length > 0 && (
<div>
<div className="vf2-section-label">Document d'approbation</div>
<div className="vf2-approval-box">
<div className="vf2-approval-head">
<FileText size={15} color="#7c3aed" />
<span className="vf2-approval-label">PDF d'approbation signé</span>
</div>
<div className="vf2-approval-files">
{fichierApprobation.map((f, fi) => {
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '' : f.fileName;
return (
<div key={fi} className="vfc-jrow ok">
<span className="vfc-jicon">{isImg ? '🖼' : '📄'}</span>
<div className="vfc-jinfo">
<div className="vfc-jname ok">{short}</div>
</div>
<span className="vfc-jstatus ok">Signé</span>
{setPreviewUrl && (
<button className="vf2-btn-voir"
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
<Eye size={11} /> Voir
</button>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
})
}
</div>
)}
{/* ── Modal NOK ── */}
{modalNok && (
<div className="vf2-modal-backdrop" onClick={() => { setModalNok(null); setNokReason(''); }}>
<div className="vf2-modal" onClick={e => e.stopPropagation()}>
<div style={{ padding: '16px 20px', display: 'flex', alignItems: 'center', gap: 12, borderBottom: '1px solid #fee2e2', background: '#fff5f5' }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', background: '#fee2e2', border: '1px solid #fca5a5', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, flexShrink: 0 }}>⚠️</div>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: '#991b1b' }}>Justificatif non conforme</div>
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>Précisez le motif pour notifier le collaborateur</div>
</div>
</div>
<div style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8 }}>
<span style={{ fontSize: 18 }}>📄</span>
<div>
<div style={{ fontSize: 11, color: '#6b7280' }}>Fichier concerné</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827', marginTop: 1 }}>{modalNok.fileName}</div>
</div>
</div>
<div>
<label style={{ fontSize: 12, color: '#374151', fontWeight: 600, display: 'block', marginBottom: 6 }}>
Motif de non-conformité <span style={{ color: '#dc2626' }}>*</span>
</label>
<textarea value={nokReason} onChange={e => setNokReason(e.target.value)} autoFocus
placeholder="Ex : Justificatif illisible, montant différent, facture non conforme TVA…"
style={{ width: '100%', minHeight: 80, resize: 'vertical', padding: '8px 10px', fontSize: 13, borderRadius: 8, border: `1.5px solid ${nokReason.trim() ? '#d1d5db' : '#fca5a5'}`, fontFamily: 'DM Sans,sans-serif', color: '#111827', background: '#fff', boxSizing: 'border-box' as const }} />
{!nokReason.trim() && <div style={{ fontSize: 11, color: '#dc2626', marginTop: 4 }}>Le motif est obligatoire</div>}
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 14px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={notifyOnNok} onChange={e => setNotifyOnNok(e.target.checked)} style={{ marginTop: 2, accentColor: '#dc2626', cursor: 'pointer', flexShrink: 0 }} />
<div>
<div style={{ fontWeight: 600, color: '#111827', fontSize: 13 }}>Notifier par email</div>
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>Le collaborateur et son validateur N1 recevront un email avec le motif</div>
</div>
</label>
</div>
<div style={{ padding: '14px 20px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 10, background: '#f9fafb' }}>
<button onClick={() => { setModalNok(null); setNokReason(''); }}
style={{ padding: '8px 16px', background: '#fff', border: '1px solid #d1d5db', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, color: '#374151', fontWeight: 500 }}>
Annuler
</button>
<button disabled={!nokReason.trim() || nokSending}
onClick={async () => {
if (!nokReason.trim() || !modalNok) return;
setNokSending(true);
try {
const res = await fetch(`${API}/api/verificateur/notes/${modalNok.noteId}/non-conforme`, {
method: 'POST',
headers: hdrs,
body: JSON.stringify({
fileName: modalNok.fileName,
motif: nokReason.trim(),
notifier: notifyOnNok,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erreur');
setJustifStates(prev => ({ ...prev, [modalNok.fileKey]: { status: 'nok', comment: nokReason.trim() } }));
setModalNok(null);
setNokReason('');
} catch (e: any) { alert(e.message); }
finally { setNokSending(false); }
}}
style={{ padding: '8px 18px', background: nokReason.trim() && !nokSending ? '#dc2626' : '#fca5a5', color: '#fff', border: 'none', borderRadius: 8, cursor: nokReason.trim() && !nokSending ? 'pointer' : 'not-allowed', fontFamily: 'inherit', fontSize: 13, fontWeight: 700, transition: 'background .15s' }}>
{nokSending ? 'Envoi' : 'Confirmer non conforme'}
</button>
</div>
</div>
</div>
)}
</div>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import * as React from 'react';
declare module 'react-qr-code' {
export interface QRCodeProps extends React.SVGProps<SVGSVGElement> {
value: string;
size?: number;
level?: 'L' | 'M' | 'Q' | 'H';
bgColor?: string;
fgColor?: string;
style?: React.CSSProperties;
}
const QRCode: React.FC<QRCodeProps>;
export default QRCode;
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') }
},
server: {
host: '0.0.0.0',
port: 81,
strictPort: true,
allowedHosts: ['myndf.ensup-adm.net', 'localhost'],
historyApiFallback: true, // ✅ routes SPA
proxy: {
'/api': {
target: 'http://backend:3024',
changeOrigin: true,
secure: false,
ws: true
},
'/proxy-file': {
target: 'http://backend:3024',
changeOrigin: true,
secure: false
}
}
}
});