Premier commit

This commit is contained in:
2026-09-11 14:55:41 +02:00
commit 8ed04aae89
134 changed files with 36627 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+75
View File
@@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SI Finance</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3611
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.101.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.62.0",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+101
View File
@@ -0,0 +1,101 @@
// src/App.tsx — connexion → portail → module
import { useEffect } from 'react';
import { Routes, Route, Navigate, Outlet, useNavigate, useSearchParams } from 'react-router-dom';
import { Box, Typography } from '@mui/material';
import { useAuth } from './auth';
import Layout from './Layout';
import SousTraitantsLayout from './SousTraitantsLayout';
import Login from './pages/Login';
import Portal from './pages/Portal';
import Dashboard from './pages/Dashboard';
import Demandes from './pages/Demandes';
import DemandeForm from './pages/DemandeForm';
import Contratsfournisseurs from './pages/Contratsfournisseurs';
import Paiements from './pages/Paiements';
import Pilotage from './pages/Pilotage';
import Fournisseurs from './pages/Fournisseurs';
import FacturesAvoirs from './pages/facturesAvoirs';
import Archives from './pages/Archives';
import DashboardST from './pages/soustraitants/DashboardST';
import CahierDesCharges from './pages/soustraitants/CahierDesCharges';
import ContratsPrestataires from './pages/soustraitants/ContratsPrestataires';
import Prestations from './pages/soustraitants/Prestations';
import FacturesST from './pages/soustraitants/FacturesST';
import ControleConformite from './pages/soustraitants/ControleConformite';
import Arbitrage from './pages/soustraitants/Arbitrage';
import PaiementsST from './pages/soustraitants/PaiementsST';
import PilotageST from './pages/soustraitants/PilotageST';
import ArchivesST from './pages/soustraitants/ArchivesST';
function RequireAuth() {
const { authed } = useAuth();
return authed ? <Outlet /> : <Navigate to="/login" replace />;
}
// Point d'atterrissage après /api/auth/callback côté backend, qui redirige ici avec
// le jeton "maison" en paramètre d'URL une fois l'échange OAuth2 terminé.
function AuthCallback() {
const [searchParams] = useSearchParams();
const { handleCallback } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('token');
if (token) {
handleCallback(token);
navigate('/portal', { replace: true });
} else {
navigate('/login', { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}
function Placeholder({ titre }: { titre: string }) {
return (
<Box>
<Typography variant="h5" mb={1}>{titre}</Typography>
<Typography color="text.secondary">Écran à venir.</Typography>
</Box>
);
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<RequireAuth />}>
<Route path="/portal" element={<Portal />} />
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/demandes" element={<Demandes />} />
<Route path="/demandes/nouvelle" element={<DemandeForm />} />
<Route path="/demandes/:id/modifier" element={<DemandeForm />} />
<Route path="/facturesavoirs" element={<FacturesAvoirs />} />
<Route path="/paiements" element={<Paiements />} />
<Route path="/fournisseurs" element={<Fournisseurs />} />
<Route path="/rapprochement" element={<Placeholder titre="Rapprochement bancaire" />} />
<Route path="/pilotage" element={<Pilotage />} />
<Route path="/archives" element={<Archives />} />
<Route path="/contrats" element={<Contratsfournisseurs />} />
</Route>
<Route path="/soustraitants" element={<SousTraitantsLayout />}>
<Route index element={<DashboardST />} />
<Route path="cahier-des-charges" element={<CahierDesCharges />} />
<Route path="contrats" element={<ContratsPrestataires />} />
<Route path="prestations" element={<Prestations />} />
<Route path="factures" element={<FacturesST />} />
<Route path="conformite" element={<ControleConformite />} />
<Route path="arbitrage" element={<Arbitrage />} />
<Route path="paiements" element={<PaiementsST />} />
<Route path="pilotage" element={<PilotageST />} />
<Route path="archives" element={<ArchivesST />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
+269
View File
@@ -0,0 +1,269 @@
// src/Layout.tsx — coquille claire, sidebar rétractable au survol
import { useState, useEffect, createContext, useContext, type ReactNode } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
Box, Drawer, List, ListItemButton, ListItemIcon, ListItemText, Typography, Stack, Chip, Avatar, Tooltip,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { achatsApi, financeApi } from './data';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import { tk } from './theme';
import { Logo } from './ui';
import { useAuth } from './auth';
// --- Contexte du rôle (espace de travail) ---
export type Role = 'collaborateur' | 'finance';
const WorkspaceCtx = createContext<{ role: Role; setRole: (r: Role) => void }>({ role: 'finance', setRole: () => {} });
export const useWorkspace = () => useContext(WorkspaceCtx);
const RAIL = 74; // largeur réduite (icônes)
const FULL = 252; // largeur déployée
type BadgeKey = 'demandes' | 'factures';
type NavItem = { label: string; path: string; icon: ReactNode; badgeKey?: BadgeKey };
type NavGroup = { section: string; items: NavItem[] };
// Les badges reflètent des compteurs réels (demandes en attente de validation,
// factures en litige ou échues) — calculés dans le composant à partir des données
// mises en cache par React Query, pas des valeurs figées.
const navByRole: Record<Role, NavGroup[]> = {
collaborateur: [
{ section: 'Pilotage', items: [
{ label: 'Tableau de bord', path: '/', icon: <DashboardOutlinedIcon /> },
{ label: 'Pilotage', path: '/pilotage', icon: <BarChartOutlinedIcon /> },
] },
{ section: 'Mes achats', items: [
{ label: 'Mes demandes', path: '/demandes', icon: <ReceiptLongOutlinedIcon />, badgeKey: 'demandes' },
] },
{ section: 'Documents', items: [
{ label: 'Archives', path: '/archives', icon: <FolderOutlinedIcon /> },
{ label: 'Contrats', path: '/contrats', icon: <FolderOutlinedIcon /> },
] },
],
finance: [
{ section: 'Pilotage', items: [
{ label: 'Tableau de bord', path: '/', icon: <DashboardOutlinedIcon /> },
{ label: 'Pilotage', path: '/pilotage', icon: <BarChartOutlinedIcon /> },
] },
{ section: 'Achats', items: [
{ label: "Demandes d'achat", path: '/demandes', icon: <ReceiptLongOutlinedIcon />, badgeKey: 'demandes' },
{ label: 'Fournisseurs', path: '/fournisseurs', icon: <StorefrontOutlinedIcon /> },
] },
{ section: 'Facturation', items: [
{ label: 'Factures & Avoirs', path: '/facturesavoirs', icon: <DescriptionOutlinedIcon />, badgeKey: 'factures' },
{ label: 'Paiements', path: '/paiements', icon: <PaymentsOutlinedIcon /> },
] },
{ section: 'Documents', items: [
{ label: 'Contrats', path: '/contrats', icon: <FolderOutlinedIcon /> },
] },
],
};
// Une demande est actionnable par son responsable dès sa soumission (SOUMISE), pas
// seulement une fois basculée en EN_VALIDATION — le badge doit refléter ça dès le départ.
const DEMANDES_A_VALIDER = new Set(['SOUMISE', 'EN_VALIDATION']);
const posteByRole: Record<Role, string> = {
collaborateur: 'Collaborateur',
finance: 'Finance',
};
const initialesDe = (nom: string) =>
nom.split(' ').filter(Boolean).slice(0, 2).map((s) => s[0]?.toUpperCase()).join('');
function RoleToggle({ role, setRole }: { role: Role; setRole: (r: Role) => void }) {
return (
<Box sx={{ px: 2.5 }}>
<Typography sx={{ fontSize: 11, letterSpacing: '0.06em', color: tk.faint, mb: 1, whiteSpace: 'nowrap' }}>ESPACE DE TRAVAIL</Typography>
<Box sx={{ display: 'flex', bgcolor: tk.canvas, borderRadius: 2, p: 0.5, gap: 0.5, border: `1px solid ${tk.line}` }}>
{(['collaborateur', 'finance'] as Role[]).map((r) => (
<Box key={r} onClick={() => setRole(r)}
sx={{
flex: 1, textAlign: 'center', py: 0.75, borderRadius: 1.5, cursor: 'pointer', fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap',
color: role === r ? tk.ink : tk.muted,
bgcolor: role === r ? tk.surface : 'transparent',
boxShadow: role === r ? '0 1px 2px rgba(16,24,40,0.06)' : 'none',
}}>
{r === 'collaborateur' ? 'Collaborateur' : 'Finance'}
</Box>
))}
</Box>
</Box>
);
}
export default function Layout() {
const [open, setOpen] = useState(false); // déployée au survol
const navigate = useNavigate();
const location = useLocation();
const isActive = (p: string) => (p === '/' ? location.pathname === '/' : location.pathname.startsWith(p));
// Le nom/les initiales viennent du compte Microsoft connecté. L'espace de travail
// (Collaborateur/Finance) est verrouillé sur le Profil de la ligne core.utilisateurs
// (colonne alimentée par la synchro Entra ID) SAUF si l'utilisateur cumule le rôle
// FINANCE (table core.utilisateur_roles) en plus de son profil de base — dans ce cas
// le sélecteur reste actif pour qu'il puisse basculer entre ses deux casquettes (ex.
// quelqu'un qui soumet ses propres demandes ET valide celles des autres).
const { displayName, roles, estResponsable } = useAuth();
const hasFinanceRole = roles.includes('FINANCE');
// Seul le rôle explicite FINANCE dans utilisateur_roles ouvre l'espace Finance.
// Un profil Entra ou le statut Responsable conserve l'espace Collaborateur.
const roleLocked: Role | null = hasFinanceRole ? null : 'collaborateur';
const [role, setRoleState] = useState<Role>(roleLocked ?? 'finance');
useEffect(() => {
if (roleLocked) setRoleState(roleLocked);
}, [roleLocked]);
const setRole = roleLocked ? () => {} : setRoleState;
// Les contrats ne sont visibles que pour un responsable de service. Finance y
// accède dans son propre espace, avec des droits de consultation uniquement.
const groups = navByRole[role].map((group) => ({
...group,
items: group.items.filter((item) => item.path !== '/contrats' || hasFinanceRole || estResponsable),
})).filter((group) => group.items.length > 0);
const w = open ? FULL : RAIL;
const nomAffiche = displayName ?? '—';
const initiales = initialesDe(nomAffiche) || '—';
// Mêmes queryKey que dans les pages : React Query dédoublonne les appels et sert le
// cache, donc plus de refetch à chaque navigation. scope=mine n'est forcé que pour un
// utilisateur Finance qui a explicitement basculé en espace Collaborateur (vue étroite
// volontaire) — pour tous les autres (demandeur simple ou responsable sans rôle
// FINANCE), on laisse le backend décider (ses demandes + celles de ses collaborateurs
// s'il en est responsable), sinon un responsable ne verrait jamais ce qu'il doit valider.
const demandesScope = role === 'collaborateur' && hasFinanceRole ? 'mine' : undefined;
const { data: demandes = [] } = useQuery({ queryKey: ['demandes', demandesScope], queryFn: () => achatsApi.getDemandes(demandesScope) });
const { data: factures = [] } = useQuery({ queryKey: ['factures'], queryFn: financeApi.getFactures });
const today = new Date();
today.setHours(0, 0, 0, 0);
const badges: Record<BadgeKey, number> = {
// peutValider : sinon un utilisateur Finance qui n'est responsable de personne
// verrait un badge alors qu'il n'a rien à valider lui-même.
demandes: demandes.filter((d) => d.peutValider && DEMANDES_A_VALIDER.has(d.statut)).length,
factures: factures.filter((f) => f.statut === 'litige' || (f.statut !== 'payee' && new Date(f.dateEcheance) < today)).length,
};
return (
<WorkspaceCtx.Provider value={{ role, setRole }}>
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: tk.canvas }}>
{/* Le rail réserve toujours RAIL px ; la barre déployée se superpose (overlay) sans décaler le contenu */}
<Box sx={{ width: RAIL, flexShrink: 0 }} />
<Drawer
variant="permanent"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
sx={{
width: w, flexShrink: 0, whiteSpace: 'nowrap',
'& .MuiDrawer-paper': {
width: w, boxSizing: 'border-box', display: 'flex', flexDirection: 'column',
overflowX: 'hidden', overflowY: 'auto',
bgcolor: tk.surface,
borderRight: `1px solid ${tk.line}`,
transition: 'width .18s ease',
boxShadow: open ? '4px 0 24px rgba(16,24,40,0.08)' : 'none',
},
}}
>
{/* Logo — le texte n'apparaît qu'en déployé */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ px: 2, py: 2.5, minHeight: 40 }}>
<Logo />
{open && (
<Box sx={{ overflow: 'hidden' }}>
<Typography sx={{ fontWeight: 700, fontSize: 16, letterSpacing: '-0.01em' }} noWrap>SI Finance</Typography>
<Typography sx={{ fontSize: 12, color: tk.faint }} noWrap>Module Achats</Typography>
</Box>
)}
</Stack>
{open && (
<>
<Box sx={{ px: 2, mb: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center" onClick={() => navigate('/portal')}
sx={{ px: 1, py: 1, borderRadius: 1.5, border: `1px solid ${tk.line}`, cursor: 'pointer', color: tk.muted, '&:hover': { color: tk.ink, bgcolor: tk.canvas } }}>
<ChevronLeftIcon fontSize="small" />
<Typography sx={{ fontSize: 13 }} noWrap>Retour au portail</Typography>
</Stack>
</Box>
{!roleLocked && <RoleToggle role={role} setRole={setRole} />}
</>
)}
<List sx={{ mt: open ? 2 : 2, flexGrow: 1, px: open ? 1 : 0.75 }}>
{groups.map((group) => (
<Box key={group.section} sx={{ mb: 0.5 }}>
{open && (
<Typography sx={{
fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.08em',
color: tk.faint, px: 1.25, pt: 1.25, pb: 0.5,
}}>
{group.section}
</Typography>
)}
{group.items.map((item) => {
const active = isActive(item.path);
const badgeValue = item.badgeKey ? badges[item.badgeKey] : 0;
const bouton = (
<ListItemButton key={item.path} selected={active} onClick={() => navigate(item.path)}
sx={{
justifyContent: open ? 'flex-start' : 'center', px: open ? 1.25 : 1.5,
borderRadius: 1.5, mb: 0.25,
borderLeft: active ? `2px solid ${tk.primary}` : '2px solid transparent',
bgcolor: active ? tk.primarySoft : 'transparent',
'&:hover': { bgcolor: active ? tk.primarySoft : tk.canvas },
'&.Mui-selected': { bgcolor: tk.primarySoft },
'&.Mui-selected:hover': { bgcolor: tk.primarySoft },
}}>
<ListItemIcon sx={{ minWidth: 0, mr: open ? 1.5 : 0, color: active ? tk.primary : tk.muted, justifyContent: 'center' }}>
{item.icon}
</ListItemIcon>
{open && (
<>
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: 13, fontWeight: active ? 600 : 500, color: active ? tk.primary : tk.ink, noWrap: true }} />
{badgeValue > 0 && <Chip label={badgeValue} size="small" sx={{ height: 20, minWidth: 20, bgcolor: tk.lineSoft, color: tk.muted }} />}
</>
)}
</ListItemButton>
);
// En mode réduit : infobulle avec le nom (accessibilité + tactile)
return open ? bouton : (
<Tooltip key={item.path} title={item.label} placement="right" arrow>
<span>{bouton}</span>
</Tooltip>
);
})}
</Box>
))}
</List>
{/* Utilisateur */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ px: open ? 2.5 : 0, py: 2, borderTop: `1px solid ${tk.line}`, justifyContent: open ? 'flex-start' : 'center' }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: tk.primarySoft, color: tk.primary, fontSize: 13, fontWeight: 600 }}>{initiales}</Avatar>
{open && (
<Box sx={{ overflow: 'hidden' }}>
<Typography sx={{ fontSize: 13, fontWeight: 600 }} noWrap>{nomAffiche}</Typography>
<Typography sx={{ fontSize: 11, color: tk.faint }} noWrap>
{role === 'collaborateur' && estResponsable ? 'Responsable' : posteByRole[role]}
</Typography>
</Box>
)}
</Stack>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 4, minWidth: 0 }}>
<Outlet />
</Box>
</Box>
</WorkspaceCtx.Provider>
);
}
+165
View File
@@ -0,0 +1,165 @@
// src/SousTraitantsLayout.tsx — coquille du Module Sous-Traitants, même structure que Layout.tsx
// (sidebar rétractable au survol) mais nav dédiée et accent vert pour distinguer le module.
import { useState, type ReactNode } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
Box, Drawer, List, ListItemButton, ListItemIcon, ListItemText, Typography, Stack, Chip, Avatar, Tooltip,
} from '@mui/material';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
import FactCheckOutlinedIcon from '@mui/icons-material/FactCheckOutlined';
import GavelOutlinedIcon from '@mui/icons-material/GavelOutlined';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined';
import ArchiveOutlinedIcon from '@mui/icons-material/ArchiveOutlined';
import { tk } from './theme';
import { useAuth } from './auth';
const RAIL = 74;
const FULL = 252;
const initialesDe = (nom: string) =>
nom.split(' ').filter(Boolean).slice(0, 2).map((s) => s[0]?.toUpperCase()).join('');
type NavItem = { label: string; path: string; icon: ReactNode; badge?: number };
const NAV: { section: string; items: NavItem[] }[] = [
{ section: 'Pilotage', items: [
{ label: 'Tableau de bord', path: '/soustraitants', icon: <DashboardOutlinedIcon /> },
] },
{ section: 'Prestataires', items: [
{ label: 'Cahier des charges', path: '/soustraitants/cahier-des-charges', icon: <DescriptionOutlinedIcon /> },
{ label: 'Contrats', path: '/soustraitants/contrats', icon: <FolderOutlinedIcon /> },
{ label: 'Prestations', path: '/soustraitants/prestations', icon: <GroupsOutlinedIcon />, badge: 3 },
] },
{ section: 'Facturation', items: [
{ label: 'Factures', path: '/soustraitants/factures', icon: <ReceiptLongOutlinedIcon />, badge: 1 },
{ label: 'Paiements', path: '/soustraitants/paiements', icon: <PaymentsOutlinedIcon /> },
] },
{ section: 'Suivi', items: [
{ label: 'Contrôle conformité', path: '/soustraitants/conformite', icon: <FactCheckOutlinedIcon /> },
{ label: 'Arbitrage', path: '/soustraitants/arbitrage', icon: <GavelOutlinedIcon />, badge: 1 },
{ label: 'Pilotage', path: '/soustraitants/pilotage', icon: <BarChartOutlinedIcon /> },
] },
{ section: 'Documents', items: [
{ label: 'Archives', path: '/soustraitants/archives', icon: <ArchiveOutlinedIcon /> },
] },
];
export default function SousTraitantsLayout() {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const isActive = (p: string) => (p === '/soustraitants' ? location.pathname === '/soustraitants' : location.pathname.startsWith(p));
const w = open ? FULL : RAIL;
const { displayName } = useAuth();
const nomAffiche = displayName ?? 'Camille Laurent';
const initiales = initialesDe(nomAffiche) || 'CL';
return (
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: tk.canvas }}>
<Box sx={{ width: RAIL, flexShrink: 0 }} />
<Drawer
variant="permanent"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
sx={{
width: w, flexShrink: 0, whiteSpace: 'nowrap',
'& .MuiDrawer-paper': {
width: w, boxSizing: 'border-box', display: 'flex', flexDirection: 'column',
overflowX: 'hidden', overflowY: 'auto',
bgcolor: tk.surface,
borderRight: `1px solid ${tk.line}`,
transition: 'width .18s ease',
boxShadow: open ? '4px 0 24px rgba(16,24,40,0.08)' : 'none',
},
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ px: 2, py: 2.5, minHeight: 40 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: tk.greenSoft, color: tk.green, fontSize: 13, fontWeight: 700 }}>ST</Avatar>
{open && (
<Box sx={{ overflow: 'hidden' }}>
<Typography sx={{ fontWeight: 700, fontSize: 16, letterSpacing: '-0.01em' }} noWrap>SI Finance</Typography>
<Typography sx={{ fontSize: 12, color: tk.faint }} noWrap>Module Sous-Traitants</Typography>
</Box>
)}
</Stack>
{open && (
<Box sx={{ px: 2, mb: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center" onClick={() => navigate('/portal')}
sx={{ px: 1, py: 1, borderRadius: 1.5, border: `1px solid ${tk.line}`, cursor: 'pointer', color: tk.muted, '&:hover': { color: tk.ink, bgcolor: tk.canvas } }}>
<ChevronLeftIcon fontSize="small" />
<Typography sx={{ fontSize: 13 }} noWrap>Retour au portail</Typography>
</Stack>
</Box>
)}
<List sx={{ mt: 2, flexGrow: 1, px: open ? 1 : 0.75 }}>
{NAV.map((group) => (
<Box key={group.section} sx={{ mb: 0.5 }}>
{open && (
<Typography sx={{
fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.08em',
color: tk.faint, px: 1.25, pt: 1.25, pb: 0.5,
}}>
{group.section}
</Typography>
)}
{group.items.map((item) => {
const active = isActive(item.path);
const bouton = (
<ListItemButton key={item.path} selected={active} onClick={() => navigate(item.path)}
sx={{
justifyContent: open ? 'flex-start' : 'center', px: open ? 1.25 : 1.5,
borderRadius: 1.5, mb: 0.25,
borderLeft: active ? `2px solid ${tk.green}` : '2px solid transparent',
bgcolor: active ? tk.greenSoft : 'transparent',
'&:hover': { bgcolor: active ? tk.greenSoft : tk.canvas },
'&.Mui-selected': { bgcolor: tk.greenSoft },
'&.Mui-selected:hover': { bgcolor: tk.greenSoft },
}}>
<ListItemIcon sx={{ minWidth: 0, mr: open ? 1.5 : 0, color: active ? tk.green : tk.muted, justifyContent: 'center' }}>
{item.icon}
</ListItemIcon>
{open && (
<>
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: 13, fontWeight: active ? 600 : 500, color: active ? tk.green : tk.ink, noWrap: true }} />
{item.badge != null && <Chip label={item.badge} size="small" sx={{ height: 20, minWidth: 20, bgcolor: tk.lineSoft, color: tk.muted }} />}
</>
)}
</ListItemButton>
);
return open ? bouton : (
<Tooltip key={item.path} title={item.label} placement="right" arrow>
<span>{bouton}</span>
</Tooltip>
);
})}
</Box>
))}
</List>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ px: open ? 2.5 : 0, py: 2, borderTop: `1px solid ${tk.line}`, justifyContent: open ? 'flex-start' : 'center' }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: tk.greenSoft, color: tk.green, fontSize: 13, fontWeight: 600 }}>{initiales}</Avatar>
{open && (
<Box sx={{ overflow: 'hidden' }}>
<Typography sx={{ fontSize: 13, fontWeight: 600 }} noWrap>{nomAffiche}</Typography>
<Typography sx={{ fontSize: 11, color: tk.faint }} noWrap>Service Finance · Sous-Traitants</Typography>
</Box>
)}
</Stack>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 4, minWidth: 0 }}>
<Outlet />
</Box>
</Box>
);
}
+71
View File
@@ -0,0 +1,71 @@
// src/auth.tsx — connexion Microsoft / Entra ID pilotée par le backend : login()
// redirige simplement vers SIFinance.Api, qui gère tout le flux OAuth2 (authorization
// code + PKCE) avec Entra ID et renvoie ici avec un jeton "maison" en paramètre d'URL
// (voir la route /auth/callback dans App.tsx, et EntraAuthEndpoints.cs côté backend).
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { API_BASE_URL, getToken, setToken, clearToken, decodeToken, type JwtClaims } from './authToken';
type AuthCtx = {
authed: boolean;
userId: number | null;
displayName: string | null;
profil: string | null;
roles: string[];
estResponsable: boolean;
serviceId: number | null;
login: () => void;
logout: () => void;
handleCallback: (token: string) => void;
};
const Ctx = createContext<AuthCtx>({
authed: false, userId: null, displayName: null, profil: null, roles: [], estResponsable: false, serviceId: null,
login: () => {}, logout: () => {}, handleCallback: () => {},
});
export const useAuth = () => useContext(Ctx);
export function AuthProvider({ children }: { children: ReactNode }) {
const [claims, setClaims] = useState<JwtClaims | null>(() => {
const token = getToken();
return token ? decodeToken(token) : null;
});
useEffect(() => {
// Jeton expiré (session de 8h dépassée) : on nettoie plutôt que de laisser l'app
// croire l'utilisateur connecté jusqu'au premier appel API qui échouerait en 401.
if (claims && claims.exp * 1000 < Date.now()) {
clearToken();
setClaims(null);
}
}, [claims]);
const login = () => {
window.location.href = `${API_BASE_URL}/api/auth/microsoft`;
};
const logout = () => {
clearToken();
setClaims(null);
};
const handleCallback = (token: string) => {
setToken(token);
setClaims(decodeToken(token));
};
const roles = claims?.roles == null ? [] : Array.isArray(claims.roles) ? claims.roles : [claims.roles];
const value: AuthCtx = {
authed: !!claims,
userId: claims ? Number(claims.id) : null,
displayName: claims ? `${claims.prenom} ${claims.nom}`.trim() : null,
profil: claims?.profil ?? null,
roles,
estResponsable: claims?.estResponsable === 'true',
serviceId: claims?.serviceId != null ? Number(claims.serviceId) : null,
login,
logout,
handleCallback,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
+51
View File
@@ -0,0 +1,51 @@
// src/authToken.ts — stockage du jeton "maison" émis par SIFinance.Api après la
// connexion Entra ID. Module séparé (plutôt que dans auth.tsx ou data.ts) pour éviter
// une dépendance circulaire : auth.tsx en a besoin pour le contexte React, data.ts en
// a besoin pour attacher le jeton aux appels fetch.
export const API_BASE_URL = 'http://127.0.0.1:5272';
const TOKEN_KEY = 'sifinance_token';
export function getToken(): string | null {
return sessionStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
sessionStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
sessionStorage.removeItem(TOKEN_KEY);
}
export type JwtClaims = {
id: string;
email: string;
nom: string;
prenom: string;
profil: string;
// Un claim JWT répété (un par rôle) est sérialisé par .NET en tableau s'il y en a
// plusieurs, mais en simple chaîne s'il n'y en a qu'un seul — d'où le type union.
roles?: string | string[];
estResponsable?: string;
serviceId?: string;
exp: number;
};
// atob() seul corrompt les caractères non-ASCII (accents des noms/prénoms français) —
// le détour par decodeURIComponent restitue un décodage UTF-8 correct.
export function decodeToken(token: string): JwtClaims | null {
try {
const payload = token.split('.')[1];
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
.join(''),
);
return JSON.parse(json);
} catch {
return null;
}
}
+512
View File
@@ -0,0 +1,512 @@
// src/data.ts — types du domaine + données mock + couche API
// Les méthodes de référence/demandes de achatsApi appellent désormais le vrai backend
// (SIFinance.Api, module Achats). getEcheances/getAlertes/getDepensesParService restent
// mockées : pas encore d'endpoint côté API pour ces agrégats (aucune page ne les consomme
// pour l'instant — Dashboard.tsx et Pilotage.tsx ont leurs propres données locales).
import { API_BASE_URL, getToken, clearToken } from './authToken';
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
// Content-Type n'est ajouté que s'il y a un corps à envoyer : le poser sur les GET/DELETE
// sans body transforme la requête en requête CORS "non simple" et déclenche un aller-retour
// OPTIONS (preflight) supplémentaire avant chaque appel, y compris les simples lectures.
const res = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
// FormData (upload de fichier) fixe elle-même son Content-Type multipart avec la
// boundary requise — la poser ici manuellement casserait le parsing côté serveur.
...(init?.body && !(init.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {}),
},
});
if (res.status === 401) {
// Jeton absent/expiré/invalide : on force une reconnexion propre plutôt que de
// laisser l'app dans un état incohérent (données non chargées sans message clair).
clearToken();
window.location.href = '/login';
throw new Error('Session expirée — reconnexion nécessaire.');
}
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `Erreur API (${res.status})`);
}
if (res.status === 204) return undefined as T;
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export type StatutDemande =
| 'BROUILLON' | 'SOUMISE' | 'EN_VALIDATION' | 'COMPLEMENT_REQUIS'
| 'VALIDEE' | 'REFUSEE' | 'FACTUREE' | 'ANNULEE' | 'ARCHIVEE'
| 'VALIDEE_MANAGER' | 'EN_VALIDATION_MATERIEL' | 'VALIDEE_MATERIEL'
| 'EN_VALIDATION_FINANCE' | 'APPROUVEE' | 'COMMANDE_EMISE'
| 'COMMANDE_CONFIRMEE' | 'EN_ATTENTE_LIVRAISON' | 'EN_ATTENTE_VERIFICATION_RECEPTION'
| 'VERIFIEE_CONFORME' | 'VERIFIEE_NON_CONFORME' | 'RECEPTION_VALIDEE'
| 'FACTURE_EN_RAPPROCHEMENT' | 'CLOTUREE'
// Libellés du workflow Achats actuel. Les variantes précédentes restent prises en
// charge afin d'afficher correctement les demandes déjà créées.
| 'EN_CONTROLE_RECEPTION' | 'RECEPTION_CONFORME' | 'CLOTURE';
export interface Service { id: number; libelle: string; actif: boolean; }
export interface Campus { id: number; libelle: string; }
export interface TypeDepense { id: number; libelle: string; actif: boolean; }
export interface ModePaiement { id: number; libelle: string; necessiteXml: boolean; actif: boolean; }
export interface Fournisseur { id: number; raisonSociale: string; siret?: string; statut: string; }
export interface FournisseurDetail {
id: number; raisonSociale: string; siret: string | null; email: string | null;
telephone: string | null; adresse: string | null; statut: string; dateCreation: string | null;
}
export interface FournisseurSave {
raisonSociale: string; siret?: string | null; email?: string | null;
telephone?: string | null; adresse?: string | null; statut: string;
}
// Étape de la timeline d'historique (panneau de détail)
export interface EtapeHistorique {
titre: string;
auteur: string;
date: string; // ISO
fait: boolean; // true = étape passée, false = étape courante
note?: string;
}
export interface DemandeWorkflowHistorique {
action: string; commentaire: string | null; ancienStatut: string | null; nouveauStatut: string | null;
auteur: string; dateAction: string | null;
}
export interface DocumentWorkflow { nom: string; sharepointUrl: string; sharepointItemId: string; tailleOctets: number; }
export interface DemandeJustificatif { id: number; libelle: string; nom: string; dateUpload: string | null; sharepointUrl?: string | null; }
export interface DemandeAchat {
id: number; reference: string; serviceId: number; objet: string; description?: string | null;
fournisseurId?: number | null; fournisseurLibre?: string | null; categorieFournisseurLibre?: string | null; typeDepenseId: number;
montantHt: number; tauxTva: number; montantTva: number; montantTtc: number;
datePrevisionnelle: string; modePaiementId: number; statut: StatutDemande;
demandeurId: number; demandeurNom: string; valideurId?: number | null; valideurNom?: string | null; seuilApplicable: number; dateCreation: string;
dateSoumission: string | null; dateDerniereMaj: string | null;
justificatifs: DemandeJustificatif[];
factureAjoutee?: boolean; factureUrl?: string | null; factureNom?: string | null;
// Calculé côté backend : true seulement si l'utilisateur connecté est le responsable
// direct (manager Entra ID) du demandeur — le rôle FINANCE ne donne plus ce droit.
peutValider: boolean;
}
export interface DemandeAchatCreate {
serviceId: number; objet: string; description?: string; fournisseurId?: number | null;
fournisseurLibre?: string | null; categorieFournisseurLibre?: string | null; typeDepenseId: number; montantHt: number; tauxTva: number;
datePrevisionnelle: string; modePaiementId: number;
justificatifs?: { libelle: string; nom: string; sharepointUrl?: string; sharepointItemId?: string; tailleOctets?: number }[];
}
// Construit une timeline dérivée des horodatages réels de la demande (création, soumission,
// décision) — ce n'est pas un journal d'événements détaillé (non stocké en base), seulement
// ce qui est déductible des colonnes dateCreation/dateSoumission/dateDerniereMaj/statut.
export const construireHistorique = (d: DemandeAchat): EtapeHistorique[] => {
const etapes: EtapeHistorique[] = [
{ titre: "Demande créée", auteur: d.demandeurNom, date: d.dateCreation, fait: true },
];
if (d.dateSoumission) {
etapes.push({ titre: "Soumise à validation", auteur: d.demandeurNom, date: d.dateSoumission, fait: true });
}
const decisionLabel: Partial<Record<StatutDemande, string>> = {
VALIDEE: "Validée", REFUSEE: "Refusée", COMPLEMENT_REQUIS: "Complément requis",
FACTUREE: "Facturée", ANNULEE: "Annulée", ARCHIVEE: "Archivée",
VALIDEE_MANAGER: "Validée par le manager",
EN_VALIDATION_MATERIEL: "En validation matériel",
VALIDEE_MATERIEL: "Matériel validé",
EN_VALIDATION_FINANCE: "En validation Finance",
APPROUVEE: "Commande approuvée",
COMMANDE_EMISE: "Commande émise",
COMMANDE_CONFIRMEE: "Commande confirmée",
EN_ATTENTE_LIVRAISON: "En attente de livraison",
EN_ATTENTE_VERIFICATION_RECEPTION: "En contrôle de réception",
EN_CONTROLE_RECEPTION: "En contrôle de réception",
VERIFIEE_CONFORME: "Réception conforme",
RECEPTION_CONFORME: "Réception conforme",
VERIFIEE_NON_CONFORME: "Réception non conforme",
RECEPTION_VALIDEE: "Réception validée",
FACTURE_EN_RAPPROCHEMENT: "Facture en rapprochement",
CLOTURE: "Demande clôturée", CLOTUREE: "Demande clôturée",
};
if (d.dateDerniereMaj && decisionLabel[d.statut]) {
// ANNULEE est toujours à l'initiative du demandeur lui-même (pas d'un validateur) —
// valideurNom resterait celui d'une éventuelle décision précédente, donc trompeur ici.
const auteur = d.statut === 'ANNULEE' ? d.demandeurNom : (d.valideurNom ?? '—');
etapes.push({
titre: decisionLabel[d.statut]!,
auteur,
date: d.dateDerniereMaj,
fait: true,
});
} else if (d.statut === "SOUMISE" || d.statut === "EN_VALIDATION") {
etapes.push({ titre: "En attente de validation", auteur: "—", date: "", fait: false });
}
return etapes;
};
export interface Echeance {
jour: string; mois: string; fournisseur: string; ref: string; montant: number;
etat: 'echu' | 'proche' | 'avenir'; etatLabel: string; lien?: string;
}
export interface Alerte { couleur: 'warn' | 'info' | 'error' | 'primary'; titre: string; detail: string; lien?: string; }
export interface DepenseService { service: string; montant: number; }
const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms));
// Ces listes ne servent plus qu'aux helpers de libellé ci-dessous (nomService, etc.) —
// achatsApi.getServices()/getFournisseurs()/etc. viennent désormais du vrai backend.
const services: Service[] = [
{ id: 1, libelle: 'Informatique', actif: true },
{ id: 2, libelle: 'Finance', actif: true },
{ id: 3, libelle: 'Ressources Humaines', actif: true },
{ id: 4, libelle: 'Formation', actif: true },
];
const typesDepense: TypeDepense[] = [
{ id: 1, libelle: 'Investissement', actif: true },
{ id: 2, libelle: 'Fonctionnement', actif: true },
{ id: 3, libelle: 'Abonnement', actif: true },
{ id: 4, libelle: 'Prestation de service', actif: true },
];
const modesPaiement: ModePaiement[] = [
{ id: 1, libelle: 'Virement', necessiteXml: true, actif: true },
{ id: 2, libelle: 'Carte bancaire', necessiteXml: false, actif: true },
{ id: 3, libelle: 'Prélèvement', necessiteXml: false, actif: true },
];
const fournisseurs: Fournisseur[] = [
{ id: 1, raisonSociale: 'Dell France', siret: '38272007200025', statut: 'ACTIF' },
{ id: 2, raisonSociale: 'RS Components', statut: 'ACTIF' },
{ id: 3, raisonSociale: 'Sophos', statut: 'ACTIF' },
{ id: 4, raisonSociale: 'Synology', statut: 'ACTIF' },
];
// --- Helpers de libellés ---
export const nomService = (id: number) => services.find((s) => s.id === id)?.libelle ?? '—';
export const nomTypeDepense = (id: number) => typesDepense.find((t) => t.id === id)?.libelle ?? '—';
export const nomModePaiement = (id: number) => modesPaiement.find((m) => m.id === id)?.libelle ?? '—';
export const nomFournisseur = (d: DemandeAchat) =>
d.fournisseurLibre ?? fournisseurs.find((f) => f.id === d.fournisseurId)?.raisonSociale ?? '—';
// --- Helpers de dates ---
export const formatDateFr = (iso: string) => new Date(iso).toLocaleDateString('fr-FR');
export const formatDateHeureFr = (iso: string) =>
new Date(iso).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
// ============================================================================
// SharePoint — upload de documents dans les dossiers modules (endpoint générique,
// réutilisé par Achats / Sous-Traitants / Contrat Etudiant).
// ============================================================================
export type DossierSharePoint = 'Achats' | 'Sous-Traitants' | 'Contrat Etudiant';
export interface SharePointUpload { webUrl: string; itemId: string; size: number; nom: string; }
export const sharepointApi = {
// service : sous-dossier optionnel (ex. le service du demandeur pour Achats) — le nom
// du collaborateur, lui, est toujours résolu côté serveur depuis l'utilisateur connecté.
async upload(dossier: DossierSharePoint, file: File, service?: string) {
const form = new FormData();
form.append('file', file);
form.append('dossier', dossier);
if (service) form.append('service', service);
return apiFetch<SharePointUpload>('/api/sharepoint/upload', { method: 'POST', body: form });
},
};
export const achatsApi = {
async getServices() { return apiFetch<Service[]>('/api/services'); },
async getCampus() { return apiFetch<Campus[]>('/api/campus'); },
async getBudgets(exercice?: number) {
return apiFetch<BudgetServiceItem[]>(`/api/budgets${exercice != null ? `?exercice=${exercice}` : ''}`);
},
async creerBudget(input: BudgetServiceCreate) {
return apiFetch<BudgetServiceItem>('/api/budgets', { method: 'POST', body: JSON.stringify(input) });
},
// Réservé à FINANCE côté backend — ajoute une révision à l'historique du budget
// (augmentation en cours d'exercice), sans jamais écraser les révisions précédentes.
async reviserBudget(id: number, input: BudgetRevisionCreate) {
return apiFetch<BudgetServiceItem>(`/api/budgets/${id}/revisions`, { method: 'POST', body: JSON.stringify(input) });
},
async getTypesDepense() { return apiFetch<TypeDepense[]>('/api/types-depense'); },
async getModesPaiement() { return apiFetch<ModePaiement[]>('/api/modes-paiement'); },
async getFournisseurs() { return apiFetch<Fournisseur[]>('/api/fournisseurs'); },
// scope='mine' force la restriction aux demandes du demandeur connecté, même pour un
// utilisateur qui a aussi le rôle FINANCE (utile en espace de travail Collaborateur).
async getDemandes(scope?: 'mine') { return apiFetch<DemandeAchat[]>(`/api/demandes${scope ? `?scope=${scope}` : ''}`); },
async getDemande(id: number) { return apiFetch<DemandeAchat>(`/api/demandes/${id}`); },
async getHistoriqueDemande(id: number) { return apiFetch<DemandeWorkflowHistorique[]>(`/api/demandes/${id}/historique`); },
async ajouterFactureDemande(id: number, input: { numero: string; montant: number; dateFacture: string; dateEcheance: string; document: DocumentWorkflow }) {
return apiFetch<{ id: number }>(`/api/demandes/${id}/facture`, { method: 'POST', body: JSON.stringify({
numero: input.numero, montant: input.montant, dateFacture: input.dateFacture, dateEcheance: input.dateEcheance,
documentNom: input.document.nom, documentSharepointUrl: input.document.sharepointUrl,
documentSharepointItemId: input.document.sharepointItemId, documentTailleOctets: input.document.tailleOctets,
}) });
},
async modifierDemande(id: number, input: DemandeAchatCreate) {
return apiFetch<DemandeAchat>(`/api/demandes/${id}`, { method: 'PUT', body: JSON.stringify(input) });
},
async annulerDemande(id: number) {
return apiFetch<void>(`/api/demandes/${id}/annuler`, { method: 'PATCH' });
},
async getEcheances(): Promise<Echeance[]> {
await delay();
return [
{ jour: '18', mois: 'JUIN', fournisseur: 'Cabinet Mazet', ref: 'FAC-2026-0509', montant: 22000, etat: 'echu', etatLabel: 'Échue', lien: '/factures' },
{ jour: '27', mois: 'JUIN', fournisseur: 'OVHcloud', ref: 'FAC-2026-0518', montant: 2280, etat: 'proche', etatLabel: 'Dans 5 j', lien: '/factures' },
{ jour: '15', mois: 'JUIL', fournisseur: 'Dell France', ref: 'FAC-2026-0521', montant: 12400, etat: 'avenir', etatLabel: 'À venir', lien: '/factures' },
];
},
async getAlertes(): Promise<Alerte[]> {
await delay();
return [
{ couleur: 'warn', titre: 'Doublon potentiel — FAC-2026-0512', detail: 'OVHcloud · 2 280 € · identique à une facture existante', lien: '/factures' },
{ couleur: 'info', titre: 'Avoir disponible — Dell France', detail: '1 500 € à affecter avant le prochain paiement', lien: '/factures' },
{ couleur: 'error', titre: 'Échéance dépassée — FAC-2026-0509', detail: 'Cabinet Mazet · 22 000 € · échue le 18/06 · dossier bloqué', lien: '/paiements' },
{ couleur: 'primary', titre: 'Demandes à valider', detail: '1 demande en attente de votre décision', lien: '/demandes' },
];
},
async getDepensesParService(): Promise<DepenseService[]> {
await delay();
return [
{ service: 'Informatique', montant: 23400 },
{ service: 'Finance', montant: 22000 },
{ service: 'Marketing', montant: 15600 },
{ service: 'Ressources Humaines', montant: 8900 },
{ service: 'Services Généraux', montant: 740 },
];
},
async createDemande(input: DemandeAchatCreate): Promise<DemandeAchat> {
return apiFetch<DemandeAchat>('/api/demandes', { method: 'POST', body: JSON.stringify(input) });
},
async changerStatutDemande(id: number, statut: 'VALIDEE' | 'REFUSEE'): Promise<DemandeAchat> {
return apiFetch<DemandeAchat>(`/api/demandes/${id}/statut`, { method: 'PATCH', body: JSON.stringify({ statut }) });
},
async avancerWorkflowDemande(id: number, action: string, commentaire?: string, document?: DocumentWorkflow): Promise<DemandeAchat> {
return apiFetch<DemandeAchat>(`/api/demandes/${id}/workflow`, { method: 'PATCH', body: JSON.stringify({
action, commentaire: commentaire || null,
documentNom: document?.nom ?? null, documentSharepointUrl: document?.sharepointUrl ?? null,
documentSharepointItemId: document?.sharepointItemId ?? null, documentTailleOctets: document?.tailleOctets ?? null,
}) });
},
};
// ============================================================================
// Fournisseurs (CRUD complet) — endpoints /api/fournisseurs/*.
// ============================================================================
// ============================================================================
// Sous-Traitants — organismes prestataires + contrats (endpoints /api/soustraitants/*).
// ============================================================================
export interface OrganismeST { id: number; raisonSociale: string; siret?: string | null; statut: string; }
export type StatutContratST = 'ACTIF' | 'EXPIRE' | 'A_RENOUVELER';
export interface ContratPrestataire {
id: number; organismeId: number; organisme: string; reference: string; objet: string;
dateSignature: string | null; dateDebut: string; dateFin: string; montantTotal: number; statut: StatutContratST;
}
export interface ContratPrestataireCreate {
organismeId: number; objet: string; dateSignature?: string | null;
dateDebut: string; dateFin: string; montantTotal: number;
}
export const soustraitantsApi = {
async getOrganismes() { return apiFetch<OrganismeST[]>('/api/soustraitants/organismes'); },
async getContrats() { return apiFetch<ContratPrestataire[]>('/api/soustraitants/contrats'); },
async createContrat(input: ContratPrestataireCreate) {
return apiFetch<ContratPrestataire>('/api/soustraitants/contrats', { method: 'POST', body: JSON.stringify(input) });
},
};
export const fournisseurApi = {
async getAll() { return apiFetch<FournisseurDetail[]>('/api/fournisseurs/all'); },
async create(input: FournisseurSave) {
return apiFetch<FournisseurDetail>('/api/fournisseurs', { method: 'POST', body: JSON.stringify(input) });
},
async update(id: number, input: FournisseurSave) {
return apiFetch<void>(`/api/fournisseurs/${id}`, { method: 'PUT', body: JSON.stringify(input) });
},
async remove(id: number) {
return apiFetch<void>(`/api/fournisseurs/${id}`, { method: 'DELETE' });
},
};
// ============================================================================
// Finance & Contrats — appellent SIFinance.Modules.Finance / le module Achats
// (endpoints /api/factures, /api/avoirs, /api/paiements, /api/contrats).
// ============================================================================
export type StatutFacture = 'attente' | 'validee' | 'litige' | 'payee';
export interface FactureEcheance { id: number; date: string; montant: number; statut: string; }
export interface FactureJustificatif { nom: string; url: string; }
export interface Facture {
id: number; numero: string; fournisseurId: number; fournisseur: string;
montantHt: number; tauxTva: number; montantTva: number; montantTtc: number;
dateFacture: string; dateEcheance: string; statut: StatutFacture;
demandeAchatId: number | null;
avoirLieId: number | null; avoirLieNumero: string | null; avoirLieMontant: number | null;
commentaire: string | null; estDoublonSignale: boolean;
contratId: number | null; contratObjet: string | null;
echeances: FactureEcheance[];
justificatifs?: FactureJustificatif[];
}
export interface FactureCreate {
numero: string; fournisseurId: number; montant: number;
dateFacture: string; dateEcheance: string; demandeAchatId?: number | null; commentaire?: string | null;
contratId?: number | null;
}
export interface Avoir {
id: number; numero: string; fournisseurId: number; fournisseur: string;
montant: number; soldeDisponible: number; date: string;
factureLieeId: number | null; factureLieeNumero: string | null;
}
export interface AvoirCreate { numero: string; fournisseurId: number; montant: number; date: string; }
export type StatutPaiement = 'programme' | 'execute' | 'rejete';
export interface PaiementGroupe {
reference: string; fournisseurId: number; fournisseur: string;
factureIds: number[]; factureNumeros: string[]; montantTotal: number;
modePaiementId: number; modePaiement: string; datePaiement: string;
statut: StatutPaiement; rapproche: boolean;
}
export interface FactureDisponible { id: number; fournisseurId: number; fournisseur: string; numero: string; montant: number; dateFacture: string; }
export interface PaiementCreate {
factureIds: number[]; modePaiementId: number; datePaiement: string;
reference: string; statut: StatutPaiement; rapproche: boolean;
}
export interface PaiementUpdate { modePaiementId: number; datePaiement: string; reference: string; statut: StatutPaiement; rapproche: boolean; }
export type StatutContrat = 'BROUILLON' | 'ACTIF' | 'A_RENOUVELER' | 'SUSPENDU' | 'RESILIE' | 'EXPIRE' | 'ARCHIVE' | 'TERMINE';
export type StatutContratEcheance = 'A_VENIR' | 'PAYEE';
export interface ContratEcheance { id: number; date: string; montant: number; statut: StatutContratEcheance; justificatifNom: string | null; }
export interface ContratDocument { id: number; date: string; tag: string; nom: string; }
export interface ContratFactureLiee { id: number; numero: string; montantTtc: number; dateFacture: string; statut: string; }
export interface Contrat {
id: number; fournisseurId: number; fournisseur: string; serviceId: number | null; service: string | null; campusId: number | null; campus: string | null; reference: string | null; objet: string;
dateSignature: string | null; dateDebut: string; dateFin: string; dateResiliation: string | null; motifResiliation: string | null; montantTotal: number;
periodicite: string; statut: StatutContrat; echeances: ContratEcheance[]; documents: ContratDocument[];
facturesLiees: ContratFactureLiee[];
}
export interface ContratEcheanceCreate { date: string; montant: number; }
export interface ContratCreate {
fournisseurId: number; serviceId: number; campusId: number; objet: string; reference?: string | null; dateSignature?: string | null;
dateDebut: string; dateFin: string; montantTotal: number; periodicite: string; echeances: ContratEcheanceCreate[];
}
export interface ContratHistorique { id: number; ancienStatut: string | null; nouveauStatut: string; commentaire: string | null; utilisateur: string; dateAction: string; }
export interface FacturXAnalyse {
conforme: boolean; numero: string | null; fournisseurNom: string | null;
montantHt: number | null; montantTva: number | null; montantTtc: number | null;
dateFacture: string | null; dateEcheance: string | null; erreur: string | null;
}
// Exercice non calendaire (1er septembre au 31 août) — exerciceAnnee désigne l'année de
// démarrage : 2026 = exercice du 01/09/2026 au 31/08/2027.
export interface BudgetRevision {
id: number; montantRevise: number; commentaire: string | null; revisePar: string; dateRevision: string;
}
export interface BudgetServiceItem {
id: number; serviceId: number; service: string; exerciceAnnee: number;
montantInitial: number; montantRevise: number | null;
revisions: BudgetRevision[];
}
export interface BudgetServiceCreate { serviceId: number; exerciceAnnee: number; montantInitial: number; }
export interface BudgetRevisionCreate { montantRevise: number; commentaire?: string | null; }
export const financeApi = {
async getFactures() { return apiFetch<Facture[]>('/api/factures'); },
// Réception de facture électronique (Factur-X/ZUGFeRD) : extrait les données du XML
// embarqué dans le PDF fournisseur pour pré-remplir le formulaire de saisie.
async analyserFacturX(file: File) {
const form = new FormData();
form.append('file', file);
return apiFetch<FacturXAnalyse>('/api/factures/analyser-facturx', { method: 'POST', body: form });
},
async createFacture(input: FactureCreate) {
return apiFetch<Facture>('/api/factures', { method: 'POST', body: JSON.stringify(input) });
},
async changerStatutFacture(id: number, statut: StatutFacture) {
return apiFetch<void>(`/api/factures/${id}/statut`, { method: 'PATCH', body: JSON.stringify({ statut }) });
},
async modifierCommentaireFacture(id: number, commentaire: string | null) {
return apiFetch<void>(`/api/factures/${id}/commentaire`, { method: 'PATCH', body: JSON.stringify({ commentaire }) });
},
// Lève un signalement de doublon potentiel après vérification humaine que ce sont bien
// deux factures distinctes (même fournisseur + même montant sur une période proche).
async leverDoublonFacture(id: number) {
return apiFetch<void>(`/api/factures/${id}/doublon`, { method: 'PATCH' });
},
async supprimerFacture(id: number) {
return apiFetch<void>(`/api/factures/${id}`, { method: 'DELETE' });
},
async ajouterEcheanceFacture(factureId: number, input: { date: string; montant: number }) {
return apiFetch<FactureEcheance>(`/api/factures/${factureId}/echeances`, { method: 'POST', body: JSON.stringify(input) });
},
async modifierEcheanceFacture(factureId: number, echeanceId: number, input: { date: string; montant: number }) {
return apiFetch<void>(`/api/factures/${factureId}/echeances/${echeanceId}`, { method: 'PUT', body: JSON.stringify(input) });
},
async supprimerEcheanceFacture(factureId: number, echeanceId: number) {
return apiFetch<void>(`/api/factures/${factureId}/echeances/${echeanceId}`, { method: 'DELETE' });
},
async lierAvoir(factureId: number, avoirId: number) {
return apiFetch<void>(`/api/factures/${factureId}/avoir/${avoirId}`, { method: 'POST' });
},
async delierAvoir(factureId: number, avoirId: number) {
return apiFetch<void>(`/api/factures/${factureId}/avoir/${avoirId}`, { method: 'DELETE' });
},
async getAvoirs() { return apiFetch<Avoir[]>('/api/avoirs'); },
async createAvoir(input: AvoirCreate) {
return apiFetch<Avoir>('/api/avoirs', { method: 'POST', body: JSON.stringify(input) });
},
async getPaiements() { return apiFetch<PaiementGroupe[]>('/api/paiements'); },
async getFacturesDisponibles() { return apiFetch<FactureDisponible[]>('/api/paiements/factures-disponibles'); },
async createPaiement(input: PaiementCreate) {
return apiFetch<void>('/api/paiements', { method: 'POST', body: JSON.stringify(input) });
},
async modifierPaiement(reference: string, input: PaiementUpdate) {
return apiFetch<void>(`/api/paiements/${encodeURIComponent(reference)}`, { method: 'PATCH', body: JSON.stringify(input) });
},
async toggleRapprochePaiement(reference: string) {
return apiFetch<{ rapproche: boolean }>(`/api/paiements/${encodeURIComponent(reference)}/rapproche`, { method: 'PATCH' });
},
async supprimerPaiement(reference: string) {
return apiFetch<void>(`/api/paiements/${encodeURIComponent(reference)}`, { method: 'DELETE' });
},
async getContrats() { return apiFetch<Contrat[]>('/api/contrats'); },
async createContrat(input: ContratCreate) {
return apiFetch<Contrat>('/api/contrats', { method: 'POST', body: JSON.stringify(input) });
},
async getHistoriqueContrat(id: number) { return apiFetch<ContratHistorique[]>(`/api/contrats/${id}/historique`); },
async changerStatutContrat(id: number, input: { statut: StatutContrat; dateResiliation?: string; motifResiliation?: string; commentaire?: string }) {
return apiFetch<void>(`/api/contrats/${id}/statut`, { method: 'PATCH', body: JSON.stringify(input) });
},
async ajouterEcheanceContrat(contratId: number, input: ContratEcheanceCreate) {
return apiFetch<ContratEcheance>(`/api/contrats/${contratId}/echeances`, { method: 'POST', body: JSON.stringify(input) });
},
async modifierEcheanceContrat(contratId: number, echeanceId: number, input: { date: string; montant: number; statut: StatutContratEcheance; justificatifNom?: string | null }) {
return apiFetch<void>(`/api/contrats/${contratId}/echeances/${echeanceId}`, { method: 'PUT', body: JSON.stringify(input) });
},
async supprimerEcheanceContrat(contratId: number, echeanceId: number) {
return apiFetch<void>(`/api/contrats/${contratId}/echeances/${echeanceId}`, { method: 'DELETE' });
},
async ajouterDocumentContrat(contratId: number, input: { tag: string; nom: string; date?: string | null }) {
return apiFetch<ContratDocument>(`/api/contrats/${contratId}/documents`, { method: 'POST', body: JSON.stringify(input) });
},
async modifierDocumentContrat(contratId: number, documentId: number, input: { tag: string; date: string }) {
return apiFetch<void>(`/api/contrats/${contratId}/documents/${documentId}`, { method: 'PUT', body: JSON.stringify(input) });
},
async supprimerDocumentContrat(contratId: number, documentId: number) {
return apiFetch<void>(`/api/contrats/${contratId}/documents/${documentId}`, { method: 'DELETE' });
},
};
+36
View File
@@ -0,0 +1,36 @@
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { theme } from './theme';
import { AuthProvider } from './auth';
import App from './App';
// staleTime : les données restent considérées fraîches 30 s, donc pas de refetch
// à chaque navigation. refetchOnWindowFocus désactivé pour éviter un rechargement
// dès qu'on revient sur l'onglet.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
</React.StrictMode>,
);
+145
View File
@@ -0,0 +1,145 @@
import { useState, useEffect } from "react";
import { achatsApi, type DemandeAchat, type StatutDemande } from "../data";
/*
Vue personnelle du collaborateur : ses demandes d'achat, avec leurs justificatifs.
scope='mine' est toujours forcé ici (même pour un utilisateur qui a aussi le rôle
FINANCE) — cette page est explicitement "mes archives", pas une vue Finance globale.
*/
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const fmtEur = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const STATUT_DEMANDE_LABEL: Partial<Record<StatutDemande, string>> = {
BROUILLON: "Brouillon",
SOUMISE: "Soumise",
EN_VALIDATION: "À valider",
COMPLEMENT_REQUIS: "Complément",
VALIDEE: "Validée",
REFUSEE: "Refusée",
FACTUREE: "Facturée",
ANNULEE: "Annulée",
ARCHIVEE: "Archivée",
};
export default function Archives() {
const [demandes, setDemandes] = useState<DemandeAchat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
achatsApi.getDemandes('mine')
.then(setDemandes)
.catch((e) => setError(e instanceof Error ? e.message : "Erreur de chargement."))
.finally(() => setLoading(false));
}, []);
return (
<div className="arc-root">
<style>{css}</style>
<div className="arc-wrap">
<header className="arc-header">
<p className="arc-eyebrow">Achats · historique</p>
<h1 className="arc-h1">Archives</h1>
<p className="arc-sub">Mes demandes et leurs justificatifs.</p>
</header>
{error && <p className="arc-error">{error}</p>}
<section className="arc-section">
<p className="arc-section-title">Mes demandes ({demandes.length})</p>
<div className="arc-table-wrap">
<table className="arc-table">
<thead>
<tr>
<th>Réf</th><th>Objet</th><th>Date de création</th><th style={{ textAlign: "center" }}>Justif.</th>
<th style={{ textAlign: "right" }}>Montant TTC</th><th style={{ textAlign: "right" }}>Statut</th>
</tr>
</thead>
<tbody>
{demandes.map((d) => {
const premierJustif = d.justificatifs.find((j) => j.sharepointUrl);
return (
<tr key={d.id}>
<td className="arc-mono-cell">{d.reference}</td>
<td className="arc-objet-cell">{d.objet}</td>
<td>{d.dateCreation ? fmtDate(d.dateCreation.slice(0, 10)) : "—"}</td>
<td style={{ textAlign: "center" }}>
{d.justificatifs.length === 0 ? (
<span className="arc-muted-inline">📄 0</span>
) : premierJustif ? (
<a href={premierJustif.sharepointUrl!} target="_blank" rel="noreferrer" className="arc-icon-link" title={`${d.justificatifs.length} justificatif${d.justificatifs.length > 1 ? "s" : ""}`}>
📄 {d.justificatifs.length}
</a>
) : (
<span className="arc-icon-link muted" title="Justificatif sans lien SharePoint">📄 {d.justificatifs.length}</span>
)}
</td>
<td className="arc-amount">{fmtEur(d.montantTtc)}</td>
<td style={{ textAlign: "right" }}>
<span className="arc-status-pill" data-statut={d.statut}>{STATUT_DEMANDE_LABEL[d.statut] ?? d.statut.replaceAll("_", " ")}</span>
</td>
</tr>
);
})}
{!loading && demandes.length === 0 && (
<tr><td colSpan={6} className="arc-empty">Aucune demande pour l'instant.</td></tr>
)}
</tbody>
</table>
</div>
</section>
</div>
</div>
);
}
const css = `
.arc-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--violet:#5B4B8A; --violet-light:#EDE9F6;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.arc-root *{ box-sizing:border-box; }
.arc-wrap{ max-width:1180px; margin:0 auto; }
.arc-header{ margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.arc-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.arc-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.arc-sub{ font-size:13px; color:var(--text-muted); margin:6px 0 0; }
.arc-error{ font-size:13px; color:var(--danger); margin:0 0 16px; }
.arc-section{ margin-bottom:28px; }
.arc-section-title{ font-size:15px; font-weight:700; margin:0 0 12px; }
.arc-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.arc-table{ width:100%; border-collapse:collapse; font-size:13px; }
.arc-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.arc-table td{ padding:12px 16px; border-bottom:1px solid var(--border); }
.arc-table tbody tr:last-child td{ border-bottom:none; }
.arc-objet-cell{ font-weight:500; }
.arc-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.arc-amount{ font-family:var(--mono); text-align:right; }
.arc-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.arc-muted-inline{ color:var(--text-muted); }
.arc-icon-link{ font-size:12.5px; color:var(--accent); text-decoration:none; white-space:nowrap; }
.arc-icon-link:hover{ text-decoration:underline; }
.arc-icon-link.muted{ color:var(--text-muted); cursor:default; }
.arc-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; background:var(--surface-2); color:var(--text-2); }
.arc-status-pill[data-statut="VALIDEE"], .arc-status-pill[data-statut="FACTUREE"]{ background:var(--success-light); color:var(--success); }
.arc-status-pill[data-statut="SOUMISE"], .arc-status-pill[data-statut="EN_VALIDATION"]{ background:var(--accent-light); color:var(--accent); }
.arc-status-pill[data-statut="COMPLEMENT_REQUIS"]{ background:var(--warning-light); color:var(--warning); }
.arc-status-pill[data-statut="REFUSEE"], .arc-status-pill[data-statut="ANNULEE"]{ background:var(--danger-light); color:var(--danger); }
`;
+853
View File
@@ -0,0 +1,853 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import {
achatsApi, financeApi,
type Campus, type Contrat, type ContratEcheance, type ContratHistorique, type Fournisseur,
type Service, type StatutContrat, type StatutContratEcheance,
} from "../data";
import { useAuth } from "../auth";
/*
Mêmes tokens de couleur / structure que FacturesAvoirs.tsx / Paiements.tsx.
Données réelles via financeApi (module achats — tables achats.contrats_fournisseurs /
achats.contrats_echeances, documents via core.pieces_jointes).
Les justificatifs ne sont pour l'instant qu'un nom de fichier — l'upload SharePoint
réel viendra se brancher dessus plus tard (cf. core.pieces_jointes.sharepoint_url).
*/
interface EchRow { id: string; date: string; montant: string; justifName: string | null; }
interface DocRow { id: string; date: string; tag: string; nom: string; }
interface EditBuffer { echeanceId: number; date: string; montant: string; statut: StatutContratEcheance; justif: string | null; }
const uid = () => Math.random().toString(36).slice(2, 10);
const fmtDate = (iso: string) => {
if (!iso) return "";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const fmtEur = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const PAGE_SIZE = 5;
function getPageList(current: number, total: number): (number | "…")[] {
const delta = 1;
const range: (number | "…")[] = [];
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) range.push(i);
if (current - delta > 2) range.unshift("…");
if (current + delta < total - 1) range.push("…");
range.unshift(1);
if (total > 1) range.push(total);
return range;
}
function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
if (totalPages <= 1) return null;
const pages = getPageList(page, totalPages);
return (
<div className="cf-pagination">
<button className="cf-page-btn" disabled={page === 1} onClick={() => onChange(Math.max(1, page - 1))}>
‹
</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="cf-page-ellipsis">
…
</span>
) : (
<button key={p} className={`cf-page-btn${p === page ? " active" : ""}`} onClick={() => onChange(p)}>
{p}
</button>
)
)}
<button className="cf-page-btn" disabled={page === totalPages} onClick={() => onChange(Math.min(totalPages, page + 1))}>
›
</button>
</div>
);
}
export default function ContratsFournisseurs() {
const { estResponsable } = useAuth();
const peutGerer = estResponsable;
const location = useLocation();
const [contracts, setContracts] = useState<Contrat[]>([]);
const [fournisseurs, setFournisseurs] = useState<Fournisseur[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [campus, setCampus] = useState<Campus[]>([]);
const [historiques, setHistoriques] = useState<Record<number, ContratHistorique[]>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const [showForm, setShowForm] = useState(false);
const [page, setPage] = useState(1);
// --- form fields ---
const [fournisseurId, setFournisseurId] = useState<number | "">("");
const [serviceId, setServiceId] = useState<number | "">("");
const [campusId, setCampusId] = useState<number | "">("");
const [objet, setObjet] = useState("");
const [signature, setSignature] = useState("");
const [reference, setReference] = useState("");
const [debut, setDebut] = useState("");
const [fin, setFin] = useState("");
const [montantTotal, setMontantTotal] = useState("");
const [periodicite, setPeriodicite] = useState<"mensuelle" | "trimestrielle" | "annuelle" | "unique">("mensuelle");
const [echRows, setEchRows] = useState<EchRow[]>([]);
const [docs, setDocs] = useState<DocRow[]>([]);
const [editBuffer, setEditBuffer] = useState<EditBuffer | null>(null);
const [saving, setSaving] = useState(false);
const [statutEnCours, setStatutEnCours] = useState<number | null>(null);
const [nouveauStatut, setNouveauStatut] = useState<StatutContrat>("ACTIF");
const [commentaireStatut, setCommentaireStatut] = useState("");
const [dateResiliation, setDateResiliation] = useState("");
const [motifResiliation, setMotifResiliation] = useState("");
const refresh = async () => setContracts(await financeApi.getContrats());
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([financeApi.getContrats(), achatsApi.getFournisseurs(), achatsApi.getServices(), achatsApi.getCampus()])
.then(([c, f, s, ca]) => { if (!cancelled) { setContracts(c); setFournisseurs(f); setServices(s); setCampus(ca); if (f.length > 0) setFournisseurId(f[0].id); } })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : "Échec du chargement."); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
// Arrivée depuis une alerte du dashboard : ouvre directement le contrat visé et
// se place sur la page de pagination qui le contient. On attend que `contracts`
// soit chargé (sinon l'index de recherche est vide) et on nettoie le state
// d'historique une fois consommé, pour ne pas rouvrir à chaque navigation suivante.
useEffect(() => {
const openContratId = (location.state as { openContratId?: number } | null)?.openContratId;
if (openContratId == null || contracts.length === 0) return;
setOpenIds((prev) => new Set(prev).add(openContratId));
if (!historiques[openContratId]) {
financeApi.getHistoriqueContrat(openContratId)
.then((h) => setHistoriques((before) => ({ ...before, [openContratId]: h })))
.catch(() => undefined);
}
const index = contracts.findIndex((c) => c.id === openContratId);
if (index !== -1) setPage(Math.floor(index / PAGE_SIZE) + 1);
window.history.replaceState({}, "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state, contracts]);
const startEditEch = (e: ContratEcheance) => {
setEditBuffer({ echeanceId: e.id, date: e.date, montant: String(e.montant), statut: e.statut, justif: e.justificatifNom });
};
const cancelEditEch = () => setEditBuffer(null);
const saveEditEch = async (contratId: number) => {
if (!editBuffer) return;
try {
await financeApi.modifierEcheanceContrat(contratId, editBuffer.echeanceId, {
date: editBuffer.date,
montant: parseFloat(editBuffer.montant.replace(/\s/g, "").replace(",", ".")) || 0,
statut: editBuffer.statut,
justificatifNom: editBuffer.justif,
});
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la mise à jour de l'échéance.");
}
setEditBuffer(null);
};
const deleteEch = async (contratId: number, echeanceId: number) => {
if (!window.confirm("Supprimer cette échéance ?")) return;
try {
await financeApi.supprimerEcheanceContrat(contratId, echeanceId);
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la suppression.");
}
if (editBuffer?.echeanceId === echeanceId) setEditBuffer(null);
};
const toggleOpen = (id: number) => {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else {
next.add(id);
if (!historiques[id]) {
financeApi.getHistoriqueContrat(id)
.then((h) => setHistoriques((before) => ({ ...before, [id]: h })))
.catch(() => undefined);
}
}
return next;
});
};
const addEchRow = () => {
setEchRows((rows) => [...rows, { id: uid(), date: "", montant: "", justifName: null }]);
};
const removeEchRow = (id: string) => {
setEchRows((rows) => rows.filter((r) => r.id !== id));
};
const updateEchRow = (id: string, patch: Partial<EchRow>) => {
setEchRows((rows) => rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const attachFacture = (id: string, file: File | undefined) => {
if (!file) return;
updateEchRow(id, { justifName: file.name });
};
const genererEcheancier = () => {
const montant = parseFloat(montantTotal.replace(/\s/g, "").replace(",", "."));
if (!debut || !fin || !montant) {
alert("Renseigner la date de début, la date de fin et le montant total avant de générer l'échéancier.");
return;
}
const stepMonths = { mensuelle: 1, trimestrielle: 3, annuelle: 12, unique: 999 }[periodicite];
const start = new Date(debut);
const end = new Date(fin);
const dates: Date[] = [];
if (periodicite === "unique") {
dates.push(new Date(start));
} else {
const cursor = new Date(start);
while (cursor <= end) {
dates.push(new Date(cursor));
cursor.setMonth(cursor.getMonth() + stepMonths);
}
}
const montantParEcheance = Math.round((montant / dates.length) * 100) / 100;
const rows: EchRow[] = dates.map((d, i) => {
const isLast = i === dates.length - 1;
const amt = isLast ? Math.round((montant - montantParEcheance * (dates.length - 1)) * 100) / 100 : montantParEcheance;
return { id: uid(), date: d.toISOString().split("T")[0], montant: String(amt), justifName: null };
});
setEchRows(rows);
};
const addDocs = (files: FileList | null) => {
if (!files) return;
const today = new Date().toISOString().split("T")[0];
const newDocs: DocRow[] = Array.from(files).map((f) => ({
id: uid(),
date: today,
tag: "Contrat signé",
nom: f.name,
}));
setDocs((prev) => [...prev, ...newDocs]);
};
const updateDoc = (id: string, patch: Partial<DocRow>) => {
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)));
};
const removeDoc = (id: string) => {
setDocs((prev) => prev.filter((d) => d.id !== id));
};
const sortedDocs = useMemo(() => [...docs].sort((a, b) => a.date.localeCompare(b.date)), [docs]);
const resetForm = () => {
setServiceId("");
setCampusId("");
setObjet("");
setSignature("");
setReference("");
setDebut("");
setFin("");
setMontantTotal("");
setPeriodicite("mensuelle");
setEchRows([]);
setDocs([]);
};
const saveContract = async () => {
if (!fournisseurId || !serviceId || !campusId || !objet || !debut || !fin || echRows.length === 0) {
const manquants = [
!fournisseurId && "le fournisseur",
!serviceId && "le service responsable",
!campusId && "le campus",
!objet && "l'objet",
!debut && "la date de début",
!fin && "la date de fin",
echRows.length === 0 && "au moins une échéance",
].filter(Boolean);
alert(`Renseignez ${manquants.join(", ")} avant d'enregistrer.`);
return;
}
setSaving(true);
try {
const contrat = await financeApi.createContrat({
fournisseurId,
serviceId,
campusId,
objet,
reference: reference || null,
dateSignature: signature || null,
dateDebut: debut,
dateFin: fin,
montantTotal: echRows.reduce((sum, r) => sum + (parseFloat(r.montant) || 0), 0),
periodicite,
echeances: echRows.map((r) => ({ date: r.date, montant: parseFloat(r.montant) || 0 })),
});
// Deuxième passe : justificatifs des échéances + documents généraux (les endpoints de
// création ne prennent que date/montant, les pièces jointes se rattachent après coup).
await Promise.all(
echRows.map((r, i) =>
r.justifName ? financeApi.modifierEcheanceContrat(contrat.id, contrat.echeances[i].id, {
date: r.date, montant: parseFloat(r.montant) || 0, statut: "A_VENIR", justificatifNom: r.justifName,
}) : Promise.resolve()
)
);
await Promise.all(docs.map((d) => financeApi.ajouterDocumentContrat(contrat.id, { tag: d.tag, nom: d.nom, date: d.date })));
await refresh();
resetForm();
setShowForm(false);
setPage(1);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de l'enregistrement du contrat.");
} finally {
setSaving(false);
}
};
const enregistrerStatut = async (contratId: number) => {
if (nouveauStatut === "RESILIE" && (!dateResiliation || !motifResiliation.trim())) {
alert("La date et le motif de résiliation sont obligatoires.");
return;
}
try {
await financeApi.changerStatutContrat(contratId, {
statut: nouveauStatut,
dateResiliation: nouveauStatut === "RESILIE" ? dateResiliation : undefined,
motifResiliation: nouveauStatut === "RESILIE" ? motifResiliation : undefined,
commentaire: commentaireStatut || undefined,
});
await refresh();
const historique = await financeApi.getHistoriqueContrat(contratId);
setHistoriques((before) => ({ ...before, [contratId]: historique }));
setStatutEnCours(null);
setCommentaireStatut("");
setDateResiliation("");
setMotifResiliation("");
} catch (err) {
alert(err instanceof Error ? err.message : "Échec du changement de statut.");
}
};
const totalPages = Math.max(1, Math.ceil(contracts.length / PAGE_SIZE));
const pageContracts = contracts.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<div className="cf-root">
<style>{css}</style>
<div className="cf-wrap">
<header className="cf-header">
<div>
<p className="cf-eyebrow">Achats · contrats</p>
<h1 className="cf-h1">Contrats fournisseurs</h1>
</div>
{peutGerer && <button className="cf-btn-primary" onClick={() => setShowForm((v) => !v)}>
+ Nouveau contrat
</button>}
</header>
{error && <div className="cf-error-banner">{error}</div>}
{showForm && (
<div className="cf-panel" style={{ marginBottom: 20 }}>
<p className="cf-panel-title">Saisir un contrat existant</p>
<div className="cf-form-grid">
<div className="cf-field">
<label>Fournisseur</label>
<select value={fournisseurId} onChange={(e) => setFournisseurId(e.target.value ? Number(e.target.value) : "")}>
<option value="">Sélectionner…</option>
{fournisseurs.map((f) => <option key={f.id} value={f.id}>{f.raisonSociale}</option>)}
</select>
</div>
<div className="cf-field">
<label>Service responsable</label>
<select value={serviceId} onChange={(e) => setServiceId(e.target.value ? Number(e.target.value) : "")}>
<option value="">Sélectionner…</option>
{services.map((s) => <option key={s.id} value={s.id}>{s.libelle}</option>)}
</select>
</div>
<div className="cf-field">
<label>Campus</label>
<select value={campusId} onChange={(e) => setCampusId(e.target.value ? Number(e.target.value) : "")}>
<option value="">Sélectionner…</option>
{campus.map((c) => <option key={c.id} value={c.id}>{c.libelle}</option>)}
</select>
</div>
<div className="cf-field">
<label>Objet du contrat</label>
<input value={objet} onChange={(e) => setObjet(e.target.value)} placeholder="Certification des titres 2026" />
</div>
<div className="cf-field">
<label>Date de signature</label>
<input type="date" value={signature} onChange={(e) => setSignature(e.target.value)} />
</div>
<div className="cf-field">
<label>Référence contrat</label>
<input value={reference} onChange={(e) => setReference(e.target.value)} placeholder="CT-2026-014" />
</div>
<div className="cf-field">
<label>Date de début</label>
<input type="date" value={debut} onChange={(e) => setDebut(e.target.value)} />
</div>
<div className="cf-field">
<label>Date de fin</label>
<input type="date" value={fin} onChange={(e) => setFin(e.target.value)} />
</div>
<div className="cf-field">
<label>Montant total (EUR)</label>
<input value={montantTotal} onChange={(e) => setMontantTotal(e.target.value)} placeholder="12 000" />
</div>
<div className="cf-field">
<label>Périodicité de facturation</label>
<select value={periodicite} onChange={(e) => setPeriodicite(e.target.value as typeof periodicite)}>
<option value="mensuelle">Mensuelle</option>
<option value="trimestrielle">Trimestrielle</option>
<option value="annuelle">Annuelle</option>
<option value="unique">Paiement unique</option>
</select>
</div>
</div>
<div className="cf-echeancier-builder">
<div className="cf-echeancier-header">
<p className="cf-detail-label" style={{ margin: 0 }}>
Échéancier
</p>
<div style={{ display: "flex", gap: 8 }}>
<button className="cf-btn-small" onClick={genererEcheancier}>
Générer automatiquement
</button>
<button className="cf-btn-small" onClick={addEchRow}>
+ Ajouter une ligne
</button>
</div>
</div>
<table className="cf-table">
<thead>
<tr>
<th style={{ width: "22%" }}>Date d'échéance</th>
<th style={{ width: "18%" }}>Montant (EUR)</th>
<th style={{ width: "15%" }}>Statut</th>
<th style={{ width: "35%" }}>Justificatif</th>
<th style={{ width: "5%" }}></th>
</tr>
</thead>
<tbody>
{echRows.map((row) => (
<tr key={row.id}>
<td>
<input type="date" value={row.date} onChange={(e) => updateEchRow(row.id, { date: e.target.value })} />
</td>
<td>
<input value={row.montant} onChange={(e) => updateEchRow(row.id, { montant: e.target.value })} />
</td>
<td>
<span className="cf-status-pill" data-statut="A_VENIR">À venir</span>
</td>
<td>
<label className={`cf-row-attach-btn${row.justifName ? " attached" : ""}`}>
{row.justifName ?? "+ Ajouter la facture"}
<input
type="file"
style={{ display: "none" }}
onChange={(e) => attachFacture(row.id, e.target.files?.[0])}
/>
</label>
</td>
<td>
<button className="cf-btn-small cf-btn-danger" onClick={() => removeEchRow(row.id)}>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="cf-upload-zone">
<p className="cf-detail-label">
Documents généraux{" "}
<span className="cf-muted-inline">· contrat signé, avenants, CGV — non liés à une échéance</span>
</p>
<label className="cf-dropzone">
Cliquer pour ajouter le contrat signé, un avenant, les CGV...
<input type="file" multiple style={{ display: "none" }} onChange={(e) => addDocs(e.target.files)} />
</label>
<div className="cf-justif-list" style={{ marginTop: 10 }}>
{sortedDocs.map((doc) => (
<div className="cf-justif-item" key={doc.id}>
<input type="date" value={doc.date} onChange={(e) => updateDoc(doc.id, { date: e.target.value })} />
<select value={doc.tag} onChange={(e) => updateDoc(doc.id, { tag: e.target.value })} className="cf-doc-tag-select">
<option>Contrat signé</option>
<option>Avenant</option>
<option>CGV</option>
<option>Autre</option>
</select>
<span className="cf-justif-name">{doc.nom}</span>
<button className="cf-btn-small cf-btn-danger" onClick={() => removeDoc(doc.id)}>
×
</button>
</div>
))}
</div>
</div>
<div className="cf-form-actions">
<button
className="cf-btn-ghost"
onClick={() => {
resetForm();
setShowForm(false);
}}
>
Annuler
</button>
<button className="cf-btn-primary" onClick={saveContract} disabled={saving}>
{saving ? "Enregistrement…" : "Enregistrer le contrat"}
</button>
</div>
</div>
)}
<div>
{pageContracts.map((c) => {
const paid = c.echeances.filter((e) => e.statut === "PAYEE").length;
const total = c.echeances.length;
const pct = total ? Math.round((paid / total) * 100) : 0;
const isOpen = openIds.has(c.id);
return (
<div className={`cf-contract-card${isOpen ? " open" : ""}`} key={c.id}>
<div className="cf-contract-summary" onClick={() => toggleOpen(c.id)}>
<div>
<div className="cf-name">{c.fournisseur}</div>
<div className="cf-sub">{c.objet}</div>
</div>
<div className="cf-sub">
{fmtDate(c.dateDebut)} → {fmtDate(c.dateFin)}
</div>
<div className="cf-amount">{fmtEur(c.montantTotal)}</div>
<div className="cf-progress-wrap">
<div className="cf-progress-track">
<div className="cf-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="cf-progress-label">
{paid}/{total}
</span>
</div>
<div className="cf-sub"><span className="cf-status-pill">{c.statut.replaceAll("_", " ")}</span></div>
<div className="cf-chevron">›</div>
</div>
{isOpen && (
<div className="cf-detail">
<div className="cf-contract-meta">
<span><b>Statut :</b> {c.statut.replaceAll("_", " ")}</span>
<span><b>Service :</b> {c.service ?? "Non renseigné"}</span>
<span><b>Campus :</b> {c.campus ?? "Non renseigné"}</span>
</div>
{c.statut === "RESILIE" && (
<p className="cf-resiliation">Résilié le {fmtDate(c.dateResiliation ?? "")} — {c.motifResiliation}</p>
)}
{peutGerer && (
<div className="cf-status-action">
{statutEnCours !== c.id ? (
<button className="cf-btn-small" onClick={() => { setStatutEnCours(c.id); setNouveauStatut(c.statut); }}>
Changer le statut
</button>
) : (
<div className="cf-status-form">
<select value={nouveauStatut} onChange={(e) => setNouveauStatut(e.target.value as StatutContrat)}>
<option value="ACTIF">Actif</option><option value="A_RENOUVELER">À renouveler</option>
<option value="SUSPENDU">Suspendu</option><option value="RESILIE">Résilié</option>
<option value="EXPIRE">Expiré</option><option value="ARCHIVE">Archivé</option>
</select>
{nouveauStatut === "RESILIE" && <><input type="date" value={dateResiliation} onChange={(e) => setDateResiliation(e.target.value)} /><input value={motifResiliation} placeholder="Motif de résiliation" onChange={(e) => setMotifResiliation(e.target.value)} /></>}
<input value={commentaireStatut} placeholder="Commentaire (optionnel)" onChange={(e) => setCommentaireStatut(e.target.value)} />
<button className="cf-btn-small" onClick={() => enregistrerStatut(c.id)}>Enregistrer</button>
<button className="cf-btn-small" onClick={() => setStatutEnCours(null)}>Annuler</button>
</div>
)}
</div>
)}
<p className="cf-detail-label">Suivi du contrat</p>
<div className="cf-history">
{(historiques[c.id] ?? []).map((h) => <div key={h.id} className="cf-history-row"><b>{h.nouveauStatut.replaceAll("_", " ")}</b> · {h.utilisateur} · {fmtDate(h.dateAction.slice(0, 10))}{h.commentaire ? ` — ${h.commentaire}` : ""}</div>)}
{!historiques[c.id]?.length && <span className="cf-ech-justif-empty">Historique en cours de chargement…</span>}
</div>
<p className="cf-detail-label">
Échéancier <span className="cf-muted-inline">· justificatif rattaché à chaque échéance</span>
</p>
<table className="cf-table" style={{ marginBottom: 20 }}>
<thead>
<tr>
<th>Date</th>
<th>Montant</th>
<th>Statut</th>
<th>Justificatif</th>
<th style={{ width: 150 }}>Actions</th>
</tr>
</thead>
<tbody>
{c.echeances.map((e) => {
const isEditing = editBuffer?.echeanceId === e.id;
return (
<tr key={e.id}>
{isEditing && editBuffer ? (
<>
<td>
<input
type="date"
value={editBuffer.date}
onChange={(ev) => setEditBuffer({ ...editBuffer, date: ev.target.value })}
/>
</td>
<td>
<input
value={editBuffer.montant}
onChange={(ev) => setEditBuffer({ ...editBuffer, montant: ev.target.value })}
/>
</td>
<td>
<select
className="cf-inline-select"
value={editBuffer.statut}
onChange={(ev) => setEditBuffer({ ...editBuffer, statut: ev.target.value as StatutContratEcheance })}
>
<option value="A_VENIR">À venir</option>
<option value="PAYEE">Payée</option>
</select>
</td>
<td>
<label className={`cf-row-attach-btn${editBuffer.justif ? " attached" : ""}`}>
{editBuffer.justif ?? "+ Ajouter la facture"}
<input
type="file"
style={{ display: "none" }}
onChange={(ev) =>
setEditBuffer({ ...editBuffer, justif: ev.target.files?.[0]?.name ?? editBuffer.justif })
}
/>
</label>
</td>
<td>
<div style={{ display: "flex", gap: 6 }}>
<button className="cf-btn-small" onClick={() => saveEditEch(c.id)}>
Enregistrer
</button>
<button className="cf-btn-small" onClick={cancelEditEch}>
Annuler
</button>
</div>
</td>
</>
) : (
<>
<td>{fmtDate(e.date)}</td>
<td className="cf-amount-cell">{fmtEur(e.montant)}</td>
<td>
<span className="cf-status-pill" data-statut={e.statut}>{e.statut === "PAYEE" ? "Payée" : "À venir"}</span>
</td>
<td>
{e.justificatifNom ? (
<a href="#" className="cf-ech-justif-link" onClick={(ev) => ev.preventDefault()}>
{e.justificatifNom}
</a>
) : (
<span className="cf-ech-justif-empty">—</span>
)}
</td>
<td>
{peutGerer && <div style={{ display: "flex", gap: 6 }}>
<button className="cf-btn-small" onClick={() => startEditEch(e)}>Modifier</button>
<button className="cf-btn-small cf-btn-danger" onClick={() => deleteEch(c.id, e.id)}>Supprimer</button>
</div>}
</td>
</>
)}
</tr>
);
})}
</tbody>
</table>
<p className="cf-detail-label">
Documents généraux <span className="cf-muted-inline">· non liés à une échéance</span>
</p>
<div className="cf-justif-list">
{[...c.documents]
.sort((a, b) => a.date.localeCompare(b.date))
.map((doc) => (
<div className="cf-justif-item" key={doc.id}>
<span className="cf-justif-date">{fmtDate(doc.date)}</span>
<span className="cf-justif-tag">{doc.tag}</span>
<span className="cf-justif-name">{doc.nom}</span>
<a href="#" onClick={(ev) => ev.preventDefault()}>
Voir
</a>
</div>
))}
{c.documents.length === 0 && <span className="cf-ech-justif-empty">Aucun document.</span>}
</div>
<p className="cf-detail-label">
Factures liées <span className="cf-muted-inline">· rapprochement, pas de lien automatique à une échéance précise</span>
</p>
<div className="cf-justif-list">
{[...c.facturesLiees]
.sort((a, b) => b.dateFacture.localeCompare(a.dateFacture))
.map((f) => (
<div className="cf-justif-item" key={f.id}>
<span className="cf-justif-date">{fmtDate(f.dateFacture)}</span>
<span className="cf-justif-tag">{f.numero}</span>
<span className="cf-justif-name">{fmtEur(f.montantTtc)}</span>
</div>
))}
{c.facturesLiees.length === 0 && <span className="cf-ech-justif-empty">Aucune facture rattachée à ce contrat.</span>}
</div>
</div>
)}
</div>
);
})}
{!loading && contracts.length === 0 && (
<div className="cf-panel" style={{ textAlign: "center", color: "var(--text-muted)" }}>Aucun contrat enregistré.</div>
)}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
</div>
);
}
const css = `
.cf-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.cf-root *{ box-sizing:border-box; }
.cf-wrap{ max-width:1180px; margin:0 auto; padding:28px 32px 64px; }
.cf-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.cf-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.cf-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.cf-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.cf-btn-primary:hover{ background:#25405C; }
.cf-btn-primary:disabled{ opacity:0.6; cursor:default; }
.cf-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.cf-btn-ghost:hover{ border-color:var(--accent); }
.cf-btn-small{ font-family:var(--sans); font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.cf-btn-small:hover{ border-color:var(--accent); color:var(--accent); }
.cf-btn-danger{ border:none; color:var(--danger); background:transparent; }
.cf-error-banner{ background:var(--danger-light); color:var(--danger); border-radius:var(--radius); padding:10px 14px; font-size:13px; margin-bottom:16px; }
.cf-contract-card{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); margin-bottom:12px; overflow:hidden; }
.cf-contract-summary{ display:grid; grid-template-columns:1.6fr 1fr 1fr 1fr 0.8fr 40px; align-items:center; gap:12px; padding:14px 20px; cursor:pointer; }
.cf-contract-summary:hover{ background:var(--surface-2); }
.cf-name{ font-weight:500; }
.cf-sub{ font-size:12px; color:var(--text-muted); margin-top:2px; }
.cf-amount{ font-family:var(--mono); }
.cf-amount-cell{ font-family:var(--mono); }
.cf-progress-wrap{ display:flex; align-items:center; gap:8px; }
.cf-progress-track{ background:var(--surface-2); border-radius:4px; height:6px; flex:1; overflow:hidden; }
.cf-progress-fill{ height:100%; background:var(--accent); border-radius:4px; }
.cf-progress-label{ font-size:11px; color:var(--text-muted); white-space:nowrap; }
.cf-chevron{ transition:transform 0.15s ease; color:var(--text-muted); text-align:right; }
.cf-contract-card.open .cf-chevron{ transform:rotate(90deg); }
.cf-detail{ border-top:1px solid var(--border); padding:16px 20px 20px; }
.cf-contract-meta{ display:flex; flex-wrap:wrap; gap:10px 22px; padding:10px 12px; margin-bottom:12px; background:var(--surface-2); border-radius:6px; font-size:12px; color:var(--text-2); }
.cf-resiliation{ margin:0 0 12px; padding:8px 10px; background:var(--danger-light); color:var(--danger); border-radius:6px; font-size:12px; }
.cf-status-action{ margin:0 0 16px; }
.cf-status-form{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px; background:var(--accent-light); border-radius:6px; }
.cf-status-form select,.cf-status-form input{ min-height:30px; border:1px solid var(--border); border-radius:5px; padding:4px 8px; font-family:var(--sans); font-size:12px; background:var(--surface); }
.cf-history{ margin:0 0 20px; border-left:2px solid var(--border); padding-left:12px; }
.cf-history-row{ font-size:12px; margin:0 0 7px; color:var(--text-2); }
.cf-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 10px; }
.cf-muted-inline{ text-transform:none; color:var(--text-muted); font-weight:400; }
.cf-table{ width:100%; border-collapse:collapse; font-size:13px; }
.cf-table th{ text-align:left; font-size:11px; color:var(--text-muted); font-weight:500; padding:6px 8px; border-bottom:1px solid var(--border); }
.cf-table td{ padding:8px; border-bottom:1px solid var(--border); }
.cf-status-pill{ font-size:11px; padding:2px 8px; border-radius:20px; background:var(--surface-2); color:var(--text-2); }
.cf-status-pill[data-statut="PAYEE"]{ background:var(--success-light); color:var(--success); }
.cf-status-pill[data-statut="A_VENIR"]{ background:var(--surface-2); color:var(--text-2); }
.cf-ech-justif-link{ font-size:12px; color:var(--accent); text-decoration:none; }
.cf-ech-justif-link:hover{ text-decoration:underline; }
.cf-ech-justif-empty{ font-size:12px; color:var(--text-muted); }
.cf-justif-list{ display:flex; flex-direction:column; gap:8px; }
.cf-justif-item{ display:flex; align-items:center; gap:8px; background:var(--surface-2); border-radius:6px; padding:8px 10px; font-size:13px; }
.cf-justif-date{ font-family:var(--mono); font-size:12px; color:var(--text-2); width:66px; flex-shrink:0; }
.cf-justif-tag{ font-size:11px; padding:2px 8px; border-radius:20px; background:var(--accent-light); color:var(--accent); flex-shrink:0; }
.cf-justif-name{ flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cf-justif-item a{ font-size:12px; color:var(--accent); text-decoration:none; flex-shrink:0; }
.cf-justif-item input[type="date"]{ font-family:var(--mono); font-size:12px; border:none; background:transparent; width:100px; padding:0; flex-shrink:0; }
.cf-doc-tag-select{ border:none; background:transparent; font-size:11px; color:var(--accent); width:auto; padding:0; flex-shrink:0; }
.cf-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.cf-panel-title{ font-size:15px; font-weight:600; margin:0 0 18px; }
.cf-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; }
.cf-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.cf-field input, .cf-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.cf-field input:focus, .cf-field select:focus{ outline:none; border-color:var(--accent); }
.cf-echeancier-builder{ margin-top:20px; border-top:1px solid var(--border); padding-top:18px; }
.cf-echeancier-header{ display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.cf-table input{ width:100%; font-family:var(--mono); font-size:13px; padding:5px 6px; border:1px solid var(--border); border-radius:4px; }
.cf-table input[type="date"]{ font-family:var(--sans); }
.cf-table td{ padding:4px; }
.cf-inline-select{ font-family:var(--sans); font-size:13px; padding:5px 6px; border:1px solid var(--border); border-radius:4px; background:var(--surface); }
.cf-row-attach-btn{ font-family:var(--sans); font-size:12px; padding:5px 8px; border-radius:4px; border:1px dashed var(--border); background:var(--surface); color:var(--text-muted); cursor:pointer; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
.cf-row-attach-btn:hover{ border-color:var(--accent); color:var(--accent); }
.cf-row-attach-btn.attached{ border-style:solid; color:var(--accent); border-color:var(--accent-light); background:var(--accent-light); }
.cf-upload-zone{ margin-top:20px; border-top:1px solid var(--border); padding-top:18px; }
.cf-dropzone{ display:block; border:1.5px dashed var(--border); border-radius:var(--radius); padding:20px; text-align:center; color:var(--text-muted); font-size:13px; cursor:pointer; }
.cf-dropzone:hover{ border-color:var(--accent); color:var(--accent); }
.cf-form-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }
.cf-pagination{ display:flex; align-items:center; justify-content:center; gap:6px; padding:20px 0 4px; }
.cf-page-btn{ min-width:32px; height:32px; padding:0 8px; border-radius:7px; font-family:var(--sans); font-size:13px; font-weight:500; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.cf-page-btn:hover:not(:disabled){ border-color:var(--accent); color:var(--accent); }
.cf-page-btn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.cf-page-btn:disabled{ opacity:0.4; cursor:default; }
.cf-page-ellipsis{ width:24px; text-align:center; color:var(--text-muted); font-size:13px; }
@media (max-width:900px){
.cf-contract-summary{ grid-template-columns:1fr; row-gap:6px; }
.cf-form-grid{ grid-template-columns:1fr; }
}
`;
+393
View File
@@ -0,0 +1,393 @@
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { achatsApi, financeApi, nomTypeDepense } from "../data";
import { useWorkspace } from "../Layout";
import { useAuth } from "../auth";
const c = {
page: "#F7F5EF",
card: "#FFFFFF",
border: "#E8E4D9",
ink: "#1A1A18",
secondary: "#6B6B62",
muted: "#9C9C90",
hover: "#FAF9F5",
amberText: "#92400E",
amberBg: "#FDF1DC",
greenText: "#2F7D5C",
greenBg: "#E4F0EA",
grayText: "#5B6472",
grayBg: "#EDEEF0",
redText: "#B23A34",
redBg: "#F6E6E4",
blueText: "#1D4ED8",
blueBg: "#E8EEFC",
};
const euro = (n: number) => new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
const STATUT: Record<string, { label: string; text: string; bg: string }> = {
BROUILLON: { label: "Brouillon", text: c.grayText, bg: c.grayBg },
SOUMISE: { label: "À valider", text: c.amberText, bg: c.amberBg },
EN_VALIDATION: { label: "À valider", text: c.amberText, bg: c.amberBg },
COMPLEMENT_REQUIS: { label: "Complément", text: c.amberText, bg: c.amberBg },
VALIDEE: { label: "Validée", text: c.greenText, bg: c.greenBg },
FACTUREE: { label: "Facturée", text: c.blueText, bg: c.blueBg },
REFUSEE: { label: "Refusée", text: c.redText, bg: c.redBg },
ANNULEE: { label: "Annulée", text: c.grayText, bg: c.grayBg },
ARCHIVEE: { label: "Archivée", text: c.grayText, bg: c.grayBg },
};
function StatutBadge({ statut }: { statut: string }) {
const s = STATUT[statut] ?? { label: statut, text: c.grayText, bg: c.grayBg };
return (
<span style={{ fontSize: 12, fontWeight: 600, color: s.text, background: s.bg, borderRadius: 6, padding: "3px 9px", whiteSpace: "nowrap" }}>
{s.label}
</span>
);
}
function Row({ onClick, children }: { onClick?: () => void; children: React.ReactNode }) {
return (
<div
style={{
display: "flex", alignItems: "center", gap: 12, padding: "12px 8px", margin: "0 -8px",
borderRadius: 8, cursor: onClick ? "pointer" : "default", borderBottom: `1px solid ${c.border}`,
}}
onClick={onClick}
onMouseEnter={(e) => (e.currentTarget.style.background = c.hover)}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{children}
</div>
);
}
function KpiCard({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) {
return (
<div style={{ borderRadius: 16, padding: 20, background: c.card, border: `1px solid ${c.border}`, boxShadow: "0 1px 2px rgba(20,20,15,0.03)" }}>
<p style={{ fontSize: 13, color: c.secondary, margin: "0 0 10px" }}>{label}</p>
<p style={{ fontSize: 26, fontWeight: 700, color: c.ink, margin: 0, letterSpacing: "-0.01em" }}>{value}</p>
{sub && <p style={{ fontSize: 13, color: subColor ?? c.secondary, margin: "8px 0 0" }}>{sub}</p>}
</div>
);
}
function Panel({ title, action, badge, children }: { title: string; action?: React.ReactNode; badge?: number; children: React.ReactNode }) {
return (
<div style={{ borderRadius: 16, overflow: "hidden", minWidth: 0, background: c.card, border: `1px solid ${c.border}`, boxShadow: "0 1px 2px rgba(20,20,15,0.03)" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: `1px solid ${c.border}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: c.ink }}>{title}</span>
{typeof badge === "number" && (
<span style={{ fontSize: 12, fontWeight: 700, color: c.redText, background: c.redBg, borderRadius: 10, padding: "1px 8px" }}>{badge}</span>
)}
</div>
{action}
</div>
<div style={{ padding: "0 20px" }}>{children}</div>
</div>
);
}
type Severite = "danger" | "warning" | "info" | "primary";
const SEVERITY: Record<Severite, string> = { danger: c.redText, warning: c.amberText, info: c.blueText, primary: c.blueText };
function AlerteRow({ a, onClick }: { a: { titre: string; detail: string; severite: Severite }; onClick?: () => void }) {
return (
<Row onClick={onClick}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: SEVERITY[a.severite], marginTop: 6, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 14, fontWeight: 600, color: c.ink, margin: 0 }}>{a.titre}</p>
<p style={{ fontSize: 12.5, color: c.secondary, margin: "2px 0 0" }}>{a.detail}</p>
</div>
</Row>
);
}
function DemandeRow({ d, onClick }: { d: { objet: string; reference: string; type: string; montant: number; statut: string }; onClick?: () => void }) {
return (
<Row onClick={onClick}>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 14.5, fontWeight: 600, color: c.ink, margin: 0 }}>{d.objet}</p>
<p style={{ fontSize: 12.5, color: c.secondary, margin: "2px 0 0" }}>{d.reference} · {d.type}</p>
</div>
<span style={{ fontSize: 14.5, fontWeight: 600, color: c.ink, fontVariantNumeric: "tabular-nums", width: 90, textAlign: "right" }}>{euro(d.montant)}</span>
<div style={{ width: 100, textAlign: "right" }}>
<StatutBadge statut={d.statut} />
</div>
</Row>
);
}
type Urgence = "echue" | "proche" | "normale";
const URGENCE: Record<Urgence, { label: string; text: string; dateText: string }> = {
echue: { label: "Échue", text: c.redText, dateText: c.redText },
proche: { label: "Dans 5 j", text: c.amberText, dateText: c.amberText },
normale: { label: "À venir", text: c.secondary, dateText: c.ink },
};
const MOIS_ABBR = ["JANV", "FÉVR", "MARS", "AVR", "MAI", "JUIN", "JUIL", "AOÛT", "SEPT", "OCT", "NOV", "DÉC"];
function EcheanceRow({ e }: { e: { jour: string; mois: string; fournisseur: string; montant: number; urgence: Urgence; joursRestants: number } }) {
const u = URGENCE[e.urgence];
const label = e.urgence === "proche" ? `Dans ${e.joursRestants} j` : u.label;
return (
<Row>
<div
style={{
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
borderRadius: 8, flexShrink: 0, width: 46, height: 46, border: `1px solid ${c.border}`, background: c.page,
}}
>
<span style={{ fontSize: 15, fontWeight: 700, color: u.dateText, lineHeight: 1 }}>{e.jour}</span>
<span style={{ fontSize: 9.5, color: c.muted, textTransform: "uppercase", letterSpacing: "0.04em" }}>{e.mois}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 14, fontWeight: 600, color: c.ink, margin: 0 }}>{e.fournisseur}</p>
<p style={{ fontSize: 12, color: u.text, margin: "2px 0 0", fontWeight: 500 }}>{label}</p>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: c.ink, fontVariantNumeric: "tabular-nums" }}>{euro(e.montant)}</span>
</Row>
);
}
const EN_ATTENTE = new Set(["SOUMISE", "EN_VALIDATION", "COMPLEMENT_REQUIS"]);
const SEUIL_RENOUVELLEMENT_JOURS = 60;
function trimestreDe(iso: string) {
const d = new Date(iso);
return { annee: d.getFullYear(), trimestre: Math.floor(d.getMonth() / 3) + 1 };
}
export default function Dashboard() {
const navigate = useNavigate();
const { role } = useWorkspace();
const { roles } = useAuth();
const hasFinanceRole = roles.includes('FINANCE');
// Mêmes queryKey que dans Layout (scope inclus) : React Query dédoublonne les appels
// et sert le cache, donc un retour sur le dashboard s'affiche instantanément. scope=mine
// n'est forcé que pour un utilisateur Finance basculé en espace Collaborateur (vue
// étroite volontaire) — sinon on laisse le backend inclure les demandes des
// collaborateurs dont l'utilisateur est responsable.
const demandesScope = role === 'collaborateur' && hasFinanceRole ? 'mine' : undefined;
const demandesQ = useQuery({ queryKey: ["demandes", demandesScope], queryFn: () => achatsApi.getDemandes(demandesScope) });
const facturesQ = useQuery({ queryKey: ["factures"], queryFn: financeApi.getFactures });
const avoirsQ = useQuery({ queryKey: ["avoirs"], queryFn: financeApi.getAvoirs });
// L'endpoint /api/contrats filtre déjà côté backend par service du responsable
// connecté (cf. ContratsEndpoints.MapGet) — pas besoin de re-filtrer ici.
const contratsQ = useQuery({ queryKey: ["contrats"], queryFn: financeApi.getContrats });
const demandes = demandesQ.data ?? [];
const factures = facturesQ.data ?? [];
const avoirs = avoirsQ.data ?? [];
const contrats = contratsQ.data ?? [];
const loading = demandesQ.isLoading || facturesQ.isLoading || avoirsQ.isLoading || contratsQ.isLoading;
const errorObj = demandesQ.error ?? facturesQ.error ?? avoirsQ.error ?? contratsQ.error;
if (loading) {
return <div style={{ width: "100%", padding: 20, background: c.page, fontFamily: "'Inter', system-ui, sans-serif" }}><p style={{ color: c.secondary }}>Chargement…</p></div>;
}
if (errorObj) {
const message = errorObj instanceof Error ? errorObj.message : "Erreur de chargement";
return <div style={{ width: "100%", padding: 20, background: c.page, fontFamily: "'Inter', system-ui, sans-serif" }}><p style={{ color: c.redText }}>Erreur : {message}</p></div>;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
// --- KPI : engagé ce trimestre (demandes validées/facturées) vs trimestre précédent ---
const { annee: anneeCourante, trimestre: trimCourant } = trimestreDe(today.toISOString());
const demandesEngagees = demandes.filter((d) => d.statut === "VALIDEE" || d.statut === "FACTUREE");
const sommeTrimestre = (annee: number, trimestre: number) =>
demandesEngagees
.filter((d) => { const t = trimestreDe(d.dateCreation); return t.annee === annee && t.trimestre === trimestre; })
.reduce((s, d) => s + d.montantTtc, 0);
const engageCourant = sommeTrimestre(anneeCourante, trimCourant);
const { annee: anneePrec, trimestre: trimPrec } = trimCourant === 1 ? { annee: anneeCourante - 1, trimestre: 4 } : { annee: anneeCourante, trimestre: trimCourant - 1 };
const engagePrecedent = sommeTrimestre(anneePrec, trimPrec);
const variationPct = engagePrecedent > 0 ? Math.round(((engageCourant - engagePrecedent) / engagePrecedent) * 1000) / 10 : null;
// --- KPI : demandes à valider ---
const demandesAValider = demandes.filter((d) => EN_ATTENTE.has(d.statut) && d.peutValider);
const montantAValider = demandesAValider.reduce((s, d) => s + d.montantTtc, 0);
// --- KPI : à payer (factures validées non encore payées) ---
const facturesAPayer = factures.filter((f) => f.statut === "validee");
const montantAPayer = facturesAPayer.reduce((s, f) => s + f.montantTtc, 0);
// --- KPI : échéances à moins de 7 jours + échues ---
const facturesEnCours = factures.filter((f) => f.statut !== "payee");
const facturesEchues = facturesEnCours.filter((f) => new Date(f.dateEcheance) < today);
const facturesProches = facturesEnCours.filter((f) => {
const jours = Math.ceil((new Date(f.dateEcheance).getTime() - today.getTime()) / 86400000);
return jours >= 0 && jours <= 7;
});
const openDemande = (id: number) => navigate("/demandes", { state: { openDemandeId: id } });
const openContrat = (id: number) => navigate("/contrats-fournisseurs", { state: { openContratId: id } });
// --- Demandes récentes ---
const demandesRecentes = [...demandes]
.sort((a, b) => new Date(b.dateCreation).getTime() - new Date(a.dateCreation).getTime())
.slice(0, 4)
.map((d) => ({ id: d.id, objet: d.objet, reference: d.reference, type: nomTypeDepense(d.typeDepenseId), montant: d.montantTtc, statut: d.statut }));
// --- Alertes ---
const alertes: { titre: string; detail: string; severite: Severite; lien?: string; demandeId?: number; contratId?: number }[] = [];
for (const f of facturesEchues) {
alertes.push({
titre: `Échéance dépassée — ${f.numero}`,
detail: `${f.fournisseur} · ${euro(f.montantTtc)} · échoue le ${new Date(f.dateEcheance).toLocaleDateString("fr-FR")} · dossier bloqué`,
severite: "danger",
lien: "/paiements",
});
}
for (const a of avoirs) {
if (a.soldeDisponible > 0) {
alertes.push({
titre: `Avoir disponible — ${a.fournisseur}`,
detail: `${euro(a.soldeDisponible)} à affecter avant le prochain paiement`,
severite: "info",
lien: "/factures",
});
}
}
if (demandesAValider.length > 0) {
alertes.push({
titre: "Demandes à valider",
detail: `${demandesAValider.length} demande${demandesAValider.length > 1 ? "s" : ""} en attente de votre décision`,
severite: "warning",
lien: demandesAValider.length === 1 ? undefined : "/demandes",
demandeId: demandesAValider.length === 1 ? demandesAValider[0].id : undefined,
});
}
// --- Alertes contrats fournisseurs (renouvellement proche/dépassé + échéances impayées en retard) ---
const contratsActifs = contrats.filter((ct) => !["RESILIE", "EXPIRE", "ARCHIVE"].includes(ct.statut));
for (const ct of contratsActifs) {
const joursAvantFin = Math.ceil((new Date(ct.dateFin).getTime() - today.getTime()) / 86400000);
if (joursAvantFin <= SEUIL_RENOUVELLEMENT_JOURS) {
alertes.push({
titre: joursAvantFin < 0 ? `Contrat expiré — ${ct.fournisseur}` : `Contrat à renouveler — ${ct.fournisseur}`,
detail: joursAvantFin < 0
? `${ct.objet} · expiré depuis le ${new Date(ct.dateFin).toLocaleDateString("fr-FR")}`
: `${ct.objet} · échéance dans ${joursAvantFin} j (${new Date(ct.dateFin).toLocaleDateString("fr-FR")})`,
severite: joursAvantFin < 0 ? "danger" : joursAvantFin <= 30 ? "warning" : "info",
contratId: ct.id,
});
}
const echeancesEnRetard = ct.echeances.filter((e) => e.statut === "A_VENIR" && new Date(e.date) < today);
if (echeancesEnRetard.length > 0) {
const montantRetard = echeancesEnRetard.reduce((s, e) => s + e.montant, 0);
alertes.push({
titre: `Échéance contrat en retard — ${ct.fournisseur}`,
detail: `${echeancesEnRetard.length} échéance${echeancesEnRetard.length > 1 ? "s" : ""} · ${euro(montantRetard)} · ${ct.objet}`,
severite: "danger",
contratId: ct.id,
});
}
}
// --- Échéances à venir ---
const echeances = [...facturesEnCours]
.sort((a, b) => new Date(a.dateEcheance).getTime() - new Date(b.dateEcheance).getTime())
.slice(0, 5)
.map((f) => {
const dt = new Date(f.dateEcheance);
const jours = Math.ceil((dt.getTime() - today.getTime()) / 86400000);
const urgence: Urgence = jours < 0 ? "echue" : jours <= 7 ? "proche" : "normale";
return {
jour: String(dt.getDate()).padStart(2, "0"),
mois: MOIS_ABBR[dt.getMonth()],
fournisseur: f.fournisseur,
montant: f.montantTtc,
urgence,
joursRestants: jours,
ref: f.numero,
};
});
const premiereADemander = demandesAValider[0];
return (
<div style={{ width: "100%", padding: 20, background: c.page, fontFamily: "'Inter', system-ui, sans-serif" }}>
<h1 style={{ fontSize: 24, fontWeight: 700, color: c.ink, margin: "0 0 4px" }}>Tableau de bord</h1>
<p style={{ fontSize: 14, color: c.secondary, margin: "0 0 20px" }}>Vue d'ensemble du module Achats</p>
{demandesAValider.length > 0 && premiereADemander && (
<div
onClick={() => (demandesAValider.length === 1 ? openDemande(premiereADemander.id) : navigate("/demandes"))}
style={{
display: "flex", alignItems: "center", gap: 12, padding: "14px 20px", borderRadius: 16, marginBottom: 20, cursor: "pointer",
background: c.amberBg, border: `1px solid ${c.amberText}30`,
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: c.amberText, flexShrink: 0 }} />
<p style={{ flex: 1, fontSize: 14.5, margin: 0, color: c.ink }}>
<strong>{demandesAValider.length === 1 ? "1 demande à valider" : `${demandesAValider.length} demandes à valider`}</strong>
<span style={{ color: c.secondary }}> — {premiereADemander.reference} · {euro(premiereADemander.montantTtc)}{demandesAValider.length > 1 ? " et autres" : ""}</span>
</p>
<span style={{ fontSize: 13.5, color: c.amberText, fontWeight: 700, whiteSpace: "nowrap" }}>Traiter →</span>
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 20 }}>
<KpiCard
label="Engagé ce trimestre"
value={euro(engageCourant)}
sub={variationPct === null ? undefined : `${variationPct >= 0 ? "+" : ""}${variationPct} % vs trimestre précédent`}
subColor={variationPct === null ? undefined : variationPct >= 0 ? c.greenText : c.redText}
/>
<KpiCard label="À valider" value={String(demandesAValider.length)} sub={demandesAValider.length ? `${euro(montantAValider)} en attente` : undefined} subColor={c.amberText} />
<KpiCard label="À payer" value={String(facturesAPayer.length)} sub={facturesAPayer.length ? euro(montantAPayer) : undefined} />
<KpiCard
label="Échéances < 7 j"
value={String(facturesProches.length)}
sub={facturesEchues.length ? `+${facturesEchues.length} facture${facturesEchues.length > 1 ? "s" : ""} échoue${facturesEchues.length > 1 ? "nt" : ""}` : undefined}
subColor={c.redText}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: 16, marginBottom: 16 }}>
<Panel title="Demandes récentes">
{demandesRecentes.length === 0 ? (
<p style={{ padding: "16px 0", color: c.secondary, fontSize: 13.5 }}>Aucune demande pour le moment.</p>
) : (
demandesRecentes.map((d) => <DemandeRow key={d.reference} d={d} onClick={() => openDemande(d.id)} />)
)}
</Panel>
<Panel title="Alertes" badge={alertes.length}>
{alertes.length === 0 ? (
<p style={{ padding: "16px 0", color: c.secondary, fontSize: 13.5 }}>Aucune alerte en cours.</p>
) : (
alertes.map((a, i) => (
<AlerteRow
key={`${a.titre}-${i}`}
a={a}
onClick={
a.demandeId != null ? () => openDemande(a.demandeId!) :
a.contratId != null ? () => openContrat(a.contratId!) :
a.lien ? () => navigate(a.lien!) : undefined
}
/>
))
)}
</Panel>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: 16 }}>
<Panel title="Échéances à venir">
{echeances.length === 0 ? (
<p style={{ padding: "16px 0", color: c.secondary, fontSize: 13.5 }}>Aucune échéance en attente.</p>
) : (
echeances.map((e) => <EcheanceRow key={e.ref} e={e} />)
)}
</Panel>
</div>
</div>
);
}
+721
View File
@@ -0,0 +1,721 @@
// src/pages/DemandeForm.tsx
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { achatsApi, sharepointApi, type Service, type TypeDepense, type ModePaiement, type Fournisseur, type DemandeAchat, type DemandeJustificatif } from '../data';
/*
Composant autonome — aucune dépendance externe hormis React.
Mêmes tokens de couleur / structure que ContratsFournisseurs.tsx,
FacturesAvoirs.tsx, Paiements.tsx, Demandes.tsx et Fournisseurs.tsx.
La logique métier (wizard, validation, appel achatsApi) est inchangée.
*/
const round2 = (n: number) => Math.round(n * 100) / 100;
const euro = (n: number, digits = 2) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: digits }).format(n);
const ENTREPRISES = ['ENSUP SQY', 'ENSUP CGY', 'ENSUP MRS', 'ENSUP NTE'];
const CATEGORIES_FOURNISSEUR = ['Informatique', 'Mobilier', 'Services', 'Logiciels', 'Fournitures'];
const STEPS = ['Général', 'Articles', 'Justificatifs', 'Planification'];
interface Ligne { article: string; quantite: number; prixUnitaire: number; }
interface Justif {
libelle: string; date: string; commentaire: string; nom: string;
sharepointUrl?: string; sharepointItemId?: string; tailleOctets?: number;
uploading?: boolean; uploadError?: string;
}
const isoToFr = (iso: string) => {
const [y, m, d] = iso.split('-');
return y && m && d ? `${d}/${m}/${y}` : '';
};
// Champ date texte affiché en JJ/MM/AAAA quel que soit la langue du navigateur —
// <input type="date"> natif affiche le format selon la locale du navigateur (souvent
// MM/DD/YYYY en anglais), ce qui n'est pas fiable. La valeur interne reste au format
// ISO (yyyy-mm-dd) pour rester compatible avec l'API.
function DateFrInput({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
const [text, setText] = useState(() => isoToFr(value));
useEffect(() => { setText(isoToFr(value)); }, [value]);
const handleChange = (raw: string) => {
const digits = raw.replace(/\D/g, '').slice(0, 8);
const formatted = digits.length > 4
? `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`
: digits.length > 2
? `${digits.slice(0, 2)}/${digits.slice(2)}`
: digits;
setText(formatted);
if (digits.length === 8) {
onChange(`${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}`);
} else if (digits.length === 0) {
onChange('');
}
};
return (
<input
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder="JJ/MM/AAAA"
inputMode="numeric"
maxLength={10}
/>
);
}
function Field({ label, error, children }: { label: string; error?: string; children: ReactNode }) {
return (
<div className="df-field">
<label>{label}</label>
{children}
{error && <p className="df-field-error">{error}</p>}
</div>
);
}
// Zone de dépôt de fichier (glisser-déposer ou clic) — upload réel vers SharePoint
// (dossier Achats) dès la sélection du fichier, pas seulement à la soumission du formulaire.
function DropZone({ nom, uploading, error, onFile }: {
nom: string; uploading?: boolean; error?: string; onFile: (file: File) => void;
}) {
const [over, setOver] = useState(false);
return (
<label
className={`df-dropzone${over ? ' over' : ''}${nom ? ' has-file' : ''}`}
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); const f = e.dataTransfer.files?.[0]; if (f) onFile(f); }}
>
{uploading ? 'Envoi en cours…' : nom ? `Fichier : ${nom} — cliquer pour remplacer` : 'Glisser un fichier ou cliquer — PDF, JPG, PNG'}
<input type="file" style={{ display: 'none' }} accept=".pdf,.jpg,.jpeg,.png" onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); }} />
{error && <p className="df-field-error">{error}</p>}
</label>
);
}
// Combobox de recherche fournisseur (remplace l'Autocomplete MUI)
function FournisseurCombo({ fournisseurs, value, onChange }: {
fournisseurs: Fournisseur[]; value: Fournisseur | null; onChange: (f: Fournisseur | null) => void;
}) {
const [query, setQuery] = useState(value?.raisonSociale ?? '');
const [open, setOpen] = useState(false);
useEffect(() => { setQuery(value?.raisonSociale ?? ''); }, [value]);
const filtered = useMemo(
() => fournisseurs.filter((f) => f.raisonSociale.toLowerCase().includes(query.toLowerCase())),
[fournisseurs, query]
);
return (
<div className="df-combo">
<input
value={query}
onChange={(e) => { setQuery(e.target.value); onChange(null); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
placeholder="Rechercher un fournisseur…"
/>
{open && filtered.length > 0 && (
<div className="df-combo-list">
{filtered.map((f) => (
<div key={f.id} className="df-combo-item" onMouseDown={() => { onChange(f); setQuery(f.raisonSociale); setOpen(false); }}>
{f.raisonSociale}
</div>
))}
</div>
)}
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="df-summary-row">
<span className="df-summary-label">{label}</span>
<span className="df-summary-value">{value}</span>
</div>
);
}
// Carte d'un justificatif
function JustifCard({ j, index, serviceLibelle, onChange, onRemove }: {
j: Justif; index: number; serviceLibelle?: string; onChange: (patch: Partial<Justif>) => void; onRemove: () => void;
}) {
const incomplet = !j.nom;
return (
<div className={`df-justif-card${incomplet ? ' incomplete' : ''}`}>
<div className="df-justif-card-head">
<span className="df-justif-card-title">Justificatif {index + 1}</span>
<span className={`df-chip${incomplet ? ' warn' : ' ok'}`}>{incomplet ? 'Incomplet' : 'Prêt'}</span>
</div>
<div className="df-form-grid" style={{ marginBottom: 14 }}>
<Field label="Date">
<DateFrInput value={j.date} onChange={(date) => onChange({ date })} />
</Field>
<Field label="Libellé">
<input value={j.libelle} onChange={(e) => onChange({ libelle: e.target.value })} placeholder="Ex : Devis Dell France" />
</Field>
</div>
<Field label="Commentaire (optionnel)">
<input value={j.commentaire} onChange={(e) => onChange({ commentaire: e.target.value })} />
</Field>
<p className="df-detail-label">Justificatif *</p>
<DropZone
nom={j.nom}
uploading={j.uploading}
error={j.uploadError}
onFile={async (file) => {
onChange({ nom: file.name, uploading: true, uploadError: undefined });
try {
const result = await sharepointApi.upload('Achats', file, serviceLibelle);
onChange({ nom: result.nom, sharepointUrl: result.webUrl, sharepointItemId: result.itemId, tailleOctets: result.size, uploading: false });
} catch (err) {
onChange({ uploading: false, uploadError: err instanceof Error ? err.message : "Échec de l'envoi du fichier." });
}
}}
/>
<div className="df-qr-box">Joindre via mobile (QR Code)</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<button className="df-btn-small df-btn-danger" onClick={onRemove}>Supprimer ce justificatif</button>
</div>
</div>
);
}
export default function DemandeForm() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = !!id;
const [demandeInit, setDemandeInit] = useState<DemandeAchat | null>(null);
const [existingJustificatifs, setExistingJustificatifs] = useState<DemandeJustificatif[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [types, setTypes] = useState<TypeDepense[]>([]);
const [modes, setModes] = useState<ModePaiement[]>([]);
const [fournisseurs, setFournisseurs] = useState<Fournisseur[]>([]);
const [activeStep, setActiveStep] = useState(0);
const [entreprise, setEntreprise] = useState('');
const [serviceId, setServiceId] = useState('');
const [objet, setObjet] = useState('');
const [description, setDescription] = useState('');
const [libreActif, setLibreActif] = useState(false);
const [fournisseurObj, setFournisseurObj] = useState<Fournisseur | null>(null);
const [fournisseurLibre, setFournisseurLibre] = useState('');
const [categorieFournisseurLibre, setCategorieFournisseurLibre] = useState('');
const [typeDepenseId, setTypeDepenseId] = useState('');
const [tauxTva, setTauxTva] = useState('20');
const [datePrevisionnelle, setDatePrevisionnelle] = useState('');
const [modePaiementId, setModePaiementId] = useState('');
const [lignes, setLignes] = useState<Ligne[]>([{ article: '', quantite: 1, prixUnitaire: 0 }]);
const [justificatifs, setJustificatifs] = useState<Justif[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
(async () => {
const [s, t, m, f] = await Promise.all([
achatsApi.getServices(), achatsApi.getTypesDepense(), achatsApi.getModesPaiement(), achatsApi.getFournisseurs(),
]);
setServices(s); setTypes(t); setModes(m); setFournisseurs(f);
})();
}, []);
// Pré-remplissage en mode édition. "entreprise" et les lignes d'articles individuelles
// ne sont pas persistées côté backend (seul le total montantHt l'est) : on synthétise
// une ligne unique représentant le total existant, et on laisse "entreprise" à
// sélectionner à nouveau (champ purement local au wizard, jamais envoyé à l'API).
useEffect(() => {
if (!id) return;
(async () => {
const d = await achatsApi.getDemande(Number(id));
setDemandeInit(d);
setServiceId(String(d.serviceId));
setObjet(d.objet);
setDescription(d.description ?? '');
if (d.fournisseurLibre) {
setLibreActif(true);
setFournisseurLibre(d.fournisseurLibre);
setCategorieFournisseurLibre(d.categorieFournisseurLibre ?? '');
}
setTypeDepenseId(String(d.typeDepenseId));
setTauxTva(String(d.tauxTva));
setDatePrevisionnelle(d.datePrevisionnelle);
setModePaiementId(String(d.modePaiementId));
setLignes([{ article: d.objet, quantite: 1, prixUnitaire: d.montantHt }]);
setExistingJustificatifs(d.justificatifs);
})();
}, [id]);
// Résolution du fournisseur référencé une fois la liste des fournisseurs chargée
// (peut arriver après la demande elle-même).
useEffect(() => {
if (demandeInit?.fournisseurId != null && fournisseurs.length > 0) {
const f = fournisseurs.find((x) => x.id === demandeInit.fournisseurId);
if (f) setFournisseurObj(f);
}
}, [demandeInit, fournisseurs]);
const setLigne = (i: number, patch: Partial<Ligne>) => setLignes((ls) => ls.map((l, idx) => (idx === i ? { ...l, ...patch } : l)));
const ajouterLigne = () => setLignes((ls) => [...ls, { article: '', quantite: 1, prixUnitaire: 0 }]);
const supprimerLigne = (i: number) => setLignes((ls) => (ls.length > 1 ? ls.filter((_, idx) => idx !== i) : ls));
const ajouterJustif = () => setJustificatifs((js) => [...js, { libelle: '', date: '', commentaire: '', nom: '' }]);
const setJustif = (i: number, patch: Partial<Justif>) => setJustificatifs((js) => js.map((j, idx) => (idx === i ? { ...j, ...patch } : j)));
const supprimerJustif = (i: number) => setJustificatifs((js) => js.filter((_, idx) => idx !== i));
const { montantHt, montantTva, montantTtc } = useMemo(() => {
const ht = round2(lignes.reduce((s, l) => s + (l.quantite || 0) * (l.prixUnitaire || 0), 0));
const tva = round2(ht * ((parseFloat(tauxTva) || 0) / 100));
return { montantHt: ht, montantTva: tva, montantTtc: round2(ht + tva) };
}, [lignes, tauxTva]);
// Validation propre à une étape du wizard (0 = Général … 3 = Planification)
function validateStepFields(step: number): Record<string, string> {
const e: Record<string, string> = {};
if (step === 0) {
if (!entreprise) e.entreprise = 'Entreprise requise';
if (!serviceId) e.serviceId = 'Service requis';
if (!typeDepenseId) e.typeDepenseId = 'Catégorie requise';
if (!objet.trim()) e.objet = 'Objet requis';
if (libreActif ? !fournisseurLibre.trim() : !fournisseurObj) e.fournisseur = 'Fournisseur requis';
if (libreActif && !categorieFournisseurLibre) e.categorieFournisseurLibre = 'Catégorie requise';
}
if (step === 1) {
if (!lignes.some((l) => l.article.trim() && l.prixUnitaire > 0 && l.quantite > 0)) e.lignes = 'Ajoutez au moins un article valide';
if (montantHt <= 0) e.lignes = 'Le montant total doit être supérieur à 0';
}
if (step === 3) {
if (!modePaiementId) e.modePaiementId = 'Mode requis';
if (!datePrevisionnelle) e.datePrevisionnelle = 'Date requise';
}
return e;
}
function handleNext() {
const e = validateStepFields(activeStep);
setErrors(e);
if (Object.keys(e).length === 0) setActiveStep((s) => Math.min(STEPS.length - 1, s + 1));
}
function handleBack() {
setErrors({});
setActiveStep((s) => Math.max(0, s - 1));
}
// Valide toutes les étapes ; si une étape est en erreur, on s'y replace
function validateAll() {
let allErrors: Record<string, string> = {};
let firstErrorStep = -1;
for (let s = 0; s < STEPS.length; s++) {
const e = validateStepFields(s);
if (Object.keys(e).length && firstErrorStep === -1) firstErrorStep = s;
allErrors = { ...allErrors, ...e };
}
setErrors(allErrors);
if (firstErrorStep !== -1) { setActiveStep(firstErrorStep); return false; }
return true;
}
async function handleSubmit() {
if (!validateAll()) return;
if (justificatifs.some((j) => j.uploading)) {
setActiveStep(2);
setSubmitError("Attends la fin de l'envoi des justificatifs avant de soumettre.");
return;
}
setSubmitting(true);
setSubmitError(null);
try {
const payload = {
serviceId: Number(serviceId), objet: objet.trim(), description: description.trim() || undefined,
fournisseurId: libreActif ? null : fournisseurObj?.id ?? null,
fournisseurLibre: libreActif ? fournisseurLibre.trim() : null,
categorieFournisseurLibre: libreActif ? categorieFournisseurLibre : null,
typeDepenseId: Number(typeDepenseId), montantHt,
tauxTva: parseFloat(tauxTva), datePrevisionnelle, modePaiementId: Number(modePaiementId),
justificatifs: justificatifs
.filter((j) => j.nom.trim())
.map((j) => ({
libelle: j.libelle.trim() || "Justificatif", nom: j.nom.trim(),
sharepointUrl: j.sharepointUrl, sharepointItemId: j.sharepointItemId, tailleOctets: j.tailleOctets,
})),
};
if (isEdit) {
await achatsApi.modifierDemande(Number(id), payload);
} else {
await achatsApi.createDemande(payload);
}
navigate('/demandes');
} catch (err) {
setSubmitError(err instanceof Error ? err.message : `Échec de ${isEdit ? 'la modification' : 'la création'} de la demande.`);
} finally {
setSubmitting(false);
}
}
const isLastStep = activeStep === STEPS.length - 1;
return (
<div className="df-root">
<style>{css}</style>
<div className="df-wrap">
<header className="df-header">
<div>
<p className="df-eyebrow">Achats · demandes</p>
<h1 className="df-h1">{isEdit ? 'Modifier la demande' : "Nouvelle demande d'achat"}</h1>
</div>
<button className="df-btn-ghost" onClick={() => navigate('/demandes')}>Annuler</button>
</header>
<div className="df-layout">
<div className="df-main">
<div className="df-steps">
{STEPS.map((label, i) => (
<div key={label} className={`df-step${i === activeStep ? ' active' : ''}${i < activeStep ? ' done' : ''}`}>
<span className="df-step-dot">{i < activeStep ? '✓' : i + 1}</span>
<span className="df-step-label">{label}</span>
{i < STEPS.length - 1 && <span className="df-step-line" />}
</div>
))}
</div>
<div className="df-panel">
{activeStep === 0 && (
<>
<Field label="Entreprise" error={errors.entreprise}>
<select value={entreprise} onChange={(e) => setEntreprise(e.target.value)}>
<option value="" disabled>Sélectionner…</option>
{ENTREPRISES.map((x) => <option key={x} value={x}>{x}</option>)}
</select>
</Field>
<Field label="Service demandeur" error={errors.serviceId}>
<select value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
<option value="" disabled>Sélectionner…</option>
{services.map((s) => <option key={s.id} value={s.id}>{s.libelle}</option>)}
</select>
</Field>
<Field label="Catégorie" error={errors.typeDepenseId}>
<select value={typeDepenseId} onChange={(e) => setTypeDepenseId(e.target.value)}>
<option value="" disabled>Sélectionner…</option>
{types.map((t) => <option key={t.id} value={t.id}>{t.libelle}</option>)}
</select>
</Field>
<Field label="Objet" error={errors.objet}>
<input value={objet} onChange={(e) => setObjet(e.target.value)} />
</Field>
<Field label="Description">
<textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} />
</Field>
<p className="df-detail-label" style={{ marginTop: 4 }}>Fournisseur</p>
<label className="df-checkbox-row">
<input type="checkbox" checked={libreActif} onChange={(e) => setLibreActif(e.target.checked)} />
Fournisseur non référencé (saisie libre)
</label>
{libreActif ? (
<>
<Field label="Nom du fournisseur" error={errors.fournisseur}>
<input value={fournisseurLibre} onChange={(e) => setFournisseurLibre(e.target.value)} />
</Field>
<Field label="Catégorie du fournisseur" error={errors.categorieFournisseurLibre}>
<select value={categorieFournisseurLibre} onChange={(e) => setCategorieFournisseurLibre(e.target.value)}>
<option value="" disabled>Sélectionner…</option>
{CATEGORIES_FOURNISSEUR.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
</>
) : (
<Field label="Fournisseur" error={errors.fournisseur}>
<FournisseurCombo fournisseurs={fournisseurs} value={fournisseurObj} onChange={setFournisseurObj} />
</Field>
)}
</>
)}
{activeStep === 1 && (
<>
<table className="df-table">
<thead>
<tr>
<th>Article</th>
<th style={{ width: 90 }}>Qté</th>
<th style={{ width: 140 }}>Prix HT</th>
<th style={{ width: 110, textAlign: 'right' }}>Total</th>
<th style={{ width: 30 }}></th>
</tr>
</thead>
<tbody>
{lignes.map((l, i) => (
<tr key={i}>
<td><input placeholder="Désignation" value={l.article} onChange={(e) => setLigne(i, { article: e.target.value })} /></td>
<td><input type="number" value={l.quantite} onChange={(e) => setLigne(i, { quantite: Math.max(0, Number(e.target.value)) })} /></td>
<td><input type="number" value={l.prixUnitaire} onChange={(e) => setLigne(i, { prixUnitaire: Math.max(0, Number(e.target.value)) })} /></td>
<td className="df-amount-cell">{euro(l.quantite * l.prixUnitaire)}</td>
<td>
<button className="df-btn-small df-btn-danger" onClick={() => supprimerLigne(i)} disabled={lignes.length === 1}>×</button>
</td>
</tr>
))}
</tbody>
</table>
<button className="df-btn-small" style={{ marginTop: 10 }} onClick={ajouterLigne}>+ Ajouter une ligne</button>
{errors.lignes && <p className="df-field-error" style={{ marginTop: 10 }}>{errors.lignes}</p>}
<div className="df-totals-row">
<Field label="Taux TVA (%)">
<input type="number" value={tauxTva} onChange={(e) => setTauxTva(e.target.value)} style={{ width: 100 }} />
</Field>
<div className="df-totals-bar">
<span>Total HT : <strong>{euro(montantHt)}</strong></span>
<span>TVA : <strong>{euro(montantTva)}</strong></span>
<span className="df-totals-ttc">Total TTC : <strong>{euro(montantTtc)}</strong></span>
</div>
</div>
</>
)}
{activeStep === 2 && (
<>
{isEdit && existingJustificatifs.length > 0 && (
<div className="df-existing-justifs">
<p className="df-detail-label">Justificatifs déjà joints</p>
{existingJustificatifs.map((j) => (
<div className="df-existing-justif-row" key={j.id}>
<span>📎</span>
{j.sharepointUrl ? (
<a href={j.sharepointUrl} target="_blank" rel="noreferrer">{j.libelle} — {j.nom}</a>
) : (
<span>{j.libelle} — {j.nom}</span>
)}
</div>
))}
</div>
)}
{justificatifs.length === 0 && (
<div className="df-empty-box">
{isEdit ? "Aucun nouveau justificatif à ajouter." : "Aucun justificatif. Ajoute un devis, un bon d'achat ou une facture."}
</div>
)}
{justificatifs.map((j, i) => (
<JustifCard key={i} j={j} index={i} serviceLibelle={services.find((s) => s.id === Number(serviceId))?.libelle} onChange={(patch) => setJustif(i, patch)} onRemove={() => supprimerJustif(i)} />
))}
<button className="df-add-justif-btn" onClick={ajouterJustif}>+ Ajouter un justificatif</button>
</>
)}
{activeStep === 3 && (
<>
<Field label="Date prévisionnelle" error={errors.datePrevisionnelle}>
<DateFrInput value={datePrevisionnelle} onChange={setDatePrevisionnelle} />
</Field>
<Field label="Mode de paiement" error={errors.modePaiementId}>
<select value={modePaiementId} onChange={(e) => setModePaiementId(e.target.value)}>
<option value="" disabled>Sélectionner…</option>
{modes.map((m) => <option key={m.id} value={m.id}>{m.libelle}</option>)}
</select>
</Field>
{isLastStep && montantHt <= 0 && (
<div className="df-alert">Retourne à l'étape "Articles" : le montant total doit être supérieur à 0.</div>
)}
</>
)}
{submitError && <div className="df-alert df-alert-error">{submitError}</div>}
<div className="df-form-actions">
<button className="df-btn-ghost" onClick={handleBack} disabled={activeStep === 0}>Retour</button>
<button className="df-btn-primary" onClick={isLastStep ? handleSubmit : handleNext} disabled={submitting}>
{isLastStep ? (submitting ? (isEdit ? 'Enregistrement…' : 'Création…') : (isEdit ? 'Enregistrer les modifications' : 'Créer la demande')) : 'Suivant'}
</button>
</div>
</div>
</div>
<aside className="df-summary">
<p className="df-summary-title">Récapitulatif</p>
<div className="df-summary-section">
<p className="df-summary-section-title">Général</p>
<SummaryRow label="Entreprise" value={entreprise || '—'} />
<SummaryRow label="Service" value={services.find((s) => s.id === Number(serviceId))?.libelle ?? '—'} />
<SummaryRow label="Catégorie" value={types.find((t) => t.id === Number(typeDepenseId))?.libelle ?? '—'} />
<SummaryRow label="Objet" value={objet || '—'} />
<SummaryRow label="Fournisseur" value={libreActif ? (fournisseurLibre || '—') : (fournisseurObj?.raisonSociale ?? '—')} />
{libreActif && <SummaryRow label="Catégorie fournisseur" value={categorieFournisseurLibre || '—'} />}
</div>
<div className="df-summary-section">
<p className="df-summary-section-title">Articles</p>
{lignes.filter((l) => l.article.trim()).length === 0 ? (
<p className="df-summary-empty">Aucun article</p>
) : (
lignes.filter((l) => l.article.trim()).map((l, i) => (
<div className="df-summary-row" key={i}>
<span className="df-summary-label">{l.article} × {l.quantite}</span>
<span className="df-summary-value">{euro(l.quantite * l.prixUnitaire)}</span>
</div>
))
)}
<div className="df-summary-row df-summary-total">
<span className="df-summary-label">Total TTC</span>
<span className="df-summary-value">{euro(montantTtc)}</span>
</div>
</div>
<div className="df-summary-section">
<p className="df-summary-section-title">Justificatifs</p>
{justificatifs.filter((j) => j.nom.trim()).length === 0 ? (
<p className="df-summary-empty">Aucun justificatif</p>
) : (
justificatifs.filter((j) => j.nom.trim()).map((j, i) => (
<SummaryRow key={i} label={j.libelle.trim() || 'Justificatif'} value={j.nom} />
))
)}
</div>
<div className="df-summary-section">
<p className="df-summary-section-title">Planification</p>
<SummaryRow label="Date prévisionnelle" value={datePrevisionnelle ? isoToFr(datePrevisionnelle) : '—'} />
<SummaryRow label="Mode de paiement" value={modes.find((m) => m.id === Number(modePaiementId))?.libelle ?? '—'} />
</div>
</aside>
</div>
</div>
</div>
);
}
const css = `
.df-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.df-root *{ box-sizing:border-box; }
.df-wrap{ max-width:1180px; margin:0 auto; padding:28px 32px 64px; }
.df-layout{ display:flex; gap:20px; align-items:flex-start; }
.df-main{ flex:1; min-width:0; }
.df-summary{ width:300px; flex-shrink:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; position:sticky; top:20px; }
.df-summary-title{ font-size:15px; font-weight:700; margin:0 0 16px; }
.df-summary-section{ padding-bottom:16px; margin-bottom:16px; border-bottom:1px solid var(--border); }
.df-summary-section:last-child{ border-bottom:none; margin-bottom:0; padding-bottom:0; }
.df-summary-section-title{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:600; margin:0 0 8px; }
.df-summary-row{ display:flex; justify-content:space-between; gap:10px; font-size:12.5px; padding:3px 0; }
.df-summary-label{ color:var(--text-2); }
.df-summary-value{ color:var(--text); font-weight:600; text-align:right; }
.df-summary-empty{ font-size:12.5px; color:var(--text-muted); margin:0; }
.df-summary-total{ margin-top:6px; padding-top:6px; border-top:1px solid var(--border); }
.df-summary-total .df-summary-label, .df-summary-total .df-summary-value{ font-weight:700; color:var(--text); }
.df-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.df-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.df-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.df-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.df-btn-primary:hover{ background:#25405C; }
.df-btn-primary:disabled{ opacity:0.6; cursor:default; }
.df-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.df-btn-ghost:hover{ border-color:var(--accent); }
.df-btn-ghost:disabled{ opacity:0.4; cursor:default; }
.df-btn-small{ font-family:var(--sans); font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.df-btn-small:hover{ border-color:var(--accent); color:var(--accent); }
.df-btn-small:disabled{ opacity:0.35; cursor:default; }
.df-btn-danger{ color:var(--danger); }
.df-btn-danger:hover{ border-color:var(--danger); color:var(--danger); }
.df-steps{ display:flex; align-items:center; margin-bottom:24px; }
.df-step{ display:flex; align-items:center; gap:8px; flex:1; }
.df-step:last-child{ flex:0; }
.df-step-dot{ width:26px; height:26px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; background:var(--surface-2); color:var(--text-muted); border:1px solid var(--border); flex-shrink:0; }
.df-step-label{ font-size:12.5px; color:var(--text-muted); white-space:nowrap; }
.df-step-line{ height:1px; background:var(--border); flex:1; margin:0 6px; }
.df-step.active .df-step-dot{ background:var(--accent); border-color:var(--accent); color:#fff; }
.df-step.active .df-step-label{ color:var(--text); font-weight:600; }
.df-step.done .df-step-dot{ background:var(--accent-light); border-color:var(--accent); color:var(--accent); }
.df-step.done .df-step-label{ color:var(--text-2); }
.df-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:22px 24px; }
.df-field{ margin-bottom:16px; }
.df-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.df-field input, .df-field select, .df-field textarea{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text); }
.df-field input:focus, .df-field select:focus, .df-field textarea:focus{ outline:none; border-color:var(--accent); }
.df-field textarea{ resize:vertical; font-family:var(--sans); }
.df-field-error{ font-size:12px; color:var(--danger); margin:5px 0 0; }
.df-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:0 16px; }
.df-checkbox-row{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--text-2); margin-bottom:14px; cursor:pointer; }
.df-combo{ position:relative; }
.df-combo input{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.df-combo input:focus{ outline:none; border-color:var(--accent); }
.df-combo-list{ position:absolute; top:calc(100% + 4px); left:0; right:0; background:var(--surface); border:1px solid var(--border); border-radius:6px; box-shadow:0 6px 16px rgba(28,30,27,0.08); max-height:220px; overflow-y:auto; z-index:5; }
.df-combo-item{ padding:8px 10px; font-size:13px; cursor:pointer; }
.df-combo-item:hover{ background:var(--surface-2); }
.df-table{ width:100%; border-collapse:collapse; font-size:13px; }
.df-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:6px 8px; border-bottom:1px solid var(--border); }
.df-table td{ padding:6px 8px; border-bottom:1px solid var(--border); }
.df-table input{ width:100%; font-family:var(--sans); font-size:13px; padding:6px 8px; border:1px solid var(--border); border-radius:4px; }
.df-amount-cell{ font-family:var(--mono); text-align:right; font-weight:600; }
.df-totals-row{ display:flex; align-items:flex-end; gap:20px; margin-top:14px; flex-wrap:wrap; }
.df-totals-row .df-field{ margin-bottom:0; flex:none; }
.df-totals-bar{ flex:1; display:flex; align-items:center; gap:24px; padding:12px 16px; border-radius:var(--radius); background:var(--accent-light); font-size:13px; color:var(--text-2); flex-wrap:wrap; }
.df-totals-bar strong{ color:var(--text); }
.df-totals-ttc{ margin-left:auto; font-size:14px; }
.df-totals-ttc strong{ color:var(--accent); }
.df-empty-box{ border:1px dashed var(--border); border-radius:var(--radius); padding:20px; text-align:center; color:var(--text-muted); font-size:13px; margin-bottom:14px; }
.df-existing-justifs{ background:var(--surface-2); border-radius:var(--radius); padding:12px 14px; margin-bottom:16px; }
.df-existing-justif-row{ display:flex; align-items:center; gap:8px; font-size:13px; padding:4px 0; }
.df-existing-justif-row a{ color:var(--accent); text-decoration:none; }
.df-existing-justif-row a:hover{ text-decoration:underline; }
.df-justif-card{ border:1px solid var(--border); border-radius:var(--radius); padding:16px; background:var(--surface); margin-bottom:14px; }
.df-justif-card.incomplete{ border-color:#e8b8b3; }
.df-justif-card-head{ display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; }
.df-justif-card-title{ font-weight:600; font-size:14px; }
.df-chip{ font-size:11px; font-weight:600; padding:2px 9px; border-radius:20px; }
.df-chip.warn{ background:var(--danger-light); color:var(--danger); }
.df-chip.ok{ background:var(--success-light); color:var(--success); }
.df-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 8px; }
.df-dropzone{ display:block; border:1.5px dashed var(--border); border-radius:var(--radius); padding:18px; text-align:center; color:var(--text-muted); font-size:13px; cursor:pointer; }
.df-dropzone:hover, .df-dropzone.over{ border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.df-dropzone.has-file{ border-style:solid; border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.df-qr-box{ margin-top:10px; width:100%; border:1px dashed var(--accent); border-radius:6px; padding:9px; text-align:center; background:var(--accent-light); color:var(--accent); font-size:12.5px; font-weight:600; }
.df-add-justif-btn{ width:100%; font-family:var(--sans); font-size:13px; padding:12px; border-radius:var(--radius); border:1.5px dashed var(--border); background:var(--surface); color:var(--text-muted); cursor:pointer; }
.df-add-justif-btn:hover{ border-color:var(--accent); color:var(--accent); }
.df-alert{ margin-top:6px; background:var(--warning-light); color:var(--warning); border-radius:6px; padding:10px 14px; font-size:13px; }
.df-alert-error{ margin-top:20px; background:var(--danger-light); color:var(--danger); }
.df-form-actions{ display:flex; justify-content:space-between; margin-top:32px; padding-top:20px; border-top:1px solid var(--border); }
@media (max-width:900px){
.df-layout{ flex-direction:column; }
.df-summary{ width:100%; position:static; }
}
@media (max-width:640px){
.df-form-grid{ grid-template-columns:1fr; }
.df-steps{ display:none; }
}
`;
+807
View File
@@ -0,0 +1,807 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
achatsApi, sharepointApi, formatDateFr, construireHistorique,
type DemandeAchat, type DemandeWorkflowHistorique, type DocumentWorkflow, type Fournisseur, type Service, type TypeDepense, type ModePaiement, type StatutDemande,
} from "../data";
import { useWorkspace } from "../Layout";
import { useAuth } from "../auth";
/*
Mêmes tokens de couleur / structure que ContratsFournisseurs.tsx,
FacturesAvoirs.tsx et Paiements.tsx. Données réelles via achatsApi.getDemandes().
*/
const fmtEur = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const STATUT_LABEL: Record<StatutDemande, string> = {
BROUILLON: "Brouillon",
SOUMISE: "Soumise",
EN_VALIDATION: "À valider",
COMPLEMENT_REQUIS: "Complément",
VALIDEE: "Validée",
VALIDEE_MANAGER: "Validée par le manager",
EN_VALIDATION_MATERIEL: "En validation matériel",
VALIDEE_MATERIEL: "Matériel validé",
EN_VALIDATION_FINANCE: "En validation Finance",
APPROUVEE: "Approuvée",
COMMANDE_EMISE: "Commande émise",
COMMANDE_CONFIRMEE: "Commande confirmée",
EN_ATTENTE_LIVRAISON: "En attente de livraison",
EN_ATTENTE_VERIFICATION_RECEPTION: "En contrôle de réception",
EN_CONTROLE_RECEPTION: "En contrôle de réception",
VERIFIEE_CONFORME: "Réception conforme",
RECEPTION_CONFORME: "Réception conforme",
VERIFIEE_NON_CONFORME: "Réception non conforme",
RECEPTION_VALIDEE: "Réception validée",
FACTURE_EN_RAPPROCHEMENT: "Facture en rapprochement",
CLOTUREE: "Clôturée",
CLOTURE: "Clôturée",
REFUSEE: "Refusée",
FACTUREE: "Facturée",
ANNULEE: "Annulée",
ARCHIVEE: "Archivée",
};
const TABS: { key: "TOUTES" | StatutDemande; label: string }[] = [
{ key: "TOUTES", label: "Toutes" },
{ key: "SOUMISE", label: "Soumises" },
{ key: "EN_VALIDATION", label: "À valider" },
{ key: "VALIDEE", label: "Validées" },
{ key: "REFUSEE", label: "Refusées" },
{ key: "COMPLEMENT_REQUIS", label: "Compléments" },
{ key: "BROUILLON", label: "Brouillons" },
];
const PEUT_STATUER = new Set<StatutDemande>(["SOUMISE", "EN_VALIDATION", "COMPLEMENT_REQUIS"]);
// Une facture peut être reçue pour tout type d'achat (matériel, prestation,
// abonnement…). L'action est disponible dès que la demande a été validée et
// disparaît pour les brouillons, demandes refusées/annulées ou incomplètes.
const STATUTS_FACTURE_AJOUTABLE: StatutDemande[] = [
"VALIDEE", "VALIDEE_MANAGER", "VALIDEE_MATERIEL", "EN_VALIDATION_FINANCE", "APPROUVEE",
"COMMANDE_EMISE", "COMMANDE_CONFIRMEE", "EN_ATTENTE_LIVRAISON",
"EN_ATTENTE_VERIFICATION_RECEPTION", "EN_CONTROLE_RECEPTION", "VERIFIEE_CONFORME",
"RECEPTION_CONFORME", "RECEPTION_VALIDEE", "FACTURE_EN_RAPPROCHEMENT",
];
type WorkflowAction = { label: string; action: string; acteur: "finance" | "demandeur" };
// L'endpoint /workflow attend un verbe métier, distinct du statut cible.
// Le serveur contrôle les droits et l'ordre des transitions.
const NEXT_WORKFLOW_ACTION: Partial<Record<StatutDemande, WorkflowAction>> = {
// Après l'accord du manager, c'est le demandeur qui transmet son besoin à
// la validation matériel. Finance suit cet état, sans pouvoir l'avancer.
VALIDEE_MANAGER: { label: "Transmettre à la validation matériel", action: "TRANSMETTRE_MATERIEL", acteur: "demandeur" },
EN_VALIDATION_MATERIEL: { label: "Valider le matériel", action: "VALIDER_MATERIEL", acteur: "demandeur" },
VALIDEE_MATERIEL: { label: "Transmettre à Finance", action: "TRANSMETTRE_FINANCE", acteur: "demandeur" },
EN_VALIDATION_FINANCE: { label: "Approuver la commande", action: "APPROUVER_FINANCE", acteur: "finance" },
APPROUVEE: { label: "Émettre la commande", action: "EMETTRE_COMMANDE", acteur: "demandeur" },
COMMANDE_EMISE: { label: "Confirmer la commande", action: "CONFIRMER_COMMANDE", acteur: "demandeur" },
COMMANDE_CONFIRMEE: { label: "Mettre en attente de livraison", action: "MARQUER_EN_LIVRAISON", acteur: "demandeur" },
EN_ATTENTE_LIVRAISON: { label: "Déclarer la commande reçue", action: "DEMARRER_CONTROLE_RECEPTION", acteur: "demandeur" },
EN_ATTENTE_VERIFICATION_RECEPTION: { label: "Confirmer la réception conforme", action: "CONFIRMER_RECEPTION_CONFORME", acteur: "demandeur" },
EN_CONTROLE_RECEPTION: { label: "Confirmer la réception conforme", action: "CONFIRMER_RECEPTION_CONFORME", acteur: "demandeur" },
VERIFIEE_CONFORME: { label: "Valider la réception", action: "VALIDER_RECEPTION", acteur: "demandeur" },
RECEPTION_CONFORME: { label: "Valider la réception", action: "VALIDER_RECEPTION", acteur: "demandeur" },
RECEPTION_VALIDEE: { label: "Mettre la facture en rapprochement", action: "ENVOYER_RAPPROCHEMENT", acteur: "demandeur" },
FACTURE_EN_RAPPROCHEMENT: { label: "Clôturer la demande", action: "CLOTURER", acteur: "demandeur" },
};
const PAGE_SIZE = 10;
function getPageList(current: number, total: number): (number | "…")[] {
const delta = 1;
const range: (number | "…")[] = [];
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) range.push(i);
if (current - delta > 2) range.unshift("…");
if (current + delta < total - 1) range.push("…");
range.unshift(1);
if (total > 1) range.push(total);
return range;
}
function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
if (totalPages <= 1) return null;
const pages = getPageList(page, totalPages);
return (
<div className="de-pagination">
<button className="de-page-btn" disabled={page === 1} onClick={() => onChange(Math.max(1, page - 1))}>
‹
</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="de-page-ellipsis">
…
</span>
) : (
<button key={p} className={`de-page-btn${p === page ? " active" : ""}`} onClick={() => onChange(p)}>
{p}
</button>
)
)}
<button className="de-page-btn" disabled={page === totalPages} onClick={() => onChange(Math.min(totalPages, page + 1))}>
›
</button>
</div>
);
}
function DetailDrawer({
demande, fournisseurNom, nomService, nomTypeDepense, nomModePaiement, onClose, onStatuer, onWorkflow, onModifier, onAnnuler,
}: {
demande: DemandeAchat;
fournisseurNom: (d: DemandeAchat) => string;
nomService: (id: number) => string;
nomTypeDepense: (id: number) => string;
nomModePaiement: (id: number) => string;
onClose: () => void;
onStatuer: (id: number, statut: "VALIDEE" | "REFUSEE") => Promise<void>;
onWorkflow: (id: number, action: string, commentaire?: string, document?: DocumentWorkflow) => Promise<void>;
onModifier: (id: number) => void;
onAnnuler: (id: number) => Promise<void>;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [confirmingAnnuler, setConfirmingAnnuler] = useState(false);
const [workflowAConfirmer, setWorkflowAConfirmer] = useState<WorkflowAction | null>(null);
const [commentaireWorkflow, setCommentaireWorkflow] = useState("");
const [documentMateriel, setDocumentMateriel] = useState<File | null>(null);
const [erreurDocument, setErreurDocument] = useState<string | null>(null);
const [historiqueWorkflow, setHistoriqueWorkflow] = useState<DemandeWorkflowHistorique[]>([]);
const historique = useMemo(() => construireHistorique(demande), [demande]);
// Dès qu'il existe un journal serveur, il contient les transitions détaillées :
// on conserve seulement création/soumission calculées localement pour éviter de
// répéter le statut courant avant les étapes qui l'ont conduit.
const historiqueAffiche = historiqueWorkflow.length > 0 ? historique.slice(0, 2) : historique;
useEffect(() => {
let annule = false;
achatsApi.getHistoriqueDemande(demande.id).then((items) => {
if (!annule) setHistoriqueWorkflow(items);
}).catch(() => { if (!annule) setHistoriqueWorkflow([]); });
return () => { annule = true; };
}, [demande.id, demande.statut]);
const statuer = async (statut: "VALIDEE" | "REFUSEE") => {
setBusy(statut);
setActionError(null);
try {
await onStatuer(demande.id, statut);
} catch (err) {
setActionError(err instanceof Error ? err.message : "Échec de l'action.");
} finally {
setBusy(null);
}
};
const confirmerAnnulation = async () => {
setConfirmingAnnuler(false);
setBusy("ANNULEE");
setActionError(null);
try {
await onAnnuler(demande.id);
} catch (err) {
setActionError(err instanceof Error ? err.message : "Échec de l'annulation.");
} finally {
setBusy(null);
}
};
const avancerWorkflow = async (workflow: WorkflowAction, commentaire?: string, document?: File | null) => {
setBusy(workflow.action);
setActionError(null);
try {
const documentJoint = document ? await sharepointApi.upload('Achats', document) : undefined;
await onWorkflow(demande.id, workflow.action, commentaire, documentJoint && {
nom: documentJoint.nom, sharepointUrl: documentJoint.webUrl, sharepointItemId: documentJoint.itemId, tailleOctets: documentJoint.size,
});
} catch (err) {
setActionError(err instanceof Error ? err.message : "Échec du passage à l'étape suivante.");
} finally {
setBusy(null);
}
};
const preparerAvancement = (workflow: WorkflowAction) => {
if (workflow.acteur === "finance" || workflow.action === "VALIDER_MATERIEL") {
setCommentaireWorkflow("");
setDocumentMateriel(null);
setErreurDocument(null);
setWorkflowAConfirmer(workflow);
return;
}
void avancerWorkflow(workflow);
};
// Seul l'auteur (demande.demandeurId === l'utilisateur connecté) peut modifier/annuler
// sa propre demande — le nom affiché est toujours le vrai prénom/nom, plus de "Moi".
const { userId, roles, profil } = useAuth();
const estMonPropreDemande = demande.demandeurId === userId;
const modifiable = estMonPropreDemande && PEUT_STATUER.has(demande.statut);
const workflow = NEXT_WORKFLOW_ACTION[demande.statut];
const peutAvancerWorkflow = workflow != null && (
workflow.acteur === "demandeur"
? estMonPropreDemande
: roles.includes("FINANCE") || profil === "FINANCE"
);
return (
<div className="de-drawer-overlay" onClick={onClose}>
<div className="de-drawer" onClick={(e) => e.stopPropagation()}>
<div className="de-drawer-header">
<div>
<p className="de-eyebrow" style={{ margin: "0 0 4px" }}>{demande.reference}</p>
<h2 className="de-drawer-title">{demande.objet}</h2>
</div>
<div className="de-drawer-header-actions">
<button className="de-drawer-icon-btn" onClick={() => window.print()} aria-label="Imprimer">🖶</button>
<button className="de-drawer-icon-btn" onClick={onClose} aria-label="Fermer">×</button>
</div>
</div>
<span className="de-status-pill" data-statut={demande.statut}>{STATUT_LABEL[demande.statut]}</span>
<p className="de-drawer-amount">{fmtEur(demande.montantTtc)}</p>
<div className="de-drawer-grid">
<div>
<p className="de-detail-label">Service</p>
<p className="de-detail-value">{nomService(demande.serviceId)}</p>
</div>
<div>
<p className="de-detail-label">Type de dépense</p>
<p className="de-detail-value">{nomTypeDepense(demande.typeDepenseId)}</p>
</div>
<div>
<p className="de-detail-label">Demandeur</p>
<p className="de-detail-value">{demande.demandeurNom}</p>
</div>
<div>
<p className="de-detail-label">Fournisseur</p>
<p className="de-detail-value">{fournisseurNom(demande)}</p>
</div>
{demande.categorieFournisseurLibre && (
<div>
<p className="de-detail-label">Catégorie fournisseur</p>
<p className="de-detail-value">{demande.categorieFournisseurLibre}</p>
</div>
)}
<div>
<p className="de-detail-label">Mode de paiement</p>
<p className="de-detail-value">{nomModePaiement(demande.modePaiementId)}</p>
</div>
<div>
<p className="de-detail-label">Date de besoin</p>
<p className="de-detail-value">{formatDateFr(demande.datePrevisionnelle)}</p>
</div>
</div>
<p className="de-detail-label" style={{ marginTop: 18 }}>Justificatifs</p>
{demande.justificatifs.length === 0 ? (
<p className="de-drawer-empty-inline">Aucun justificatif joint.</p>
) : (
<div className="de-justif-list">
{demande.justificatifs.map((j) => (
<div className="de-justif-item" key={j.id}>
<span className="de-justif-clip">📎</span>
{j.sharepointUrl ? (
<a href={j.sharepointUrl} target="_blank" rel="noreferrer">{j.libelle} — {j.nom}</a>
) : (
<span>{j.libelle} — {j.nom}</span>
)}
</div>
))}
</div>
)}
{demande.description && (
<>
<p className="de-detail-label" style={{ marginTop: 16 }}>Justification</p>
<p className="de-drawer-desc">{demande.description}</p>
</>
)}
{peutAvancerWorkflow && workflow && (
<div className="de-drawer-actions" style={{ marginTop: 18 }}>
<button className="de-btn-success" disabled={busy !== null} onClick={() => preparerAvancement(workflow)}>
{busy === workflow.action ? "Mise à jour…" : workflow.label}
</button>
</div>
)}
<p className="de-detail-label" style={{ marginTop: 18 }}>Historique</p>
<div className="de-timeline">
{historiqueAffiche.map((etape, i) => (
<div className="de-timeline-item" key={i}>
<span className={`de-timeline-dot${etape.fait ? " done" : " current"}`} />
<div className="de-timeline-content">
<p className="de-timeline-title">{etape.titre}</p>
<p className="de-timeline-meta">
{etape.auteur}{etape.date ? ` · ${formatDateFr(etape.date)}` : ""}
</p>
</div>
</div>
))}
{historiqueWorkflow.map((etape) => (
<div className="de-timeline-item" key={`${etape.action}-${etape.dateAction}`}>
<span className="de-timeline-dot done" />
<div className="de-timeline-content">
<p className="de-timeline-title">{etape.nouveauStatut ? (STATUT_LABEL[etape.nouveauStatut as StatutDemande] ?? etape.nouveauStatut.replaceAll("_", " ")) : etape.action.replaceAll("_", " ")}</p>
<p className="de-timeline-meta">{etape.auteur}{etape.dateAction ? ` · ${formatDateFr(etape.dateAction)}` : ""}{etape.commentaire ? ` — ${etape.commentaire}` : ""}</p>
</div>
</div>
))}
</div>
{actionError && <div className="de-alert-error" style={{ marginTop: 16 }}>{actionError}</div>}
{demande.peutValider && PEUT_STATUER.has(demande.statut) && (
<div className="de-drawer-actions">
<button className="de-btn-danger-solid" disabled={busy !== null} onClick={() => statuer("REFUSEE")}>
{busy === "REFUSEE" ? "Refus…" : "Refuser"}
</button>
<button className="de-btn-success" disabled={busy !== null} onClick={() => statuer("VALIDEE")}>
{busy === "VALIDEE" ? "Validation…" : "Valider"}
</button>
</div>
)}
{modifiable && (
<div className="de-drawer-actions">
<button className="de-btn-danger-solid" disabled={busy !== null} onClick={() => setConfirmingAnnuler(true)}>
{busy === "ANNULEE" ? "Annulation…" : "Annuler la demande"}
</button>
<button className="de-btn-ghost-solid" disabled={busy !== null} onClick={() => onModifier(demande.id)}>
Modifier
</button>
</div>
)}
</div>
{confirmingAnnuler && (
<div className="de-confirm-overlay" onClick={() => setConfirmingAnnuler(false)}>
<div className="de-confirm-box" onClick={(e) => e.stopPropagation()}>
<p className="de-confirm-title">Annuler cette demande ?</p>
<p className="de-confirm-body">
La demande <strong>{demande.reference}</strong> sera annulée. Cette action est irréversible.
</p>
<div className="de-confirm-actions">
<button className="de-btn-ghost-solid" onClick={() => setConfirmingAnnuler(false)}>Retour</button>
<button className="de-btn-danger-solid" onClick={confirmerAnnulation}>Confirmer l'annulation</button>
</div>
</div>
</div>
)}
{workflowAConfirmer && (
<div className="de-confirm-overlay" onClick={() => setWorkflowAConfirmer(null)}>
<div className="de-confirm-box" onClick={(e) => e.stopPropagation()}>
<p className="de-confirm-title">{workflowAConfirmer.label}</p>
<p className="de-confirm-body">{workflowAConfirmer.action === "VALIDER_MATERIEL" ? "Joignez le bon de commande, devis ou document matériel avant de valider cette étape." : "Ajoutez un commentaire Finance avant de passer à l'étape suivante."}</p>
{workflowAConfirmer.action === "VALIDER_MATERIEL" ? <>
<label className="de-comment-label" htmlFor="document-materiel">Bon de commande / document matériel *</label>
<input id="document-materiel" className="de-file-input" type="file" accept="application/pdf,image/*" onChange={(e) => { setDocumentMateriel(e.target.files?.[0] ?? null); setErreurDocument(null); }} />
{documentMateriel && <p className="de-file-name">{documentMateriel.name}</p>}
{erreurDocument && <p className="de-file-error">{erreurDocument}</p>}
</> : <>
<label className="de-comment-label" htmlFor="workflow-comment">Commentaire Finance</label>
<textarea id="workflow-comment" className="de-comment-input" value={commentaireWorkflow}
onChange={(e) => setCommentaireWorkflow(e.target.value)} placeholder="Ex. budget contrôlé et commande autorisée." rows={4} />
</>}
<div className="de-confirm-actions">
<button className="de-btn-ghost-solid" onClick={() => setWorkflowAConfirmer(null)}>Annuler</button>
<button className="de-btn-success" disabled={busy !== null} onClick={() => {
const action = workflowAConfirmer;
if (action.action === "VALIDER_MATERIEL" && !documentMateriel) { setErreurDocument("Le bon de commande ou document matériel est requis."); return; }
setWorkflowAConfirmer(null);
void avancerWorkflow(action, commentaireWorkflow, documentMateriel);
}}>Confirmer l'étape</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function DemandesAchat() {
const navigate = useNavigate();
const location = useLocation();
const { role } = useWorkspace();
const { roles, userId } = useAuth();
const hasFinanceRole = roles.includes('FINANCE');
const estFinance = hasFinanceRole;
const [demandes, setDemandes] = useState<DemandeAchat[]>([]);
const [fournisseurs, setFournisseurs] = useState<Fournisseur[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [typesDepense, setTypesDepense] = useState<TypeDepense[]>([]);
const [modesPaiement, setModesPaiement] = useState<ModePaiement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"TOUTES" | StatutDemande>("TOUTES");
const [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [factureDemande, setFactureDemande] = useState<DemandeAchat | null>(null);
const [factureNumero, setFactureNumero] = useState("");
const [factureMontant, setFactureMontant] = useState("");
const [factureDate, setFactureDate] = useState(new Date().toISOString().slice(0, 10));
const [factureEcheance, setFactureEcheance] = useState("");
const [factureDocument, setFactureDocument] = useState<File | null>(null);
const [factureErreur, setFactureErreur] = useState<string | null>(null);
const [factureBusy, setFactureBusy] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
// scope=mine seulement pour un utilisateur Finance basculé en espace Collaborateur
// (vue étroite volontaire) — sinon on laisse le backend inclure les demandes des
// collaborateurs dont l'utilisateur est responsable.
const scope = role === 'collaborateur' && hasFinanceRole ? 'mine' : undefined;
Promise.all([
achatsApi.getDemandes(scope), achatsApi.getFournisseurs(),
achatsApi.getServices(), achatsApi.getTypesDepense(), achatsApi.getModesPaiement(),
])
.then(([d, f, s, t, m]) => {
if (!cancelled) { setDemandes(d); setFournisseurs(f); setServices(s); setTypesDepense(t); setModesPaiement(m); }
})
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : "Échec du chargement des demandes."); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [role, hasFinanceRole]);
// Ouverture directe du panneau détail depuis un lien externe (ex. Tableau de bord),
// via l'état de navigation plutôt qu'un paramètre d'URL — évite d'introduire une route dédiée.
useEffect(() => {
const openId = (location.state as { openDemandeId?: number } | null)?.openDemandeId;
if (openId != null && !loading) {
setSelectedId(openId);
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, loading]);
// Résolution du nom de fournisseur à partir des fournisseurs réels chargés depuis l'API
// (le helper nomFournisseur() de data.ts s'appuie encore sur une liste mockée non fiable ici).
const fournisseurNom = useMemo(() => {
const map = new Map(fournisseurs.map((f) => [f.id, f.raisonSociale]));
return (d: DemandeAchat) => d.fournisseurLibre ?? (d.fournisseurId != null ? map.get(d.fournisseurId) : undefined) ?? "—";
}, [fournisseurs]);
// Mêmes résolutions que fournisseurNom ci-dessus : les helpers nomService/nomTypeDepense/
// nomModePaiement de data.ts s'appuient sur des listes mockées non fiables ici.
const nomService = useMemo(() => {
const map = new Map(services.map((s) => [s.id, s.libelle]));
return (id: number) => map.get(id) ?? "—";
}, [services]);
const nomTypeDepense = useMemo(() => {
const map = new Map(typesDepense.map((t) => [t.id, t.libelle]));
return (id: number) => map.get(id) ?? "—";
}, [typesDepense]);
const nomModePaiement = useMemo(() => {
const map = new Map(modesPaiement.map((m) => [m.id, m.libelle]));
return (id: number) => map.get(id) ?? "—";
}, [modesPaiement]);
const counts = useMemo(() => {
const base: Record<string, number> = { TOUTES: demandes.length };
TABS.slice(1).forEach((t) => (base[t.key] = demandes.filter((d) => d.statut === t.key).length));
return base;
}, [demandes]);
const filtered = useMemo(() => (tab === "TOUTES" ? demandes : demandes.filter((d) => d.statut === tab)), [demandes, tab]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const changeTab = (key: "TOUTES" | StatutDemande) => {
setTab(key);
setPage(1);
};
const selected = demandes.find((d) => d.id === selectedId) ?? null;
const statuerDemande = async (id: number, statut: "VALIDEE" | "REFUSEE") => {
const updated = await achatsApi.changerStatutDemande(id, statut);
setDemandes((prev) => prev.map((d) => (d.id === id ? updated : d)));
setSelectedId(null);
};
const avancerWorkflowDemande = async (id: number, action: string, commentaire?: string, document?: DocumentWorkflow) => {
const updated = await achatsApi.avancerWorkflowDemande(id, action, commentaire, document);
setDemandes((prev) => prev.map((d) => (d.id === id ? updated : d)));
};
const modifierDemande = (id: number) => navigate(`/demandes/${id}/modifier`);
const annulerDemande = async (id: number) => {
await achatsApi.annulerDemande(id);
setDemandes((prev) => prev.map((d) => (d.id === id ? { ...d, statut: "ANNULEE" } : d)));
setSelectedId(null);
};
const ouvrirFacture = (demande: DemandeAchat) => {
setFactureDemande(demande);
setFactureNumero(""); setFactureMontant(String(demande.montantTtc));
setFactureDate(new Date().toISOString().slice(0, 10)); setFactureEcheance(""); setFactureDocument(null); setFactureErreur(null);
};
const enregistrerFacture = async () => {
if (!factureDemande?.fournisseurId || !factureNumero.trim() || !factureMontant || !factureDate || !factureEcheance || !factureDocument) {
setFactureErreur("Renseignez les informations et joignez le document de facture."); return;
}
const montant = Number(factureMontant.replace(',', '.'));
if (!Number.isFinite(montant) || montant <= 0) { setFactureErreur("Le montant TTC doit être supérieur à zéro."); return; }
setFactureBusy(true); setFactureErreur(null);
try {
const document = await sharepointApi.upload('Achats', factureDocument);
await achatsApi.ajouterFactureDemande(factureDemande.id, { numero: factureNumero.trim(), montant, dateFacture: factureDate, dateEcheance: factureEcheance,
document: { nom: document.nom, sharepointUrl: document.webUrl, sharepointItemId: document.itemId, tailleOctets: document.size } });
setDemandes((prev) => prev.map((d) => d.id === factureDemande.id
? { ...d, factureAjoutee: true, factureUrl: document.webUrl, factureNom: document.nom } : d));
setFactureDemande(null);
} catch (err) { setFactureErreur(err instanceof Error ? err.message : "Impossible d'ajouter la facture."); }
finally { setFactureBusy(false); }
};
return (
<div className="de-root">
<style>{css}</style>
<div className="de-wrap">
<header className="de-header">
<div>
<p className="de-eyebrow">Achats · demandes</p>
<h1 className="de-h1">Demandes d'achat</h1>
</div>
<button className="de-btn-primary" onClick={() => navigate("/demandes/nouvelle")}>
+ Nouvelle demande
</button>
</header>
{error && <div className="de-alert-error">{error}</div>}
<div className="de-filter-tabs">
{TABS.map((t) => (
<button key={t.key} className={tab === t.key ? "active" : ""} onClick={() => changeTab(t.key)}>
{t.label} <span className="de-tab-count">{counts[t.key] ?? 0}</span>
</button>
))}
</div>
<div className="de-table-wrap">
<table className="de-table">
<thead>
<tr>
<th>Réf</th>
<th>Objet</th>
<th>Demandeur</th>
<th>Fournisseur</th>
<th style={{ textAlign: "right" }}>Montant</th>
<th style={{ textAlign: "right" }}>Statut</th>
{(estFinance || userId != null) && <th style={{ textAlign: "right" }}>Action</th>}
</tr>
</thead>
<tbody>
{pageItems.map((d) => (
<tr key={d.id} className="de-row" onClick={() => setSelectedId(d.id)}>
<td className="de-mono-cell">{d.reference}</td>
<td className="de-objet-cell">{d.objet}</td>
<td>{d.demandeurNom}</td>
<td>{fournisseurNom(d)}</td>
<td className="de-amount">{fmtEur(d.montantTtc)}</td>
<td style={{ textAlign: "right" }}>
<span className="de-status-pill" data-statut={d.statut}>
{STATUT_LABEL[d.statut]}
</span>
</td>
{(estFinance || userId != null) && (
<td style={{ textAlign: "right" }}>
{estFinance && NEXT_WORKFLOW_ACTION[d.statut]?.acteur === "finance" ? (
<button className="de-btn-table-action" onClick={(e) => { e.stopPropagation(); setSelectedId(d.id); }}>Traiter</button>
) : d.factureAjoutee && d.factureUrl ? (
<a className="de-btn-table-action de-btn-table-link" href={d.factureUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>Voir la facture</a>
) : d.demandeurId === userId && STATUTS_FACTURE_AJOUTABLE.includes(d.statut) ? (
<button className="de-btn-table-action" onClick={(e) => { e.stopPropagation(); ouvrirFacture(d); }}>Ajouter une facture</button>
) : "—"}
</td>
)}
</tr>
))}
{!loading && pageItems.length === 0 && (
<tr>
<td colSpan={(estFinance || userId != null) ? 7 : 6} className="de-empty">
Aucune demande dans cette catégorie.
</td>
</tr>
)}
{loading && (
<tr>
<td colSpan={(estFinance || userId != null) ? 7 : 6} className="de-empty">
Chargement…
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
{selected && (
<DetailDrawer
demande={selected}
fournisseurNom={fournisseurNom}
nomService={nomService}
nomTypeDepense={nomTypeDepense}
nomModePaiement={nomModePaiement}
onClose={() => setSelectedId(null)}
onStatuer={statuerDemande}
onWorkflow={avancerWorkflowDemande}
onModifier={modifierDemande}
onAnnuler={annulerDemande}
/>
)}
{factureDemande && (
<div className="de-confirm-overlay" onClick={() => !factureBusy && setFactureDemande(null)}>
<div className="de-confirm-box" onClick={(e) => e.stopPropagation()}>
<p className="de-confirm-title">Ajouter une facture</p>
<p className="de-confirm-body">La facture sera liée à la demande {factureDemande.reference} et apparaîtra dans « Factures & Avoirs ».</p>
<label className="de-comment-label" htmlFor="facture-numero">Numéro de facture *</label>
<input id="facture-numero" className="de-comment-input de-input-one-line" value={factureNumero} onChange={(e) => setFactureNumero(e.target.value)} />
<label className="de-comment-label" htmlFor="facture-montant">Montant TTC *</label>
<input id="facture-montant" className="de-comment-input de-input-one-line" type="number" min="0.01" step="0.01" value={factureMontant} onChange={(e) => setFactureMontant(e.target.value)} />
<label className="de-comment-label" htmlFor="facture-date">Date de facture *</label>
<input id="facture-date" className="de-comment-input de-input-one-line" type="date" value={factureDate} onChange={(e) => setFactureDate(e.target.value)} />
<label className="de-comment-label" htmlFor="facture-echeance">Date d'échéance *</label>
<input id="facture-echeance" className="de-comment-input de-input-one-line" type="date" value={factureEcheance} onChange={(e) => setFactureEcheance(e.target.value)} />
<label className="de-comment-label" htmlFor="facture-document">Document de facture (PDF ou image) *</label>
<input id="facture-document" className="de-file-input" type="file" accept="application/pdf,image/*" onChange={(e) => { setFactureDocument(e.target.files?.[0] ?? null); setFactureErreur(null); }} />
{factureDocument && <p className="de-file-name">{factureDocument.name}</p>}
{factureErreur && <p className="de-file-error">{factureErreur}</p>}
<div className="de-confirm-actions">
<button className="de-btn-ghost-solid" disabled={factureBusy} onClick={() => setFactureDemande(null)}>Annuler</button>
<button className="de-btn-success" disabled={factureBusy} onClick={() => void enregistrerFacture()}>{factureBusy ? "Ajout…" : "Ajouter la facture"}</button>
</div>
</div>
</div>
)}
</div>
);
}
const css = `
.de-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--violet:#5B4B8A; --violet-light:#EDE9F6;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.de-root *{ box-sizing:border-box; }
.de-wrap{ max-width:100%; padding:28px 32px 64px; }
.de-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.de-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.de-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.de-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.de-btn-primary:hover{ background:#25405C; }
.de-alert-error{ margin-bottom:16px; background:var(--danger-light); color:var(--danger); border-radius:var(--radius); padding:10px 14px; font-size:13px; }
.de-filter-tabs{ display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.de-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.de-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.de-tab-count{ font-size:11px; opacity:0.75; }
.de-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.de-table{ width:100%; border-collapse:collapse; font-size:13px; }
.de-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.de-table td{ padding:12px 16px; border-bottom:1px solid var(--border); }
.de-table tbody tr:last-child td{ border-bottom:none; }
.de-row{ cursor:pointer; }
.de-row:hover{ background:var(--surface-2); }
.de-objet-cell{ font-weight:500; }
.de-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.de-amount{ font-family:var(--mono); text-align:right; }
.de-btn-table-action{ font:600 12px var(--sans); padding:6px 10px; border:1px solid var(--success); border-radius:6px; background:var(--success); color:#fff; cursor:pointer; }
.de-btn-table-action:hover{ opacity:.9; }
.de-btn-table-link{ display:inline-block; text-decoration:none; }
.de-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.de-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; background:var(--surface-2); color:var(--text-2); }
.de-status-pill[data-statut="SOUMISE"]{ background:var(--accent-light); color:var(--accent); }
.de-status-pill[data-statut="EN_VALIDATION"]{ background:var(--warning-light); color:var(--warning); }
.de-status-pill[data-statut="BROUILLON"]{ background:var(--surface-2); color:var(--text-2); }
.de-status-pill[data-statut="VALIDEE"]{ background:var(--success-light); color:var(--success); }
.de-status-pill[data-statut="REFUSEE"]{ background:var(--danger-light); color:var(--danger); }
.de-status-pill[data-statut="COMPLEMENT_REQUIS"]{ background:var(--violet-light); color:var(--violet); }
.de-status-pill[data-statut="FACTUREE"]{ background:var(--accent-light); color:var(--accent); }
.de-status-pill[data-statut="ANNULEE"]{ background:var(--surface-2); color:var(--text-muted); }
.de-status-pill[data-statut="ARCHIVEE"]{ background:var(--surface-2); color:var(--text-muted); }
.de-status-pill[data-statut="VALIDEE_MANAGER"],.de-status-pill[data-statut="VALIDEE_MATERIEL"],.de-status-pill[data-statut="APPROUVEE"],.de-status-pill[data-statut="RECEPTION_CONFORME"],.de-status-pill[data-statut="VERIFIEE_CONFORME"],.de-status-pill[data-statut="RECEPTION_VALIDEE"],.de-status-pill[data-statut="CLOTURE"],.de-status-pill[data-statut="CLOTUREE"]{ background:var(--success-light); color:var(--success); }
.de-status-pill[data-statut="EN_VALIDATION_MATERIEL"],.de-status-pill[data-statut="EN_VALIDATION_FINANCE"],.de-status-pill[data-statut="EN_CONTROLE_RECEPTION"],.de-status-pill[data-statut="EN_ATTENTE_VERIFICATION_RECEPTION"]{ background:var(--warning-light); color:var(--warning); }
.de-status-pill[data-statut="COMMANDE_EMISE"],.de-status-pill[data-statut="COMMANDE_CONFIRMEE"],.de-status-pill[data-statut="EN_ATTENTE_LIVRAISON"],.de-status-pill[data-statut="FACTURE_EN_RAPPROCHEMENT"]{ background:var(--accent-light); color:var(--accent); }
.de-status-pill[data-statut="VERIFIEE_NON_CONFORME"]{ background:var(--danger-light); color:var(--danger); }
.de-pagination{ display:flex; align-items:center; justify-content:center; gap:6px; padding:20px 0 4px; }
.de-page-btn{ min-width:32px; height:32px; padding:0 8px; border-radius:7px; font-family:var(--sans); font-size:13px; font-weight:500; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.de-page-btn:hover:not(:disabled){ border-color:var(--accent); color:var(--accent); }
.de-page-btn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.de-page-btn:disabled{ opacity:0.4; cursor:default; }
.de-page-ellipsis{ width:24px; text-align:center; color:var(--text-muted); font-size:13px; }
@media (max-width:900px){
.de-table th:nth-child(3), .de-table td:nth-child(3){ display:none; }
}
.de-drawer-overlay{ position:fixed; inset:0; background:rgba(28,30,27,0.45); z-index:50; animation:de-fade-in .18s ease; }
.de-drawer{
position:fixed; top:0; right:0; bottom:0; width:100%; max-width:460px;
background:var(--surface); padding:28px 32px; overflow-y:auto;
box-shadow:-8px 0 32px rgba(20,20,15,0.18);
animation:de-slide-in .22s ease;
}
@keyframes de-fade-in{ from{ opacity:0; } to{ opacity:1; } }
@keyframes de-slide-in{ from{ transform:translateX(100%); } to{ transform:translateX(0); } }
.de-confirm-overlay{ position:fixed; inset:0; background:rgba(28,30,27,0.55); z-index:60; display:flex; align-items:center; justify-content:center; padding:16px; animation:de-fade-in .15s ease; }
.de-confirm-box{ background:var(--surface); border-radius:var(--radius); padding:24px; width:100%; max-width:380px; box-shadow:0 12px 40px rgba(20,20,15,0.25); }
.de-confirm-title{ font-size:16px; font-weight:700; margin:0 0 8px; }
.de-confirm-body{ font-size:13.5px; color:var(--text-2); margin:0 0 20px; line-height:1.5; }
.de-confirm-actions{ display:flex; gap:10px; }
.de-confirm-actions button{ flex:1; }
.de-comment-label{ display:block; font-size:12px; font-weight:600; color:var(--text-2); margin:0 0 6px; }
.de-comment-input{ display:block; width:100%; resize:vertical; min-height:88px; padding:10px; border:1px solid var(--border); border-radius:var(--radius); font:13px var(--sans); color:var(--text); margin-bottom:18px; outline:none; }
.de-comment-input:focus{ border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-light); }
.de-input-one-line{ min-height:0; height:38px; resize:none; margin-bottom:12px; }
.de-file-input{ display:block; width:100%; font:13px var(--sans); margin:0 0 8px; }
.de-file-name{ margin:0 0 14px; font-size:12px; color:var(--success); }
.de-file-error{ margin:0 0 14px; font-size:12px; color:var(--danger); }
.de-drawer-header{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:12px; }
.de-drawer-header-actions{ display:flex; gap:4px; flex-shrink:0; }
.de-drawer-title{ font-size:18px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.de-drawer-icon-btn{ font-family:var(--sans); font-size:15px; line-height:1; color:var(--text-muted); background:var(--surface-2); border:1px solid var(--border); border-radius:7px; cursor:pointer; width:30px; height:30px; }
.de-drawer-icon-btn:hover{ color:var(--text); border-color:var(--accent); }
.de-drawer-amount{ font-size:28px; font-weight:700; margin:14px 0 20px; letter-spacing:-0.01em; }
.de-drawer-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; padding-top:16px; border-top:1px solid var(--border); }
.de-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 4px; }
.de-detail-value{ font-size:13.5px; font-weight:600; color:var(--text); margin:0; }
.de-drawer-desc{ font-size:13.5px; color:var(--text-2); margin:0; background:var(--surface-2); border-radius:8px; padding:12px 14px; }
.de-drawer-empty-inline{ font-size:13px; color:var(--text-muted); margin:0; }
.de-justif-list{ display:flex; flex-direction:column; gap:6px; }
.de-justif-item{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--text); background:var(--surface-2); border-radius:8px; padding:8px 12px; }
.de-justif-clip{ flex-shrink:0; }
.de-timeline{ padding-left:2px; }
.de-timeline-item{ display:flex; gap:12px; position:relative; padding-bottom:18px; }
.de-timeline-item:last-child{ padding-bottom:0; }
.de-timeline-item:not(:last-child)::before{ content:''; position:absolute; left:4px; top:14px; bottom:0; width:1px; background:var(--border); }
.de-timeline-dot{ flex-shrink:0; width:9px; height:9px; border-radius:50%; margin-top:4px; background:var(--border); }
.de-timeline-dot.done{ background:var(--text-2); }
.de-timeline-dot.current{ background:var(--surface); border:2px solid var(--success); width:9px; height:9px; box-sizing:content-box; margin-top:2px; margin-left:-2px; }
.de-timeline-title{ font-size:13.5px; font-weight:600; color:var(--text); margin:0; }
.de-timeline-meta{ font-size:12px; color:var(--text-muted); margin:2px 0 0; }
.de-drawer-actions{ display:flex; gap:10px; margin-top:24px; padding-top:20px; border-top:1px solid var(--border); }
.de-btn-success{ flex:1; font-family:var(--sans); font-size:13.5px; font-weight:600; padding:10px 16px; border-radius:var(--radius); border:1px solid var(--success); background:var(--success); color:#fff; cursor:pointer; }
.de-btn-success:hover:not(:disabled){ opacity:0.9; }
.de-btn-danger-solid{ flex:1; font-family:var(--sans); font-size:13.5px; font-weight:600; padding:10px 16px; border-radius:var(--radius); border:1px solid var(--danger); background:var(--surface); color:var(--danger); cursor:pointer; }
.de-btn-danger-solid:hover:not(:disabled){ background:var(--danger-light); }
.de-btn-success:disabled, .de-btn-danger-solid:disabled{ opacity:0.5; cursor:default; }
.de-btn-ghost-solid{ flex:1; font-family:var(--sans); font-size:13.5px; font-weight:600; padding:10px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--surface); color:var(--accent); cursor:pointer; }
.de-btn-ghost-solid:hover:not(:disabled){ background:var(--accent-light); }
.de-btn-ghost-solid:disabled{ opacity:0.5; cursor:default; }
@media (max-width:520px){
.de-drawer{ max-width:100%; }
}
`;
+502
View File
@@ -0,0 +1,502 @@
import { useEffect, useMemo, useState } from "react";
import { fournisseurApi, financeApi, type FournisseurDetail, type FournisseurSave, type Contrat } from "../data";
/*
Composant autonome — aucune dépendance externe hormis React.
Mêmes tokens de couleur / structure que ContratsFournisseurs.tsx,
FacturesAvoirs.tsx, Paiements.tsx, Demandes.tsx et DemandeForm.tsx.
Données réelles via fournisseurApi (SIFinance.Modules.Achats) + financeApi
(pour les contrats liés, affichés dans la vue détail).
*/
const euro = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const CONTRAT_STATUT_LABEL: Record<Contrat["statut"], string> = {
ACTIF: "Actif",
TERMINE: "Terminé",
A_RENOUVELER: "À renouveler",
};
const emptyForm: FournisseurSave = { raisonSociale: "", siret: "", email: "", telephone: "", adresse: "", statut: "ACTIF" };
const PAGE_SIZE = 8;
function getPageList(current: number, total: number): (number | "…")[] {
const delta = 1;
const range: (number | "…")[] = [];
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) range.push(i);
if (current - delta > 2) range.unshift("…");
if (current + delta < total - 1) range.push("…");
range.unshift(1);
if (total > 1) range.push(total);
return range;
}
function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
if (totalPages <= 1) return null;
const pages = getPageList(page, totalPages);
return (
<div className="fo-pagination">
<button className="fo-page-btn" disabled={page === 1} onClick={() => onChange(Math.max(1, page - 1))}>‹</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="fo-page-ellipsis">…</span>
) : (
<button key={p} className={`fo-page-btn${p === page ? " active" : ""}`} onClick={() => onChange(p)}>{p}</button>
)
)}
<button className="fo-page-btn" disabled={page === totalPages} onClick={() => onChange(Math.min(totalPages, page + 1))}>›</button>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="fo-field">
<label>{label}</label>
{children}
</div>
);
}
type View = "liste" | "form" | "detail";
export default function Fournisseurs() {
const [fournisseurs, setFournisseurs] = useState<FournisseurDetail[]>([]);
const [contrats, setContrats] = useState<Contrat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<View>("liste");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FournisseurSave>(emptyForm);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [statutFiltre, setStatutFiltre] = useState("Tous");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const refresh = async () => {
const [f, c] = await Promise.all([fournisseurApi.getAll(), financeApi.getContrats()]);
setFournisseurs(f);
setContrats(c);
};
useEffect(() => {
let cancelled = false;
setLoading(true);
refresh()
.catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : "Erreur de chargement"); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const selected = fournisseurs.find((f) => f.id === selectedId);
const contratsDe = (fournisseurId: number) => contrats.filter((c) => c.fournisseurId === fournisseurId);
const totalEngageDe = (fournisseurId: number) => contratsDe(fournisseurId).reduce((s, c) => s + c.montantTotal, 0);
const filtered = useMemo(() => {
return fournisseurs.filter((f) => {
const matchStatut = statutFiltre === "Tous" || f.statut === statutFiltre;
const matchQuery = query.trim() === "" || f.raisonSociale.toLowerCase().includes(query.toLowerCase());
return matchStatut && matchQuery;
});
}, [fournisseurs, statutFiltre, query]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const openNewForm = () => {
setForm(emptyForm);
setFormError(null);
setEditingId(null);
setView("form");
};
const openEditForm = (f: FournisseurDetail) => {
setForm({ raisonSociale: f.raisonSociale, siret: f.siret ?? "", email: f.email ?? "", telephone: f.telephone ?? "", adresse: f.adresse ?? "", statut: f.statut });
setFormError(null);
setEditingId(f.id);
setView("form");
};
const openDetail = (id: number) => {
setSelectedId(id);
setView("detail");
};
const saveForm = async () => {
if (!form.raisonSociale.trim()) return;
setSaving(true);
setFormError(null);
try {
if (editingId) {
await fournisseurApi.update(editingId, form);
} else {
await fournisseurApi.create(form);
setPage(1);
}
await refresh();
setView("liste");
} catch (e) {
setFormError(e instanceof Error ? e.message : "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
};
const deleteFournisseur = async (id: number) => {
setDeleteError(null);
try {
await fournisseurApi.remove(id);
await refresh();
setConfirmDeleteId(null);
setPage(1);
if (selectedId === id) setView("liste");
} catch (e) {
setDeleteError(e instanceof Error ? e.message : "Erreur lors de la suppression");
}
};
const update = <K extends keyof FournisseurSave>(key: K, value: FournisseurSave[K]) => setForm((f) => ({ ...f, [key]: value }));
if (loading) {
return (
<div className="fo-root">
<style>{css}</style>
<div className="fo-wrap"><p className="fo-sub">Chargement…</p></div>
</div>
);
}
if (error) {
return (
<div className="fo-root">
<style>{css}</style>
<div className="fo-wrap"><p className="fo-sub" style={{ color: "var(--danger)" }}>Erreur : {error}</p></div>
</div>
);
}
// ---------- VUE FORMULAIRE ----------
if (view === "form") {
return (
<div className="fo-root">
<style>{css}</style>
<div className="fo-wrap">
<button className="fo-back-link" onClick={() => setView("liste")}>‹ Retour</button>
<header className="fo-header" style={{ borderBottom: "none", marginBottom: 20, paddingBottom: 0 }}>
<div>
<p className="fo-eyebrow">Achats · fournisseurs</p>
<h1 className="fo-h1">{editingId ? "Modifier le fournisseur" : "Nouveau fournisseur"}</h1>
</div>
</header>
<div className="fo-panel" style={{ maxWidth: 520 }}>
{formError && <div className="fo-alert-error">{formError}</div>}
<Field label="Raison sociale">
<input value={form.raisonSociale} onChange={(e) => update("raisonSociale", e.target.value)} placeholder="Ex. Dell France" />
</Field>
<div className="fo-form-grid">
<Field label="Email">
<input value={form.email ?? ""} onChange={(e) => update("email", e.target.value)} placeholder="contact@fournisseur.com" />
</Field>
<Field label="Téléphone">
<input value={form.telephone ?? ""} onChange={(e) => update("telephone", e.target.value)} placeholder="01 23 45 67 89" />
</Field>
</div>
<Field label="Adresse">
<input value={form.adresse ?? ""} onChange={(e) => update("adresse", e.target.value)} placeholder="12 rue de l'Exemple, 75000 Paris" />
</Field>
<Field label="SIRET">
<input value={form.siret ?? ""} onChange={(e) => update("siret", e.target.value)} placeholder="000 000 000 00000" />
</Field>
<Field label="Statut">
<div style={{ display: "flex", gap: 8 }}>
{["ACTIF", "INACTIF"].map((s) => (
<button
key={s}
type="button"
className={`fo-toggle-btn${form.statut === s ? " active" : ""}`}
onClick={() => update("statut", s)}
>
{s === "ACTIF" ? "Actif" : "Inactif"}
</button>
))}
</div>
</Field>
<div className="fo-form-actions" style={{ justifyContent: "flex-start" }}>
<button className="fo-btn-primary" disabled={saving} onClick={saveForm}>
{saving ? "Enregistrement…" : editingId ? "Enregistrer" : "Créer le fournisseur"}
</button>
<button className="fo-btn-ghost" onClick={() => setView("liste")}>Annuler</button>
</div>
</div>
</div>
</div>
);
}
// ---------- VUE DÉTAIL ----------
if (view === "detail" && selected) {
const contratsSel = contratsDe(selected.id);
const totalEngage = totalEngageDe(selected.id);
return (
<div className="fo-root">
<style>{css}</style>
<div className="fo-wrap">
<button className="fo-back-link" onClick={() => setView("liste")}>‹ Retour aux fournisseurs</button>
<header className="fo-header">
<div>
<p className="fo-eyebrow">Achats · fournisseurs</p>
<h1 className="fo-h1">{selected.raisonSociale}</h1>
<p className="fo-sub" style={{ marginTop: 6 }}>
{contratsSel.length} contrat{contratsSel.length !== 1 ? "s" : ""} · {euro(totalEngage)} engagé
</p>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button className="fo-btn-ghost" onClick={() => openEditForm(selected)}>Modifier</button>
{confirmDeleteId === selected.id ? (
<div className="fo-confirm-inline">
<span>Supprimer ?</span>
<button onClick={() => deleteFournisseur(selected.id)}>Oui</button>
<button onClick={() => setConfirmDeleteId(null)}>Annuler</button>
</div>
) : (
<button className="fo-btn-danger-outline" onClick={() => { setDeleteError(null); setConfirmDeleteId(selected.id); }}>Supprimer</button>
)}
</div>
</header>
{deleteError && <div className="fo-alert-error" style={{ marginBottom: 16 }}>{deleteError}</div>}
<div className="fo-info-grid">
{[
["Email", selected.email ?? "—"], ["Téléphone", selected.telephone ?? "—"], ["Adresse", selected.adresse ?? "—"],
["SIRET", selected.siret ?? "—"], ["Statut", selected.statut === "ACTIF" ? "Actif" : "Inactif"],
].map(([label, value]) => (
<div className="fo-info-card" key={label}>
<p className="fo-detail-label">{label}</p>
<p className="fo-info-value">{value}</p>
</div>
))}
</div>
<div className="fo-table-wrap">
<div className="fo-table-head">
<span className="fo-h2">Contrats liés</span>
</div>
{contratsSel.length === 0 ? (
<div className="fo-empty">Aucun contrat associé à ce fournisseur.</div>
) : (
<table className="fo-table">
<thead>
<tr>
<th>Réf</th><th>Objet</th><th>Début</th><th>Fin</th>
<th style={{ textAlign: "right" }}>Montant</th><th style={{ textAlign: "right" }}>Statut</th>
</tr>
</thead>
<tbody>
{contratsSel.map((ct) => (
<tr key={ct.id}>
<td className="fo-mono-cell">{ct.reference ?? `CT-${ct.id}`}</td>
<td className="fo-objet-cell">{ct.objet}</td>
<td>{fmtDate(ct.dateDebut)}</td>
<td>{fmtDate(ct.dateFin)}</td>
<td className="fo-amount">{euro(ct.montantTotal)}</td>
<td style={{ textAlign: "right" }}>
<span className="fo-status-pill" data-statut={ct.statut}>{CONTRAT_STATUT_LABEL[ct.statut]}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
// ---------- VUE LISTE ----------
return (
<div className="fo-root">
<style>{css}</style>
<div className="fo-wrap">
<header className="fo-header">
<div>
<p className="fo-eyebrow">Achats · fournisseurs</p>
<h1 className="fo-h1">Fournisseurs</h1>
</div>
<button className="fo-btn-primary" onClick={openNewForm}>+ Nouveau fournisseur</button>
</header>
<div className="fo-toolbar">
<div className="fo-search">
<input
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(1); }}
placeholder="Rechercher..."
/>
</div>
<div className="fo-filter-tabs">
{["Tous", "ACTIF", "INACTIF"].map((s) => (
<button key={s} className={statutFiltre === s ? "active" : ""} onClick={() => { setStatutFiltre(s); setPage(1); }}>
{s === "Tous" ? "Tous" : s === "ACTIF" ? "Actifs" : "Inactifs"}
</button>
))}
</div>
</div>
<div className="fo-table-wrap">
<table className="fo-table">
<thead>
<tr>
<th>Raison sociale</th><th>Email</th><th>Téléphone</th><th>Contrats</th>
<th style={{ textAlign: "right" }}>Engagé</th><th>Statut</th><th></th>
</tr>
</thead>
<tbody>
{pageItems.map((f) => {
const nbContrats = contratsDe(f.id).length;
const totalEngage = totalEngageDe(f.id);
return (
<tr key={f.id} className="fo-row" onClick={() => openDetail(f.id)}>
<td className="fo-objet-cell">{f.raisonSociale}</td>
<td>{f.email ?? "—"}</td>
<td>{f.telephone ?? "—"}</td>
<td>{nbContrats}</td>
<td className="fo-amount">{euro(totalEngage)}</td>
<td>
<span className="fo-status-pill" data-statut={f.statut === "ACTIF" ? "ACTIF" : "TERMINE"}>{f.statut === "ACTIF" ? "Actif" : "Inactif"}</span>
</td>
<td onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
<button className="fo-btn-small" onClick={() => openEditForm(f)}>Modifier</button>
<button className="fo-btn-small fo-btn-danger" onClick={() => { setDeleteError(null); setConfirmDeleteId(confirmDeleteId === f.id ? null : f.id); }}>Supprimer</button>
</div>
{confirmDeleteId === f.id && (
<div className="fo-confirm-inline" style={{ marginTop: 8, justifyContent: "flex-end" }}>
<span>Supprimer {f.raisonSociale} ?</span>
<button onClick={() => deleteFournisseur(f.id)}>Oui</button>
<button onClick={() => setConfirmDeleteId(null)}>Annuler</button>
</div>
)}
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr><td colSpan={7} className="fo-empty">Aucun fournisseur ne correspond à ces filtres.</td></tr>
)}
</tbody>
</table>
</div>
{deleteError && <div className="fo-alert-error" style={{ marginTop: 12 }}>{deleteError}</div>}
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
</div>
);
}
const css = `
.fo-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.fo-root *{ box-sizing:border-box; }
.fo-wrap{ max-width:1180px; margin:0 auto; padding:28px 32px 64px; }
.fo-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.fo-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.fo-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.fo-h2{ font-size:15px; font-weight:600; }
.fo-sub{ font-size:13px; color:var(--text-muted); margin:0; }
.fo-back-link{ font-family:var(--sans); font-size:13px; color:var(--text-2); background:none; border:none; cursor:pointer; padding:0; margin-bottom:16px; }
.fo-back-link:hover{ color:var(--accent); }
.fo-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.fo-btn-primary:hover{ background:#25405C; }
.fo-btn-primary:disabled{ opacity:0.6; cursor:default; }
.fo-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.fo-btn-ghost:hover{ border-color:var(--accent); }
.fo-btn-small{ font-family:var(--sans); font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.fo-btn-small:hover{ border-color:var(--accent); color:var(--accent); }
.fo-btn-danger{ color:var(--danger); }
.fo-btn-danger-outline{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--danger); background:var(--danger-light); color:var(--danger); cursor:pointer; }
.fo-toggle-btn{ font-family:var(--sans); font-size:13px; padding:7px 16px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.fo-toggle-btn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.fo-alert-error{ background:var(--danger-light); color:var(--danger); border-radius:var(--radius); padding:10px 14px; font-size:13px; margin-bottom:14px; }
.fo-confirm-inline{ display:inline-flex; align-items:center; gap:8px; background:var(--danger-light); border-radius:20px; padding:6px 12px; font-size:12.5px; color:var(--danger); }
.fo-confirm-inline button{ font-family:var(--sans); font-size:12px; font-weight:600; color:var(--danger); background:none; border:none; cursor:pointer; padding:0; }
.fo-confirm-inline button:last-child{ font-weight:400; color:var(--text-2); }
.fo-toolbar{ display:flex; align-items:center; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
.fo-search input{ width:220px; font-family:var(--sans); font-size:13px; padding:8px 12px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); }
.fo-search input:focus{ outline:none; border-color:var(--accent); }
.fo-filter-tabs{ display:flex; gap:6px; flex-wrap:wrap; }
.fo-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.fo-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.fo-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:12px; }
.fo-table-head{ padding:14px 16px; border-bottom:1px solid var(--border); }
.fo-table{ width:100%; border-collapse:collapse; font-size:13px; }
.fo-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.fo-table td{ padding:12px 16px; border-bottom:1px solid var(--border); }
.fo-table tbody tr:last-child td{ border-bottom:none; }
.fo-row{ cursor:pointer; }
.fo-row:hover{ background:var(--surface-2); }
.fo-objet-cell{ font-weight:500; }
.fo-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.fo-amount{ font-family:var(--mono); text-align:right; }
.fo-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.fo-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; background:var(--surface-2); color:var(--text-2); }
.fo-status-pill[data-statut="Actif"], .fo-status-pill[data-statut="ACTIF"]{ background:var(--success-light); color:var(--success); }
.fo-status-pill[data-statut="Inactif"], .fo-status-pill[data-statut="TERMINE"]{ background:var(--surface-2); color:var(--text-2); }
.fo-status-pill[data-statut="A_RENOUVELER"]{ background:var(--warning-light); color:var(--warning); }
.fo-info-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:20px; }
.fo-info-card{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:14px 16px; }
.fo-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 6px; }
.fo-info-value{ font-size:14px; font-weight:600; color:var(--text); margin:0; }
.fo-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.fo-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:0 16px; }
.fo-field{ margin-bottom:14px; }
.fo-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.fo-field input, .fo-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.fo-field input:focus, .fo-field select:focus{ outline:none; border-color:var(--accent); }
.fo-form-actions{ display:flex; gap:8px; margin-top:6px; }
.fo-pagination{ display:flex; align-items:center; justify-content:center; gap:6px; padding:20px 0 4px; }
.fo-page-btn{ min-width:32px; height:32px; padding:0 8px; border-radius:7px; font-family:var(--sans); font-size:13px; font-weight:500; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.fo-page-btn:hover:not(:disabled){ border-color:var(--accent); color:var(--accent); }
.fo-page-btn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.fo-page-btn:disabled{ opacity:0.4; cursor:default; }
.fo-page-ellipsis{ width:24px; text-align:center; color:var(--text-muted); font-size:13px; }
@media (max-width:900px){
.fo-form-grid{ grid-template-columns:1fr; }
}
`;
+58
View File
@@ -0,0 +1,58 @@
// src/pages/Login.tsx
import { useSearchParams } from 'react-router-dom';
import { Box, Paper, Stack, Typography, Button } from '@mui/material';
import { tk } from '../theme';
import { Logo } from '../ui';
import { useAuth } from '../auth';
function MicrosoftLogo() {
return (
<svg width="18" height="18" viewBox="0 0 21 21" aria-hidden>
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
<rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
</svg>
);
}
const ERROR_MESSAGES: Record<string, string> = {
invalid_state: 'La session de connexion a expiré. Réessaie.',
token_exchange_failed: "Échec de l'échange avec Microsoft. Réessaie.",
graph_me_failed: 'Impossible de récupérer ton profil Microsoft.',
no_email: "Ton compte Microsoft n'a pas d'adresse e-mail exploitable.",
user_not_found: "Ton compte n'est pas encore autorisé à accéder à SI Finance. Contacte le support.",
};
export default function Login() {
const { login } = useAuth();
const [searchParams] = useSearchParams();
const errorCode = searchParams.get('error');
const error = errorCode ? (ERROR_MESSAGES[errorCode] ?? 'La connexion a échoué. Réessaie.') : null;
return (
<Box sx={{ minHeight: '100vh', bgcolor: tk.canvas, px: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<Stack direction="row" spacing={1.5} alignItems="center" mb={4}>
<Logo size={46} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: 20, letterSpacing: '-0.01em' }}>SI Finance</Typography>
<Typography sx={{ fontSize: 13, color: tk.muted }}>Système d'information financier</Typography>
</Box>
</Stack>
<Paper sx={{ p: 4, width: '100%', maxWidth: 400 }}>
<Typography sx={{ fontWeight: 700, fontSize: 19, mb: 0.5 }}>Bienvenue</Typography>
<Typography sx={{ color: tk.muted, fontSize: 14, mb: 3 }}>Connectez-vous pour accéder à vos modules.</Typography>
<Button fullWidth variant="outlined" onClick={login} startIcon={<MicrosoftLogo />}
sx={{ py: 1.25, gap: 1, color: tk.ink, borderColor: tk.line, '&:hover': { borderColor: tk.faint, bgcolor: tk.canvas } }}>
Se connecter avec Microsoft
</Button>
{error && <Typography sx={{ mt: 2, fontSize: 13, color: '#A63D34' }}>{error}</Typography>}
</Paper>
<Typography sx={{ mt: 3, fontSize: 12, color: tk.faint }}>
Accès réservé aux agents habilités · Support : dsi@organisation.fr
</Typography>
</Box>
);
}
+515
View File
@@ -0,0 +1,515 @@
import { useEffect, useMemo, useState } from "react";
import {
achatsApi, financeApi,
type ModePaiement as ModePaiementRef, type PaiementGroupe, type FactureDisponible, type StatutPaiement,
} from "../data";
/*
Mêmes tokens de couleur / structure que ContratsFournisseurs.tsx et FacturesAvoirs.tsx.
Données réelles via financeApi (SIFinance.Modules.Finance). Un "paiement" à l'écran peut
regrouper plusieurs factures d'un même fournisseur ; côté API cela correspond à plusieurs
lignes fin.paiements partageant la même référence — voir financeApi.getPaiements().
*/
const fmtDate = (iso: string) => {
if (!iso) return "";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const fmtEur = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const statutLabel: Record<StatutPaiement, string> = {
programme: "Programmé",
execute: "Exécuté",
rejete: "Rejeté",
};
const PAGE_SIZE = 8;
function getPageList(current: number, total: number): (number | "…")[] {
const delta = 1;
const range: (number | "…")[] = [];
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) range.push(i);
if (current - delta > 2) range.unshift("…");
if (current + delta < total - 1) range.push("…");
range.unshift(1);
if (total > 1) range.push(total);
return range;
}
function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
if (totalPages <= 1) return null;
const pages = getPageList(page, totalPages);
return (
<div className="pa-pagination">
<button className="pa-page-btn" disabled={page === 1} onClick={() => onChange(Math.max(1, page - 1))}>
‹
</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="pa-page-ellipsis">
…
</span>
) : (
<button key={p} className={`pa-page-btn${p === page ? " active" : ""}`} onClick={() => onChange(p)}>
{p}
</button>
)
)}
<button className="pa-page-btn" disabled={page === totalPages} onClick={() => onChange(Math.min(totalPages, page + 1))}>
›
</button>
</div>
);
}
type FilterTab = "tous" | StatutPaiement;
export default function Paiements() {
const [paiements, setPaiements] = useState<PaiementGroupe[]>([]);
const [facturesDisponibles, setFacturesDisponibles] = useState<FactureDisponible[]>([]);
const [modesPaiement, setModesPaiement] = useState<ModePaiementRef[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingReference, setEditingReference] = useState<string | null>(null);
const [filter, setFilter] = useState<FilterTab>("tous");
const [page, setPage] = useState(1);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [modePaiementId, setModePaiementId] = useState<number | "">("");
const [datePaiement, setDatePaiement] = useState("");
const [reference, setReference] = useState("");
const [statut, setStatut] = useState<StatutPaiement>("programme");
const [rapproche, setRapproche] = useState(false);
const refresh = async () => {
const [p, f] = await Promise.all([financeApi.getPaiements(), financeApi.getFacturesDisponibles()]);
setPaiements(p);
setFacturesDisponibles(f);
};
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([financeApi.getPaiements(), financeApi.getFacturesDisponibles(), achatsApi.getModesPaiement()])
.then(([p, f, m]) => { if (!cancelled) { setPaiements(p); setFacturesDisponibles(f); setModesPaiement(m); } })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : "Échec du chargement."); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const selectedFactures = useMemo(
() => facturesDisponibles.filter((f) => selectedIds.has(f.id)),
[selectedIds, facturesDisponibles]
);
const fournisseurSelection = selectedFactures[0]?.fournisseur ?? null;
const fournisseurIdSelection = selectedFactures[0]?.fournisseurId ?? null;
const montantTotalSelection = selectedFactures.reduce((sum, f) => sum + f.montant, 0);
const resetForm = () => {
setSelectedIds(new Set());
setModePaiementId("");
setDatePaiement("");
setReference("");
setStatut("programme");
setRapproche(false);
setEditingReference(null);
};
const openNewForm = () => {
resetForm();
setShowForm(true);
};
const openEditForm = (p: PaiementGroupe) => {
setEditingReference(p.reference);
setModePaiementId(p.modePaiementId);
setDatePaiement(p.datePaiement);
setReference(p.reference);
setStatut(p.statut);
setRapproche(p.rapproche);
setSelectedIds(new Set());
setShowForm(true);
};
const toggleFacture = (f: FactureDisponible) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(f.id)) {
next.delete(f.id);
return next;
}
if (fournisseurIdSelection && fournisseurIdSelection !== f.fournisseurId) {
return new Set([f.id]);
}
next.add(f.id);
return next;
});
};
const deletePaiement = async (ref: string) => {
if (!window.confirm("Supprimer ce paiement ? Les factures associées redeviendront disponibles.")) return;
try {
await financeApi.supprimerPaiement(ref);
await refresh();
setPage(1);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la suppression.");
}
};
const savePaiement = async () => {
if (!modePaiementId || !datePaiement || !reference) {
alert("Renseigner le mode de paiement, la date et une référence de paiement.");
return;
}
try {
if (editingReference) {
await financeApi.modifierPaiement(editingReference, { modePaiementId, datePaiement, reference, statut, rapproche });
} else {
if (selectedFactures.length === 0) {
alert("Sélectionner au moins une facture.");
return;
}
await financeApi.createPaiement({ factureIds: selectedFactures.map((f) => f.id), modePaiementId, datePaiement, reference, statut, rapproche });
setPage(1);
}
await refresh();
resetForm();
setShowForm(false);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de l'enregistrement du paiement.");
}
};
const toggleRapprocheInline = async (ref: string) => {
setPaiements((prev) => prev.map((p) => (p.reference === ref ? { ...p, rapproche: !p.rapproche } : p)));
try {
await financeApi.toggleRapprochePaiement(ref);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la mise à jour.");
await refresh();
}
};
const visiblePaiements = useMemo(() => {
if (filter === "tous") return paiements;
return paiements.filter((p) => p.statut === filter);
}, [paiements, filter]);
const totalPages = Math.max(1, Math.ceil(visiblePaiements.length / PAGE_SIZE));
const pagePaiements = visiblePaiements.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const changeFilter = (f: FilterTab) => {
setFilter(f);
setPage(1);
};
const editingPaiement = editingReference ? paiements.find((p) => p.reference === editingReference) ?? null : null;
return (
<div className="pa-root">
<style>{css}</style>
<div className="pa-wrap">
<header className="pa-header">
<div>
<p className="pa-eyebrow">Achats · paiements</p>
<h1 className="pa-h1">Paiements</h1>
</div>
<button className="pa-btn-primary" onClick={openNewForm}>
+ Nouveau paiement
</button>
</header>
{error && <div className="pa-empty" style={{ background: "var(--danger-light)", color: "var(--danger)", borderRadius: "var(--radius)", padding: "10px 14px", marginBottom: 16, textAlign: "left" }}>{error}</div>}
{showForm && (
<div className="pa-panel" style={{ marginBottom: 20 }}>
<p className="pa-panel-title">{editingReference ? "Modifier le paiement" : "Créer un paiement"}</p>
{!editingReference ? (
<>
<p className="pa-detail-label">
Factures validées disponibles <span className="pa-muted-inline">· un paiement regroupe un seul fournisseur</span>
</p>
<table className="pa-table" style={{ marginBottom: 18 }}>
<thead>
<tr>
<th style={{ width: 34 }}></th>
<th>Fournisseur</th>
<th>N° facture</th>
<th>Montant</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{facturesDisponibles.map((f) => (
<tr
key={f.id}
className={selectedIds.has(f.id) ? "pa-row-selected" : ""}
onClick={() => toggleFacture(f)}
style={{ cursor: "pointer" }}
>
<td>
<input type="checkbox" checked={selectedIds.has(f.id)} readOnly />
</td>
<td>{f.fournisseur}</td>
<td>{f.numero}</td>
<td className="pa-amount">{fmtEur(f.montant)}</td>
<td>{fmtDate(f.dateFacture)}</td>
</tr>
))}
{facturesDisponibles.length === 0 && (
<tr>
<td colSpan={5} className="pa-empty">
Aucune facture validée en attente de paiement.
</td>
</tr>
)}
</tbody>
</table>
{selectedFactures.length > 0 && (
<div className="pa-selection-summary">
<span>
{selectedFactures.length} facture{selectedFactures.length > 1 ? "s" : ""} · {fournisseurSelection}
</span>
<span className="pa-amount">{fmtEur(montantTotalSelection)}</span>
</div>
)}
</>
) : (
<div className="pa-selection-summary" style={{ marginBottom: 18 }}>
<span>
{editingPaiement?.factureNumeros.join(", ")} · {editingPaiement?.fournisseur}
</span>
<span className="pa-amount">{fmtEur(editingPaiement?.montantTotal ?? 0)}</span>
</div>
)}
<div className="pa-form-grid">
<div className="pa-field">
<label>Mode de paiement</label>
<select value={modePaiementId} onChange={(e) => setModePaiementId(e.target.value ? Number(e.target.value) : "")}>
<option value="">Sélectionner…</option>
{modesPaiement.map((m) => <option key={m.id} value={m.id}>{m.libelle}</option>)}
</select>
</div>
<div className="pa-field">
<label>Date de paiement</label>
<input type="date" value={datePaiement} onChange={(e) => setDatePaiement(e.target.value)} />
</div>
<div className="pa-field">
<label>Référence</label>
<input value={reference} onChange={(e) => setReference(e.target.value)} placeholder="VIR-2026-0143" />
</div>
<div className="pa-field">
<label>Statut</label>
<select value={statut} onChange={(e) => setStatut(e.target.value as StatutPaiement)}>
<option value="programme">Programmé</option>
<option value="execute">Exécuté</option>
<option value="rejete">Rejeté</option>
</select>
</div>
</div>
{statut === "execute" && (
<label className="pa-checkbox-row">
<input type="checkbox" checked={rapproche} onChange={(e) => setRapproche(e.target.checked)} />
Déjà rapproché avec le relevé bancaire
</label>
)}
<div className="pa-form-actions">
<button
className="pa-btn-ghost"
onClick={() => {
resetForm();
setShowForm(false);
}}
>
Annuler
</button>
<button className="pa-btn-primary" onClick={savePaiement}>
{editingReference ? "Enregistrer les modifications" : "Créer le paiement"}
</button>
</div>
</div>
)}
<div className="pa-table-wrap">
<div className="pa-table-head">
<div className="pa-filter-tabs">
<button className={filter === "tous" ? "active" : ""} onClick={() => changeFilter("tous")}>
Tous
</button>
<button className={filter === "programme" ? "active" : ""} onClick={() => changeFilter("programme")}>
Programmés
</button>
<button className={filter === "execute" ? "active" : ""} onClick={() => changeFilter("execute")}>
Exécutés
</button>
<button className={filter === "rejete" ? "active" : ""} onClick={() => changeFilter("rejete")}>
Rejetés
</button>
</div>
</div>
<table className="pa-table">
<thead>
<tr>
<th>Fournisseur</th>
<th>Factures</th>
<th>Montant</th>
<th>Mode</th>
<th>Date</th>
<th>Référence</th>
<th>Statut</th>
<th>Rapproché</th>
<th style={{ width: 130 }}>Actions</th>
</tr>
</thead>
<tbody>
{pagePaiements.map((p) => (
<tr key={p.reference}>
<td>{p.fournisseur}</td>
<td className="pa-muted-inline">{p.factureNumeros.join(", ")}</td>
<td className="pa-amount">{fmtEur(p.montantTotal)}</td>
<td>{p.modePaiement}</td>
<td>{fmtDate(p.datePaiement)}</td>
<td className="pa-mono-cell">{p.reference}</td>
<td>
<span className="pa-status-pill" data-statut={p.statut}>
{statutLabel[p.statut]}
</span>
</td>
<td>
{p.statut === "execute" ? (
<button
className={`pa-rapproche-toggle${p.rapproche ? " on" : ""}`}
onClick={() => toggleRapprocheInline(p.reference)}
>
{p.rapproche ? "Oui" : "Non"}
</button>
) : (
<span className="pa-muted-inline">—</span>
)}
</td>
<td>
<div style={{ display: "flex", gap: 6 }}>
<button className="pa-btn-small" onClick={() => openEditForm(p)}>
Modifier
</button>
<button className="pa-btn-small pa-btn-danger" onClick={() => deletePaiement(p.reference)}>
Supprimer
</button>
</div>
</td>
</tr>
))}
{!loading && visiblePaiements.length === 0 && (
<tr>
<td colSpan={9} className="pa-empty">
Aucun paiement dans cette vue.
</td>
</tr>
)}
{loading && (
<tr>
<td colSpan={9} className="pa-empty">
Chargement…
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
</div>
);
}
const css = `
.pa-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.pa-root *{ box-sizing:border-box; }
.pa-wrap{ max-width:1180px; margin:0 auto; padding:28px 32px 64px; }
.pa-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.pa-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.pa-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.pa-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.pa-btn-primary:hover{ background:#25405C; }
.pa-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.pa-btn-ghost:hover{ border-color:var(--accent); }
.pa-btn-small{ font-family:var(--sans); font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.pa-btn-small:hover{ border-color:var(--accent); color:var(--accent); }
.pa-btn-danger{ border:none; color:var(--danger); background:transparent; }
.pa-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.pa-panel-title{ font-size:15px; font-weight:600; margin:0 0 16px; }
.pa-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 10px; }
.pa-muted-inline{ text-transform:none; color:var(--text-muted); font-size:12px; font-weight:400; }
.pa-table{ width:100%; border-collapse:collapse; font-size:13px; }
.pa-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:8px 10px; border-bottom:1px solid var(--border); }
.pa-table td{ padding:9px 10px; border-bottom:1px solid var(--border); }
.pa-table tbody tr:last-child td{ border-bottom:none; }
.pa-table tbody tr:hover{ background:var(--surface-2); }
.pa-row-selected{ background:var(--accent-light) !important; }
.pa-amount{ font-family:var(--mono); }
.pa-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.pa-empty{ text-align:center; color:var(--text-muted); padding:20px !important; }
.pa-selection-summary{ display:flex; justify-content:space-between; align-items:center; background:var(--surface-2); border-radius:var(--radius); padding:10px 14px; font-size:13px; margin-bottom:18px; }
.pa-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; margin-top:4px; }
.pa-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.pa-field input, .pa-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.pa-field input:focus, .pa-field select:focus{ outline:none; border-color:var(--accent); }
.pa-checkbox-row{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--text-2); margin-top:14px; cursor:pointer; }
.pa-form-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }
.pa-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.pa-table-head{ padding:14px 16px; border-bottom:1px solid var(--border); }
.pa-filter-tabs{ display:flex; gap:6px; }
.pa-filter-tabs button{ font-family:var(--sans); font-size:12px; padding:6px 12px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.pa-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.pa-status-pill{ font-size:12px; padding:3px 10px; border-radius:20px; background:var(--surface-2); color:var(--text-2); }
.pa-status-pill[data-statut="programme"]{ background:var(--surface-2); color:var(--text-2); }
.pa-status-pill[data-statut="execute"]{ background:var(--success-light); color:var(--success); }
.pa-status-pill[data-statut="rejete"]{ background:var(--danger-light); color:var(--danger); }
.pa-rapproche-toggle{ font-family:var(--sans); font-size:12px; padding:3px 10px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-muted); cursor:pointer; }
.pa-rapproche-toggle.on{ background:var(--accent-light); border-color:var(--accent); color:var(--accent); }
@media (max-width:900px){
.pa-form-grid{ grid-template-columns:1fr; }
}
.pa-pagination{ display:flex; align-items:center; justify-content:center; gap:6px; padding:18px 0 4px; }
.pa-page-btn{ min-width:32px; height:32px; padding:0 8px; border-radius:7px; font-family:var(--sans); font-size:13px; font-weight:500; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.pa-page-btn:hover:not(:disabled){ border-color:var(--accent); color:var(--accent); }
.pa-page-btn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.pa-page-btn:disabled{ opacity:0.4; cursor:default; }
.pa-page-ellipsis{ width:24px; text-align:center; color:var(--text-muted); font-size:13px; }
`;
+625
View File
@@ -0,0 +1,625 @@
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { achatsApi, financeApi, type Service, type BudgetServiceItem } from "../data";
import { useAuth } from "../auth";
import { useWorkspace } from "../Layout";
/*
Composant autonome — aucune dépendance externe hormis React.
Mêmes tokens de couleur / structure que ContratsFournisseurs.tsx,
FacturesAvoirs.tsx, Paiements.tsx, Demandes.tsx, Fournisseurs.tsx et DemandeForm.tsx.
Données réelles via achatsApi / financeApi.
*/
const euro = (n: number) => new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
const MOIS_LABEL = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"];
const VUES = ["Par service", "Par fournisseur", "Prévisionnel"] as const;
type Vue = (typeof VUES)[number];
type PeriodeCode = "T1" | "T2" | "S1" | "ANNEE";
const PERIODE_MOIS: Record<PeriodeCode, [number, number]> = {
T1: [0, 2], T2: [3, 5], S1: [0, 5], ANNEE: [0, 11],
};
const PERIODE_LABEL: Record<PeriodeCode, string> = { T1: "T1", T2: "T2", S1: "S1", ANNEE: "Année" };
function SearchIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
function BellIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
);
}
function KpiCard({ label, value }: { label: string; value: string }) {
return (
<div className="pi-kpi-card">
<p className="pi-kpi-label">{label}</p>
<p className="pi-kpi-value">{value}</p>
</div>
);
}
const dansPeriode = (iso: string, annee: number, code: PeriodeCode) => {
const d = new Date(iso);
const [m0, m1] = PERIODE_MOIS[code];
return d.getFullYear() === annee && d.getMonth() >= m0 && d.getMonth() <= m1;
};
export default function Pilotage() {
const { roles, serviceId } = useAuth();
const { role: workspaceRole } = useWorkspace();
// FINANCE garde la visibilité totale, mais seulement dans l'espace de travail Finance —
// un utilisateur qui cumule le rôle FINANCE et a basculé sur l'espace Collaborateur
// (même logique que Demandes.tsx/Dashboard.tsx) ne doit voir que son propre service,
// sinon la bascule d'espace de travail n'aurait aucun effet sur cette page.
const isFinance = roles.includes("FINANCE") && workspaceRole === "finance";
const anneeCourante = new Date().getFullYear();
const moisCourant = new Date().getMonth(); // 0 = janvier
// Exercice non calendaire (1er septembre → 31 août) : de janvier à août, on est encore
// dans l'exercice démarré en septembre de l'année précédente.
const exerciceCourant = moisCourant >= 8 ? anneeCourante : anneeCourante - 1;
const PERIODES: { code: PeriodeCode; annee: number; label: string }[] = [
{ code: "T1", annee: anneeCourante, label: `${anneeCourante} · T1` },
{ code: "T2", annee: anneeCourante, label: `${anneeCourante} · T2` },
{ code: "S1", annee: anneeCourante, label: `${anneeCourante} · S1` },
{ code: "ANNEE", annee: anneeCourante, label: `${anneeCourante} · Année` },
];
const [periode, setPeriode] = useState<PeriodeCode>("S1");
const [vue, setVue] = useState<Vue>("Par service");
// Mêmes queryKey que dans Layout/Dashboard pour "demandes"/"factures" : React Query
// dédoublonne les appels et sert le cache, donc pas de refetch si déjà chargées.
const demandesQ = useQuery({ queryKey: ["demandes"], queryFn: () => achatsApi.getDemandes() });
const facturesQ = useQuery({ queryKey: ["factures"], queryFn: financeApi.getFactures });
const paiementsQ = useQuery({ queryKey: ["paiements"], queryFn: financeApi.getPaiements });
const contratsQ = useQuery({ queryKey: ["contrats"], queryFn: financeApi.getContrats });
const servicesQ = useQuery({ queryKey: ["services"], queryFn: achatsApi.getServices });
const budgetsQ = useQuery({ queryKey: ["budgets", exerciceCourant], queryFn: () => achatsApi.getBudgets(exerciceCourant) });
const budgets = budgetsQ.data ?? [];
const queryClient = useQueryClient();
const [busyBudgetServiceId, setBusyBudgetServiceId] = useState<number | null>(null);
const refreshBudgets = () => queryClient.invalidateQueries({ queryKey: ["budgets", exerciceCourant] });
// Formulaire inline (au lieu de window.prompt, peu fiable selon le navigateur/contexte
// d'affichage) — un seul ouvert à la fois, identifié par le service concerné.
const [budgetFormServiceId, setBudgetFormServiceId] = useState<number | null>(null);
const [budgetFormMontant, setBudgetFormMontant] = useState("");
const [budgetFormCommentaire, setBudgetFormCommentaire] = useState("");
const [historiqueOuvert, setHistoriqueOuvert] = useState<Set<number>>(new Set());
const [budgetSuccess, setBudgetSuccess] = useState<string | null>(null);
const ouvrirFormulaireBudget = (serviceIdCible: number) => {
setBudgetFormServiceId(serviceIdCible);
setBudgetFormMontant("");
setBudgetFormCommentaire("");
};
const fermerFormulaireBudget = () => setBudgetFormServiceId(null);
const toggleHistorique = (serviceIdCible: number) => {
setHistoriqueOuvert((prev) => {
const next = new Set(prev);
next.has(serviceIdCible) ? next.delete(serviceIdCible) : next.add(serviceIdCible);
return next;
});
};
// Un budget initial se crée une fois par service et par exercice ; une fois posé, on ne
// fait plus que le réviser (l'augmenter en cours d'exercice, avec historique complet
// conservé) — jamais recréer le montant initial, qui reste la référence de ce qui avait
// été voté au départ.
const validerFormulaireBudget = async (serviceIdCible: number, budgetExistant: BudgetServiceItem | undefined) => {
const montant = parseFloat(budgetFormMontant.replace(",", "."));
if (!montant || montant <= 0) {
alert("Montant invalide.");
return;
}
setBusyBudgetServiceId(serviceIdCible);
try {
if (!budgetExistant) {
await achatsApi.creerBudget({ serviceId: serviceIdCible, exerciceAnnee: exerciceCourant, montantInitial: montant });
setBudgetSuccess(`Budget initial enregistré : ${euro(montant)}.`);
} else {
const plafondActuel = budgetExistant.montantRevise ?? budgetExistant.montantInitial;
await achatsApi.reviserBudget(budgetExistant.id, {
montantRevise: plafondActuel + montant,
commentaire: budgetFormCommentaire.trim() || null,
});
setBudgetSuccess(`${euro(montant)} ajoutés au budget de ${nomService(serviceIdCible)}.`);
setHistoriqueOuvert((prev) => new Set(prev).add(serviceIdCible));
}
await refreshBudgets();
fermerFormulaireBudget();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de l'enregistrement du budget.");
} finally {
setBusyBudgetServiceId(null);
}
};
const services = servicesQ.data ?? [];
// Le helper nomService() de data.ts s'appuie sur une liste mockée non fiable — on
// résout les libellés à partir des services réels chargés depuis l'API (même correctif
// que sur Demandes.tsx).
const nomService = useMemo(() => {
const map = new Map(services.map((s: Service) => [s.id, s.libelle]));
return (id: number) => map.get(id) ?? "—";
}, [services]);
const demandesBrutes = demandesQ.data ?? [];
const facturesBrutes = facturesQ.data ?? [];
const paiementsBrutes = paiementsQ.data ?? [];
const contrats = contratsQ.data ?? [];
const loading = demandesQ.isLoading || facturesQ.isLoading || paiementsQ.isLoading || contratsQ.isLoading || servicesQ.isLoading || budgetsQ.isLoading;
const errorObj = demandesQ.error ?? facturesQ.error ?? paiementsQ.error ?? contratsQ.error ?? servicesQ.error ?? budgetsQ.error;
// Scoping par service : seul FINANCE voit tout. Un collaborateur/responsable sans
// service assigné en base ne doit PAS retomber sur la vue globale par défaut — il ne
// voit rien tant que son compte n'est pas rattaché à un service (cf. message d'en-tête).
// Une facture n'a pas de service fiable en base (colonne toujours posée à une valeur
// par défaut à la création côté backend), donc on la rattache via la demande d'achat
// liée plutôt que via sa propre colonne ServiceId. Les contrats ne sont pas rattachés
// à un service — la vue "Prévisionnel" reste globale.
const demandes = useMemo(
() => (isFinance ? demandesBrutes : serviceId == null ? [] : demandesBrutes.filter((d) => d.serviceId === serviceId)),
[demandesBrutes, isFinance, serviceId]
);
const factures = useMemo(() => {
if (isFinance) return facturesBrutes;
if (serviceId == null) return [];
const serviceParDemande = new Map(demandesBrutes.map((d) => [d.id, d.serviceId]));
return facturesBrutes.filter((f) => f.demandeAchatId != null && serviceParDemande.get(f.demandeAchatId) === serviceId);
}, [facturesBrutes, demandesBrutes, isFinance, serviceId]);
const paiements = useMemo(() => {
if (isFinance) return paiementsBrutes;
if (serviceId == null) return [];
const facturesDuService = new Set(factures.map((f) => f.id));
return paiementsBrutes.filter((p) => p.factureIds.some((id) => facturesDuService.has(id)));
}, [paiementsBrutes, factures, isFinance, serviceId]);
const periodeInfo = PERIODES.find((p) => p.code === periode)!;
const facturesPeriode = useMemo(
() => factures.filter((f) => dansPeriode(f.dateFacture, periodeInfo.annee, periode)),
[factures, periodeInfo.annee, periode]
);
const paiementsPeriode = useMemo(
() => paiements.filter((p) => p.statut === "execute" && dansPeriode(p.datePaiement, periodeInfo.annee, periode)),
[paiements, periodeInfo.annee, periode]
);
const demandesPeriode = useMemo(
() => demandes.filter((d) => dansPeriode(d.dateCreation, periodeInfo.annee, periode)),
[demandes, periodeInfo.annee, periode]
);
const totalEngage = facturesPeriode.reduce((s, f) => s + f.montantTtc, 0);
const totalPaye = paiementsPeriode.reduce((s, p) => s + p.montantTotal, 0);
const resteAPayer = Math.max(0, totalEngage - totalPaye);
// Les contrats fournisseurs ne sont pas rattachés à un service en base — les exposer
// à un collaborateur/responsable reviendrait à lui montrer des engagements financiers
// d'autres services. On les réserve donc à FINANCE (visibilité totale).
// Consommé sur l'exercice budgétaire (indépendant du filtre de période T1/T2/S1/Année
// ci-dessus) : une facture est rattachée à un service via sa demande d'achat liée,
// comme pour le scoping (cf. commentaire plus haut).
const engageParServiceExercice = useMemo(() => {
const debut = new Date(exerciceCourant, 8, 1);
const fin = new Date(exerciceCourant + 1, 7, 31);
const serviceParDemande = new Map(demandesBrutes.map((d) => [d.id, d.serviceId]));
const map = new Map<number, number>();
for (const f of factures) {
const d = new Date(f.dateFacture);
if (d < debut || d > fin) continue;
const sId = f.demandeAchatId != null ? serviceParDemande.get(f.demandeAchatId) : undefined;
if (sId == null) continue;
map.set(sId, (map.get(sId) ?? 0) + f.montantTtc);
}
return map;
}, [factures, demandesBrutes, exerciceCourant]);
const budgetsAffiches = useMemo(() => {
const rows = isFinance ? services : services.filter((s) => s.id === serviceId);
return rows.map((s) => {
const budget = budgets.find((b) => b.serviceId === s.id);
const engage = engageParServiceExercice.get(s.id) ?? 0;
const plafond = budget?.montantRevise ?? budget?.montantInitial ?? null;
return { service: s, budget, engage, plafond };
});
}, [services, budgets, engageParServiceExercice, isFinance, serviceId]);
const previsionnelS2 = useMemo(() => {
if (!isFinance) return 0;
const debutS2 = new Date(anneeCourante, 6, 1);
const finS2 = new Date(anneeCourante, 11, 31);
return contrats
.flatMap((ct) => ct.echeances)
.filter((e) => e.statut === "A_VENIR" && new Date(e.date) >= debutS2 && new Date(e.date) <= finS2)
.reduce((s, e) => s + e.montant, 0);
}, [contrats, anneeCourante, isFinance]);
// --- Panneau "Dépenses par ..." selon la vue active ---
const depenses = useMemo(() => {
const totals = new Map<string, number>();
if (vue === "Par service") {
for (const d of demandesPeriode) {
const label = nomService(d.serviceId);
totals.set(label, (totals.get(label) ?? 0) + d.montantTtc);
}
} else if (vue === "Par fournisseur") {
for (const f of facturesPeriode) {
totals.set(f.fournisseur, (totals.get(f.fournisseur) ?? 0) + f.montantTtc);
}
} else if (isFinance) {
const debutS2 = new Date(anneeCourante, 6, 1);
const finS2 = new Date(anneeCourante, 11, 31);
for (const ct of contrats) {
for (const e of ct.echeances) {
if (e.statut !== "A_VENIR") continue;
const dt = new Date(e.date);
if (dt < debutS2 || dt > finS2) continue;
totals.set(ct.fournisseur, (totals.get(ct.fournisseur) ?? 0) + e.montant);
}
}
}
return [...totals.entries()]
.map(([label, montant]) => ({ label, montant }))
.sort((a, b) => b.montant - a.montant)
.slice(0, 6);
}, [vue, demandesPeriode, facturesPeriode, contrats, anneeCourante, isFinance]);
const maxDep = Math.max(1, ...depenses.map((d) => d.montant));
// --- Panneau "Engagé vs payé par mois" sur la période sélectionnée ---
const parMois = useMemo(() => {
const [m0, m1] = PERIODE_MOIS[periode];
const mois: { mois: string; engage: number; paye: number }[] = [];
for (let m = m0; m <= m1; m++) {
const engage = factures
.filter((f) => { const d = new Date(f.dateFacture); return d.getFullYear() === periodeInfo.annee && d.getMonth() === m; })
.reduce((s, f) => s + f.montantTtc, 0);
const paye = paiements
.filter((p) => p.statut === "execute" && (() => { const d = new Date(p.datePaiement); return d.getFullYear() === periodeInfo.annee && d.getMonth() === m; })())
.reduce((s, p) => s + p.montantTotal, 0);
mois.push({ mois: MOIS_LABEL[m], engage, paye });
}
return mois;
}, [factures, paiements, periode, periodeInfo.annee]);
const maxMois = Math.max(1, ...parMois.flatMap((m) => [m.engage, m.paye]));
if (loading) {
return (
<div className="pi-root">
<style>{css}</style>
<div className="pi-wrap"><p className="pi-sub">Chargement…</p></div>
</div>
);
}
if (errorObj) {
const message = errorObj instanceof Error ? errorObj.message : "Erreur de chargement";
return (
<div className="pi-root">
<style>{css}</style>
<div className="pi-wrap"><p className="pi-sub" style={{ color: "var(--danger)" }}>Erreur : {message}</p></div>
</div>
);
}
return (
<div className="pi-root">
<style>{css}</style>
<div className="pi-wrap">
<header className="pi-header">
<div>
<p className="pi-eyebrow">Achats · pilotage</p>
<h1 className="pi-h1">Pilotage</h1>
<p className="pi-sub">
{isFinance
? "Suivi budgétaire et prévisionnel — tous services"
: serviceId != null
? `Suivi budgétaire et prévisionnel — ${nomService(serviceId)}`
: "Suivi budgétaire et prévisionnel — aucun service associé à votre compte"}
</p>
</div>
<div className="pi-header-actions">
<div className="pi-search">
<SearchIcon />
<span>Rechercher...</span>
</div>
<button className="pi-bell-btn" aria-label="Notifications">
<BellIcon />
<span className="pi-bell-dot" />
</button>
</div>
</header>
<div className="pi-toolbar">
<div className="pi-filter-tabs">
{PERIODES.map((p) => (
<button key={p.code} className={periode === p.code ? "active" : ""} onClick={() => setPeriode(p.code)}>
{p.label}
</button>
))}
</div>
<div className="pi-vue-toggle">
{VUES.filter((v) => isFinance || v !== "Prévisionnel").map((v) => (
<button key={v} className={vue === v ? "active" : ""} onClick={() => setVue(v)}>
{v}
</button>
))}
</div>
</div>
<div className="pi-kpi-grid">
<KpiCard label={`Total engagé — ${PERIODE_LABEL[periode]}`} value={euro(totalEngage)} />
<KpiCard label={`Total payé — ${PERIODE_LABEL[periode]}`} value={euro(totalPaye)} />
<KpiCard label="Reste à payer" value={euro(resteAPayer)} />
{isFinance && <KpiCard label="Prévisionnel S2" value={euro(previsionnelS2)} />}
</div>
<div className="pi-panel-grid">
<div className="pi-panel">
<p className="pi-panel-title">
{vue === "Par service" ? "Dépenses par service" : vue === "Par fournisseur" ? "Dépenses par fournisseur" : "Échéances de contrats prévues (S2)"}
</p>
{depenses.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--text-muted)" }}>Aucune donnée pour cette période.</p>
) : (
depenses.map((d) => (
<div className="pi-bar-row" key={d.label}>
<div className="pi-bar-head">
<span>{d.label}</span>
<span className="pi-bar-value">{euro(d.montant)}</span>
</div>
<div className="pi-bar-track">
<div className="pi-bar-fill" style={{ width: `${Math.max(3, Math.round((d.montant / maxDep) * 100))}%` }} />
</div>
</div>
))
)}
</div>
<div className="pi-panel">
<p className="pi-panel-title">Engagé vs payé — par mois</p>
<div className="pi-chart">
{parMois.map((m) => (
<div className="pi-chart-col" key={m.mois}>
<div className="pi-chart-bars">
<div className="pi-chart-bar engage" style={{ height: `${Math.max(4, Math.round((m.engage / maxMois) * 100))}%` }} />
<div className="pi-chart-bar paye" style={{ height: `${Math.max(4, Math.round((m.paye / maxMois) * 100))}%` }} />
</div>
<span className="pi-chart-label">{m.mois}</span>
</div>
))}
</div>
<div className="pi-legend">
<span className="pi-legend-item"><span className="pi-legend-dot engage" /> Engagé</span>
<span className="pi-legend-item"><span className="pi-legend-dot paye" /> Payé</span>
</div>
</div>
</div>
<div className="pi-panel-grid" style={{ marginTop: 16 }}>
<div className="pi-panel" style={{ gridColumn: "1 / -1" }}>
<p className="pi-panel-title">
Budget — exercice {exerciceCourant}/{exerciceCourant + 1} <span style={{ fontWeight: 400, color: "var(--text-muted)" }}>(1er sept. {exerciceCourant} – 31 août {exerciceCourant + 1})</span>
</p>
{budgetSuccess && isFinance && <p className="pi-budget-success" role="status">{budgetSuccess}</p>}
{budgetsAffiches.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--text-muted)" }}>
{serviceId == null && !isFinance ? "Aucun service associé à votre compte." : "Aucun service à afficher."}
</p>
) : (
budgetsAffiches.map(({ service, budget, engage, plafond }) => (
<div className="pi-budget-row" key={service.id}>
<div className="pi-budget-head">
<span>{service.libelle}</span>
{isFinance && (
<span style={{ display: "flex", gap: 6 }}>
{budget && budget.revisions.length > 0 && (
<button className="pi-budget-btn" onClick={() => toggleHistorique(service.id)}>
{historiqueOuvert.has(service.id) ? "Masquer l'historique" : `Historique (${budget.revisions.length})`}
</button>
)}
<button
className="pi-budget-btn"
disabled={busyBudgetServiceId === service.id}
onClick={() => (budgetFormServiceId === service.id ? fermerFormulaireBudget() : ouvrirFormulaireBudget(service.id))}
>
{budget ? "Ajouter du budget" : "Définir le budget initial"}
</button>
</span>
)}
</div>
{budget ? (
<>
<div className="pi-bar-track">
<div
className="pi-bar-fill"
style={{
width: `${plafond ? Math.min(100, Math.round((engage / plafond) * 100)) : 0}%`,
background: plafond != null && engage > plafond ? "var(--danger)" : undefined,
}}
/>
</div>
<p className="pi-budget-meta">
Initial {euro(budget.montantInitial)}
{budget.montantRevise != null && ` · Révisé ${euro(budget.montantRevise)}`}
{" · Engagé "}{euro(engage)}
{plafond != null && ` · Disponible ${euro(Math.max(0, plafond - engage))}`}
</p>
{!isFinance && budget.revisions.length > 0 && (() => {
const revisions = [...budget.revisions].sort(
(a, b) => new Date(a.dateRevision).getTime() - new Date(b.dateRevision).getTime()
);
const derniereRevision = revisions[revisions.length - 1];
const plafondAvant = revisions.length > 1
? revisions[revisions.length - 2].montantRevise
: budget.montantInitial;
const ajout = derniereRevision.montantRevise - plafondAvant;
return (
<div className="pi-budget-revision-notice" role="status">
<strong>Budget révisé par Finance</strong>
<span>+{euro(ajout)} le {new Date(derniereRevision.dateRevision).toLocaleDateString("fr-FR")} — nouveau budget : {euro(derniereRevision.montantRevise)}.</span>
{derniereRevision.commentaire && <span className="pi-budget-revision-comment">{derniereRevision.commentaire}</span>}
</div>
);
})()}
</>
) : (
<p className="pi-budget-empty">Aucun budget renseigné pour cet exercice.</p>
)}
{historiqueOuvert.has(service.id) && budget && budget.revisions.length > 0 && (
<div className="pi-budget-historique">
{budget.revisions.map((r) => (
<div className="pi-budget-historique-item" key={r.id}>
<span className="pi-budget-historique-montant">{euro(r.montantRevise)}</span>
<span className="pi-budget-historique-meta">
{new Date(r.dateRevision).toLocaleDateString("fr-FR")} · {r.revisePar}
{r.commentaire && ` — ${r.commentaire}`}
</span>
</div>
))}
</div>
)}
{budgetFormServiceId === service.id && (
<div className="pi-budget-form">
<input
type="text" inputMode="decimal" placeholder={budget ? "Montant à ajouter (EUR)" : "Budget initial (EUR)"}
value={budgetFormMontant} onChange={(e) => setBudgetFormMontant(e.target.value)} autoFocus
/>
{budget && <p className="pi-budget-form-help">Ce montant sera ajouté au plafond actuel de {euro(budget.montantRevise ?? budget.montantInitial)}.</p>}
{budget && (
<input
type="text" placeholder="Commentaire (raison de la révision, optionnel)"
value={budgetFormCommentaire} onChange={(e) => setBudgetFormCommentaire(e.target.value)}
/>
)}
<div className="pi-budget-form-actions">
<button className="pi-budget-btn" onClick={fermerFormulaireBudget}>Annuler</button>
<button
className="pi-budget-btn primary"
disabled={busyBudgetServiceId === service.id}
onClick={() => validerFormulaireBudget(service.id, budget)}
>
Valider
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
const css = `
.pi-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.pi-root *{ box-sizing:border-box; }
.pi-wrap{ max-width:1180px; margin:0 auto; padding:28px 32px 64px; }
.pi-header{ display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.pi-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.pi-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.pi-sub{ font-size:13px; color:var(--text-muted); margin:6px 0 0; }
.pi-header-actions{ display:flex; align-items:center; gap:10px; }
.pi-search{ display:flex; align-items:center; gap:8px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); height:36px; width:220px; padding:0 12px; color:var(--text-muted); font-size:13px; }
.pi-bell-btn{ position:relative; display:flex; align-items:center; justify-content:center; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); width:36px; height:36px; color:var(--text-2); cursor:pointer; }
.pi-bell-dot{ position:absolute; top:7px; right:8px; width:6px; height:6px; border-radius:50%; background:var(--danger); }
.pi-toolbar{ display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:12px; }
.pi-filter-tabs{ display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.pi-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.pi-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.pi-vue-toggle{ display:flex; align-items:center; gap:4px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:4px; }
.pi-vue-toggle button{ font-family:var(--sans); font-size:12.5px; padding:6px 12px; border-radius:6px; border:none; background:transparent; color:var(--text-muted); cursor:pointer; }
.pi-vue-toggle button.active{ background:var(--surface-2); color:var(--text); font-weight:500; }
.pi-kpi-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; }
.pi-kpi-card{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:16px 20px; }
.pi-kpi-label{ font-size:12.5px; color:var(--text-2); margin:0 0 8px; }
.pi-kpi-value{ font-size:22px; font-weight:600; color:var(--text); margin:0; letter-spacing:-0.01em; }
.pi-panel-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(340px, 1fr)); gap:16px; }
.pi-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:16px 20px; }
.pi-panel-title{ font-size:13.5px; font-weight:600; color:var(--text); margin:0 0 16px; }
.pi-bar-row{ margin-bottom:16px; }
.pi-bar-row:last-child{ margin-bottom:0; }
.pi-bar-head{ display:flex; justify-content:space-between; margin-bottom:6px; font-size:13px; color:var(--text-2); }
.pi-bar-value{ color:var(--text); font-variant-numeric:tabular-nums; font-family:var(--mono); }
.pi-bar-track{ height:5px; border-radius:3px; background:var(--surface-2); }
.pi-bar-fill{ height:100%; border-radius:3px; background:var(--accent); }
.pi-chart{ display:flex; align-items:flex-end; justify-content:space-between; height:130px; }
.pi-chart-col{ display:flex; flex-direction:column; align-items:center; gap:8px; flex:1; }
.pi-chart-bars{ display:flex; align-items:flex-end; gap:4px; height:100px; }
.pi-chart-bar{ width:9px; border-radius:2px 2px 0 0; }
.pi-chart-bar.engage{ background:var(--accent); }
.pi-chart-bar.paye{ background:var(--text-muted); }
.pi-chart-label{ font-size:11px; color:var(--text-muted); }
.pi-legend{ display:flex; align-items:center; gap:16px; margin-top:16px; }
.pi-legend-item{ display:flex; align-items:center; gap:6px; font-size:12px; color:var(--text-2); }
.pi-legend-dot{ width:8px; height:8px; border-radius:2px; display:inline-block; }
.pi-legend-dot.engage{ background:var(--accent); }
.pi-legend-dot.paye{ background:var(--text-muted); }
.pi-budget-row{ margin-bottom:18px; }
.pi-budget-row:last-child{ margin-bottom:0; }
.pi-budget-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:6px; font-size:13px; color:var(--text-2); }
.pi-budget-btn{ font-family:var(--sans); font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.pi-budget-btn:hover:not(:disabled){ border-color:var(--accent); color:var(--accent); }
.pi-budget-btn:disabled{ opacity:0.5; cursor:default; }
.pi-budget-meta{ font-size:12px; color:var(--text-muted); margin:6px 0 0; }
.pi-budget-empty{ font-size:12.5px; color:var(--text-muted); margin:0; }
.pi-budget-success{ margin:0 0 12px; padding:9px 12px; border-radius:6px; background:var(--success-light); color:var(--success); font-size:13px; }
.pi-budget-revision-notice{ display:flex; flex-direction:column; gap:3px; margin-top:10px; padding:10px 12px; border-left:3px solid var(--accent); border-radius:0 6px 6px 0; background:var(--accent-light); color:var(--text-2); font-size:12.5px; }
.pi-budget-revision-notice strong{ color:var(--accent); }
.pi-budget-revision-comment{ font-style:italic; }
.pi-budget-btn.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
.pi-budget-btn.primary:hover:not(:disabled){ color:#fff; }
.pi-budget-historique{ margin-top:10px; padding:10px 12px; background:var(--surface-2); border-radius:6px; }
.pi-budget-historique-item{ display:flex; align-items:baseline; gap:10px; font-size:12px; padding:3px 0; }
.pi-budget-historique-item:not(:last-child){ border-bottom:1px solid var(--border); padding-bottom:6px; margin-bottom:3px; }
.pi-budget-historique-montant{ font-family:var(--mono); font-weight:600; color:var(--text); white-space:nowrap; }
.pi-budget-historique-meta{ color:var(--text-muted); }
.pi-budget-form{ margin-top:10px; display:flex; flex-direction:column; gap:8px; max-width:340px; }
.pi-budget-form input{ font-family:var(--sans); font-size:13px; padding:7px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.pi-budget-form input:focus{ outline:none; border-color:var(--accent); }
.pi-budget-form-help{ margin:0; font-size:12px; color:var(--text-muted); }
.pi-budget-form-actions{ display:flex; justify-content:flex-end; gap:8px; }
@media (max-width:700px){
.pi-header{ flex-direction:column; gap:16px; }
.pi-search{ width:auto; flex:1; }
}
`;
+68
View File
@@ -0,0 +1,68 @@
// src/pages/Portal.tsx
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Paper, Stack, Typography, Chip } from '@mui/material';
import ShoppingBagOutlinedIcon from '@mui/icons-material/ShoppingBagOutlined';
import PeopleAltOutlinedIcon from '@mui/icons-material/PeopleAltOutlined';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { tk } from '../theme';
import { Logo } from '../ui';
import { useAuth } from '../auth';
function ModuleCard({ accent, accentSoft, icon, titre, desc, tags, onClick }: {
accent: string; accentSoft: string; icon: ReactNode; titre: string; desc: string; tags: string[]; onClick: () => void;
}) {
return (
<Paper onClick={onClick}
sx={{ flex: 1, p: 3, cursor: 'pointer', transition: 'all .15s', '&:hover': { borderColor: accent, boxShadow: `0 6px 24px ${accent}1a`, transform: 'translateY(-2px)' } }}>
<Box sx={{ width: 44, height: 44, borderRadius: 2, mb: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: accentSoft, color: accent }}>{icon}</Box>
<Typography sx={{ fontWeight: 700, fontSize: 18, mb: 0.75 }}>{titre}</Typography>
<Typography sx={{ color: tk.muted, fontSize: 14, mb: 2, lineHeight: 1.5 }}>{desc}</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mb={2}>
{tags.map((t) => <Chip key={t} label={t} size="small" sx={{ bgcolor: accentSoft, color: accent }} />)}
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: accent, fontWeight: 600, fontSize: 14 }}>
<span>Accéder</span><ArrowForwardIcon sx={{ fontSize: 16 }} />
</Stack>
</Paper>
);
}
export default function Portal() {
const navigate = useNavigate();
const { logout, displayName, roles } = useAuth();
// Le module Sous-Traitants reste réservé aux profils Finance — Collaborateur/Responsable
// n'ont besoin que du module Achats (soumission/validation de leurs propres demandes).
const hasFinanceRole = roles.includes('FINANCE');
const seDeconnecter = () => {
logout();
navigate('/login');
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: tk.canvas, px: 2, py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<Stack direction="row" spacing={1.5} alignItems="center" mb={1.5}>
<Logo size={44} />
<Typography sx={{ fontWeight: 700, fontSize: 22, letterSpacing: '-0.01em' }}>SI Finance</Typography>
</Stack>
<Typography sx={{ color: tk.muted, mb: 4 }}>Bonjour <strong>{displayName ?? 'Camille Laurent'}</strong> — sélectionnez votre module de travail</Typography>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ width: '100%', maxWidth: hasFinanceRole ? 880 : 420 }}>
<ModuleCard accent={tk.primary} accentSoft={tk.primarySoft} icon={<ShoppingBagOutlinedIcon />} titre="Module Achats"
desc="Demandes d'achat, validation, factures fournisseurs, paiements SEPA et pilotage budgétaire."
tags={["Demandes d'achat", 'Factures', 'Paiements', 'Pilotage']} onClick={() => navigate('/')} />
{hasFinanceRole && (
<ModuleCard accent={tk.green} accentSoft={tk.greenSoft} icon={<PeopleAltOutlinedIcon />} titre="Module Sous-Traitants"
desc="Cahiers des charges, contrats prestataires, suivi des prestations, conformité et arbitrage pédagogique."
tags={['Cahier des charges', 'Contrats', 'Conformité', 'Arbitrage']} onClick={() => navigate('/soustraitants')} />
)}
</Stack>
<Typography onClick={seDeconnecter}
sx={{ mt: 4, fontSize: 13, color: tk.muted, cursor: 'pointer', '&:hover': { color: tk.ink } }}>
Se déconnecter
</Typography>
</Box>
);
}
+735
View File
@@ -0,0 +1,735 @@
import { useEffect, useMemo, useState, Fragment } from "react";
import {
achatsApi, financeApi,
type Facture, type Avoir, type Fournisseur, type StatutFacture, type Contrat,
} from "../data";
/*
Mêmes tokens de couleur / structure que ContratsFournisseurs.tsx et Paiements.tsx.
Données réelles via financeApi (SIFinance.Modules.Finance).
*/
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const fmtEur = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const PAGE_SIZE = 8;
function getPageList(current: number, total: number): (number | "…")[] {
const delta = 1;
const range: (number | "…")[] = [];
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) range.push(i);
if (current - delta > 2) range.unshift("…");
if (current + delta < total - 1) range.push("…");
range.unshift(1);
if (total > 1) range.push(total);
return range;
}
function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
if (totalPages <= 1) return null;
const pages = getPageList(page, totalPages);
return (
<div className="fa-pagination">
<button className="fa-page-btn" disabled={page === 1} onClick={() => onChange(Math.max(1, page - 1))}>‹</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="fa-page-ellipsis">…</span>
) : (
<button key={p} className={`fa-page-btn${p === page ? " active" : ""}`} onClick={() => onChange(p)}>{p}</button>
)
)}
<button className="fa-page-btn" disabled={page === totalPages} onClick={() => onChange(Math.min(totalPages, page + 1))}>›</button>
</div>
);
}
type FilterTab = "toutes" | StatutFacture;
export default function FacturesAvoirs() {
const [factures, setFactures] = useState<Facture[]>([]);
const [avoirs, setAvoirs] = useState<Avoir[]>([]);
const [fournisseurs, setFournisseurs] = useState<Fournisseur[]>([]);
const [contrats, setContrats] = useState<Contrat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<FilterTab>("toutes");
const [facturePage, setFacturePage] = useState(1);
const [avoirPage, setAvoirPage] = useState(1);
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const [linkingId, setLinkingId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false);
const [docType, setDocType] = useState<"facture" | "avoir">("facture");
const [numero, setNumero] = useState("");
const [fournisseurId, setFournisseurId] = useState<number | "">("");
const [montant, setMontant] = useState("");
const [dateFacture, setDateFacture] = useState("");
const [dateEcheance, setDateEcheance] = useState("");
const [commentaire, setCommentaire] = useState("");
const [contratId, setContratId] = useState<number | "">("");
const [analyseFacturX, setAnalyseFacturX] = useState<{ loading: boolean; erreur: string | null; conforme: boolean | null }>({ loading: false, erreur: null, conforme: null });
const refresh = async () => {
const [f, a] = await Promise.all([financeApi.getFactures(), financeApi.getAvoirs()]);
setFactures(f);
setAvoirs(a);
};
const contratsDuFournisseur = useMemo(
() => (fournisseurId ? contrats.filter((c) => c.fournisseurId === fournisseurId) : []),
[contrats, fournisseurId]
);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([financeApi.getFactures(), financeApi.getAvoirs(), achatsApi.getFournisseurs(), financeApi.getContrats()])
.then(([f, a, fo, c]) => { if (!cancelled) { setFactures(f); setAvoirs(a); setFournisseurs(fo); setContrats(c); } })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : "Échec du chargement."); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const duplicate = useMemo(() => {
if (docType !== "facture" || !numero || !fournisseurId || !montant) return null;
const m = parseFloat(montant.replace(",", "."));
return factures.find((f) => f.numero === numero && f.fournisseurId === fournisseurId && f.montantTtc === m) ?? null;
}, [docType, numero, fournisseurId, montant, factures]);
// Doublon potentiel "flou" : même fournisseur + même montant à moins de 30 jours
// d'intervalle, quel que soit le numéro (souvent ressaisi différemment d'une facture à
// l'autre) — avertissement distinct du doublon exact ci-dessus, non bloquant.
const doublonProbable = useMemo(() => {
if (docType !== "facture" || !fournisseurId || !montant || !dateFacture) return null;
const m = parseFloat(montant.replace(",", "."));
if (!m) return null;
const jour = 86400000;
const ref = new Date(dateFacture).getTime();
return factures.find((f) => {
if (f.fournisseurId !== fournisseurId || f.montantTtc !== m) return false;
if (f.numero === numero) return false; // déjà couvert par `duplicate`
const ecart = Math.abs(new Date(f.dateFacture).getTime() - ref) / jour;
return ecart <= 30;
}) ?? null;
}, [docType, fournisseurId, montant, dateFacture, numero, factures]);
const changeFilter = (f: FilterTab) => {
setFilter(f);
setFacturePage(1);
};
const toggleOpen = (id: number) => {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const resetForm = () => {
setDocType("facture");
setNumero("");
setFournisseurId("");
setMontant("");
setDateFacture("");
setDateEcheance("");
setCommentaire("");
setContratId("");
setAnalyseFacturX({ loading: false, erreur: null, conforme: null });
};
const importerFacturX = async (file: File) => {
setAnalyseFacturX({ loading: true, erreur: null, conforme: null });
try {
const r = await financeApi.analyserFacturX(file);
if (!r.conforme) {
setAnalyseFacturX({ loading: false, erreur: r.erreur ?? "PDF non conforme.", conforme: false });
return;
}
if (r.numero) setNumero(r.numero);
if (r.montantTtc != null) setMontant(String(r.montantTtc));
if (r.dateFacture) setDateFacture(r.dateFacture);
if (r.dateEcheance) setDateEcheance(r.dateEcheance);
// Rapprochement par nom — le fournisseur doit déjà exister comme référentiel ACTIF
// pour être sélectionnable ; sinon le nom extrait reste affiché à titre indicatif.
const matched = r.fournisseurNom
? fournisseurs.find((f) => f.raisonSociale.toLowerCase() === r.fournisseurNom!.toLowerCase())
: undefined;
if (matched) setFournisseurId(matched.id);
setAnalyseFacturX({ loading: false, erreur: matched ? null : (r.fournisseurNom ? `Fournisseur "${r.fournisseurNom}" non trouvé dans le référentiel — sélectionne-le manuellement.` : null), conforme: true });
} catch (err) {
setAnalyseFacturX({ loading: false, erreur: err instanceof Error ? err.message : "Échec de l'analyse du PDF.", conforme: false });
}
};
const saveDoc = async () => {
const m = parseFloat(montant.replace(",", "."));
if (!numero || !fournisseurId || !m) {
alert("Renseigner au moins le numéro, le fournisseur et le montant.");
return;
}
try {
if (docType === "facture") {
await financeApi.createFacture({ numero, fournisseurId, montant: m, dateFacture, dateEcheance, commentaire: commentaire.trim() || null, contratId: contratId || null });
setFacturePage(1);
} else {
await financeApi.createAvoir({ numero, fournisseurId, montant: m, date: dateFacture });
setAvoirPage(1);
}
await refresh();
resetForm();
setShowForm(false);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de l'enregistrement.");
}
};
const changeStatut = async (id: string | number, statut: StatutFacture) => {
setFactures((prev) => prev.map((f) => (f.id === id ? { ...f, statut } : f)));
try {
await financeApi.changerStatutFacture(Number(id), statut);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec du changement de statut.");
await refresh();
}
};
const updateCommentaire = async (id: number, valeur: string) => {
const v = valeur.trim() || null;
setFactures((prev) => prev.map((f) => (f.id === id ? { ...f, commentaire: v } : f)));
try {
await financeApi.modifierCommentaireFacture(id, v);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de l'enregistrement du commentaire.");
await refresh();
}
};
const leverDoublon = async (id: number) => {
setFactures((prev) => prev.map((f) => (f.id === id ? { ...f, estDoublonSignale: false } : f)));
try {
await financeApi.leverDoublonFacture(id);
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la mise à jour.");
await refresh();
}
};
const deleteFacture = async (id: number) => {
if (!window.confirm("Supprimer cette facture ?")) return;
try {
await financeApi.supprimerFacture(id);
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la suppression.");
}
};
const linkAvoir = async (factureId: number, avoirId: number) => {
try {
await financeApi.lierAvoir(factureId, avoirId);
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la liaison.");
}
setLinkingId(null);
};
const unlinkAvoir = async (factureId: number, avoirId: number) => {
try {
await financeApi.delierAvoir(factureId, avoirId);
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la déliaison.");
}
};
const addEcheance = async (factureId: number) => {
try {
await financeApi.ajouterEcheanceFacture(factureId, { date: new Date().toISOString().slice(0, 10), montant: 0 });
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de l'ajout d'échéance.");
}
};
const updateEcheance = async (factureId: number, echeanceId: number, patch: { date?: string; montant?: number }) => {
const facture = factures.find((f) => f.id === factureId);
const echeance = facture?.echeances.find((e) => e.id === echeanceId);
if (!echeance) return;
const date = patch.date ?? echeance.date;
const montantVal = patch.montant ?? echeance.montant;
setFactures((prev) => prev.map((f) => (f.id !== factureId ? f : { ...f, echeances: f.echeances.map((e) => (e.id === echeanceId ? { ...e, date, montant: montantVal } : e)) })));
try {
await financeApi.modifierEcheanceFacture(factureId, echeanceId, { date, montant: montantVal });
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la mise à jour de l'échéance.");
await refresh();
}
};
const removeEcheance = async (factureId: number, echeanceId: number) => {
try {
await financeApi.supprimerEcheanceFacture(factureId, echeanceId);
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Échec de la suppression de l'échéance.");
}
};
const openForm = (type: "facture" | "avoir") => {
resetForm();
setDocType(type);
setShowForm(true);
};
const visibleFactures = useMemo(() => {
if (filter === "toutes") return factures;
return factures.filter((f) => f.statut === filter);
}, [factures, filter]);
const totalFacturePages = Math.max(1, Math.ceil(visibleFactures.length / PAGE_SIZE));
const pageFactures = visibleFactures.slice((facturePage - 1) * PAGE_SIZE, facturePage * PAGE_SIZE);
const totalAvoirPages = Math.max(1, Math.ceil(avoirs.length / PAGE_SIZE));
const pageAvoirs = avoirs.slice((avoirPage - 1) * PAGE_SIZE, avoirPage * PAGE_SIZE);
const counts = {
toutes: factures.length,
attente: factures.filter((f) => f.statut === "attente").length,
validee: factures.filter((f) => f.statut === "validee").length,
litige: factures.filter((f) => f.statut === "litige").length,
payee: factures.filter((f) => f.statut === "payee").length,
};
const formContent = (
<>
<p className="fa-panel-title">{docType === "facture" ? "Enregistrer une facture" : "Enregistrer un avoir"}</p>
{docType === "facture" && (
<div className="fa-facturx-box">
<label className="fa-facturx-label">
📄 Importer une facture électronique (Factur-X/ZUGFeRD)
<input
type="file" accept=".pdf" style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) importerFacturX(f); }}
/>
</label>
{analyseFacturX.loading && <p className="fa-facturx-info">Analyse du PDF…</p>}
{analyseFacturX.conforme === true && !analyseFacturX.erreur && (
<p className="fa-facturx-ok">✓ Facture électronique conforme détectée — champs pré-remplis.</p>
)}
{analyseFacturX.erreur && <p className="fa-facturx-warn">⚠ {analyseFacturX.erreur}</p>}
</div>
)}
<div className="fa-form-grid">
<div className="fa-field">
<label>Numéro</label>
<input value={numero} onChange={(e) => setNumero(e.target.value)} placeholder={docType === "facture" ? "FA-2026-0913" : "AV-2026-0052"} />
</div>
<div className="fa-field">
<label>Fournisseur</label>
<select value={fournisseurId} onChange={(e) => { setFournisseurId(e.target.value ? Number(e.target.value) : ""); setContratId(""); }}>
<option value="">Sélectionner…</option>
{fournisseurs.map((f) => <option key={f.id} value={f.id}>{f.raisonSociale}</option>)}
</select>
</div>
<div className="fa-field">
<label>Montant TTC (EUR)</label>
<input value={montant} onChange={(e) => setMontant(e.target.value)} placeholder="1 200" />
</div>
<div className="fa-field">
<label>{docType === "facture" ? "Date de facture" : "Date de l'avoir"}</label>
<input type="date" value={dateFacture} onChange={(e) => setDateFacture(e.target.value)} />
</div>
{docType === "facture" && (
<div className="fa-field">
<label>Date d'échéance</label>
<input type="date" value={dateEcheance} onChange={(e) => setDateEcheance(e.target.value)} />
</div>
)}
{docType === "facture" && contratsDuFournisseur.length > 0 && (
<div className="fa-field">
<label>Contrat lié (optionnel)</label>
<select value={contratId} onChange={(e) => setContratId(e.target.value ? Number(e.target.value) : "")}>
<option value="">Aucun</option>
{contratsDuFournisseur.map((c) => <option key={c.id} value={c.id}>{c.reference ? `${c.reference} — ${c.objet}` : c.objet}</option>)}
</select>
</div>
)}
<div className="fa-field" style={{ gridColumn: "1 / -1" }}>
<label>Commentaire (optionnel)</label>
<input value={commentaire} onChange={(e) => setCommentaire(e.target.value)} placeholder="Précision utile pour la validation ou le suivi…" />
</div>
</div>
{duplicate && (
<div className="fa-duplicate-warning">
⚠ Une facture {duplicate.numero} · {duplicate.fournisseur} · {fmtEur(duplicate.montantTtc)} existe déjà (enregistrée le {fmtDate(duplicate.dateFacture)}).
</div>
)}
{!duplicate && doublonProbable && (
<div className="fa-duplicate-warning">
⚠ Doublon possible : {doublonProbable.fournisseur} · {fmtEur(doublonProbable.montantTtc)} — facture {doublonProbable.numero} déjà enregistrée à moins de 30 jours ({fmtDate(doublonProbable.dateFacture)}). Vérifiez qu'il ne s'agit pas de la même facture avant d'enregistrer.
</div>
)}
<div className="fa-form-actions">
<button className="fa-btn-ghost" onClick={() => { resetForm(); setShowForm(false); }}>Annuler</button>
<button className="fa-btn-primary" onClick={saveDoc}>Enregistrer</button>
</div>
</>
);
return (
<div className="fa-root">
<style>{css}</style>
<div className="fa-wrap">
<header className="fa-header">
<div>
<p className="fa-eyebrow">Achats · facturation</p>
<h1 className="fa-h1">Factures &amp; Avoirs</h1>
</div>
</header>
{error && <div className="fa-duplicate-warning" style={{ marginBottom: 16 }}>{error}</div>}
<section className="fa-section">
<div className="fa-section-head">
<h2 className="fa-h2">Factures</h2>
<button className="fa-btn-primary" onClick={() => openForm("facture")}>+ Nouvelle facture</button>
</div>
{showForm && docType === "facture" && (
<div className="fa-panel" style={{ marginBottom: 16 }}>
{formContent}
</div>
)}
<div className="fa-table-wrap">
<div className="fa-table-head">
<div className="fa-filter-tabs">
<button className={filter === "toutes" ? "active" : ""} onClick={() => changeFilter("toutes")}>Toutes {counts.toutes}</button>
<button className={filter === "attente" ? "active" : ""} onClick={() => changeFilter("attente")}>En attente {counts.attente}</button>
<button className={filter === "validee" ? "active" : ""} onClick={() => changeFilter("validee")}>Validées {counts.validee}</button>
<button className={filter === "litige" ? "active" : ""} onClick={() => changeFilter("litige")}>Litige {counts.litige}</button>
<button className={filter === "payee" ? "active" : ""} onClick={() => changeFilter("payee")}>Payées {counts.payee}</button>
</div>
</div>
<table className="fa-table">
<thead>
<tr>
<th style={{ width: 24 }}></th>
<th>Numéro</th>
<th>Fournisseur</th>
<th>Montant</th>
<th>Échéance</th>
<th>Statut</th>
<th>Avoir lié</th>
<th style={{ width: 150 }}>Actions</th>
</tr>
</thead>
<tbody>
{pageFactures.map((f) => {
const isOpen = openIds.has(f.id);
const avoirsDispo = avoirs.filter((a) => a.fournisseurId === f.fournisseurId && a.soldeDisponible > 0);
const sommeEch = f.echeances.reduce((s, e) => s + e.montant, 0);
return (
<Fragment key={f.id}>
<tr className="fa-row" onClick={() => toggleOpen(f.id)}>
<td><span className={`fa-chevron${isOpen ? " open" : ""}`}>›</span></td>
<td className="fa-mono-cell">
{f.numero}
{f.estDoublonSignale && <span className="fa-doublon-badge" title="Doublon potentiel : même fournisseur et même montant à moins de 30 jours">⚠ doublon ?</span>}
</td>
<td>{f.fournisseur}</td>
<td className="fa-amount">{fmtEur(f.montantTtc)}</td>
<td>{fmtDate(f.dateEcheance)}</td>
<td>
<select
className="fa-status-select"
data-statut={f.statut}
value={f.statut}
onClick={(e) => e.stopPropagation()}
onChange={(e) => changeStatut(f.id, e.target.value as StatutFacture)}
>
<option value="attente">En attente</option>
<option value="validee">Validée</option>
<option value="litige">Litige</option>
<option value="payee">Payée</option>
</select>
</td>
<td onClick={(e) => e.stopPropagation()}>
{f.avoirLieId ? (
<span className="fa-avoir-tag">
{f.avoirLieNumero} (−{fmtEur(f.avoirLieMontant ?? 0)})
<button onClick={() => unlinkAvoir(f.id, f.avoirLieId!)}>×</button>
</span>
) : linkingId === f.id ? (
<select
className="fa-inline-select"
defaultValue=""
onChange={(e) => e.target.value && linkAvoir(f.id, Number(e.target.value))}
>
<option value="" disabled>Choisir un avoir…</option>
{avoirsDispo.map((a) => (
<option key={a.id} value={a.id}>{a.numero} (solde {fmtEur(a.soldeDisponible)})</option>
))}
</select>
) : avoirsDispo.length > 0 ? (
<button className="fa-btn-small" onClick={() => setLinkingId(f.id)}>Lier un avoir</button>
) : (
<span className="fa-muted-inline">—</span>
)}
</td>
<td onClick={(e) => e.stopPropagation()}>
<button className="fa-btn-small fa-btn-danger" onClick={() => deleteFacture(f.id)}>Supprimer</button>
</td>
</tr>
{isOpen && (
<tr className="fa-detail-row">
<td colSpan={8}>
<div className="fa-detail">
<div className="fa-detail-cols">
<div>
<p className="fa-detail-label">Rattachement</p>
<p className="fa-detail-value">{f.demandeAchatId ? `Demande #${f.demandeAchatId}` : "Non renseigné"}</p>
</div>
<div>
<p className="fa-detail-label">Date de facture</p>
<p className="fa-detail-value">{fmtDate(f.dateFacture)}</p>
</div>
<div>
<p className="fa-detail-label">Contrat lié</p>
<p className="fa-detail-value">{f.contratObjet ?? "Aucun"}</p>
</div>
</div>
<p className="fa-detail-label" style={{ marginTop: 14 }}>Justificatifs</p>
{f.justificatifs?.length ? (
<div className="fa-justif-list">
{f.justificatifs.map((j) => <a key={j.url} href={j.url} target="_blank" rel="noreferrer" className="fa-justif-link">📄 {j.nom}</a>)}
</div>
) : <p className="fa-muted-inline">Aucun justificatif joint.</p>}
{f.estDoublonSignale && (
<div className="fa-duplicate-warning" style={{ marginTop: 14, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<span>⚠ Doublon potentiel signalé automatiquement (même fournisseur, même montant, à moins de 30 jours d'une autre facture).</span>
<button className="fa-btn-small" onClick={() => leverDoublon(f.id)}>Ce n'est pas un doublon</button>
</div>
)}
<p className="fa-detail-label" style={{ marginTop: 14 }}>Commentaire</p>
<input
className="fa-comment-input"
defaultValue={f.commentaire ?? ""}
placeholder="Aucun commentaire — cliquez pour en ajouter un…"
onBlur={(e) => { if (e.target.value.trim() !== (f.commentaire ?? "")) updateCommentaire(f.id, e.target.value); }}
/>
<p className="fa-detail-label" style={{ marginTop: 14 }}>
Échéancier{" "}
{sommeEch !== f.montantTtc && (
<span className="fa-echeance-warning">· total {fmtEur(sommeEch)} ≠ montant facture {fmtEur(f.montantTtc)}</span>
)}
</p>
<table className="fa-table fa-subtable">
<thead>
<tr><th>Date</th><th>Montant</th><th style={{ width: 30 }}></th></tr>
</thead>
<tbody>
{f.echeances.map((e) => (
<tr key={e.id}>
<td><input type="date" defaultValue={e.date} onBlur={(ev) => updateEcheance(f.id, e.id, { date: ev.target.value })} /></td>
<td><input defaultValue={e.montant} onBlur={(ev) => updateEcheance(f.id, e.id, { montant: parseFloat(ev.target.value) || 0 })} /></td>
<td><button className="fa-btn-small fa-btn-danger" onClick={() => removeEcheance(f.id, e.id)}>×</button></td>
</tr>
))}
</tbody>
</table>
<button className="fa-btn-small" onClick={() => addEcheance(f.id)}>+ Ajouter une échéance</button>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
{!loading && pageFactures.length === 0 && (
<tr><td colSpan={8} className="fa-empty">Aucune facture dans cette vue.</td></tr>
)}
{loading && (
<tr><td colSpan={8} className="fa-empty">Chargement…</td></tr>
)}
</tbody>
</table>
</div>
<Pagination page={facturePage} totalPages={totalFacturePages} onChange={setFacturePage} />
</section>
<section className="fa-section">
<div className="fa-section-head">
<h2 className="fa-h2">Avoirs</h2>
<button className="fa-btn-primary" onClick={() => openForm("avoir")}>+ Nouvel avoir</button>
</div>
{showForm && docType === "avoir" && (
<div className="fa-panel" style={{ marginBottom: 16 }}>
{formContent}
</div>
)}
<div className="fa-table-wrap">
<table className="fa-table">
<thead>
<tr>
<th>Numéro</th><th>Fournisseur</th><th>Montant</th><th>Date</th><th>Statut</th>
</tr>
</thead>
<tbody>
{pageAvoirs.map((a) => (
<tr key={a.id}>
<td className="fa-mono-cell">{a.numero}</td>
<td>{a.fournisseur}</td>
<td className="fa-amount">{fmtEur(a.montant)}</td>
<td>{fmtDate(a.date)}</td>
<td>
{a.factureLieeId ? (
<span className="fa-avoir-tag">Affecté · {a.factureLieeNumero}</span>
) : (
<span className="fa-status-select" data-statut="attente" style={{ display: "inline-block" }}>Disponible</span>
)}
</td>
</tr>
))}
{pageAvoirs.length === 0 && (
<tr><td colSpan={5} className="fa-empty">Aucun avoir enregistré.</td></tr>
)}
</tbody>
</table>
</div>
<Pagination page={avoirPage} totalPages={totalAvoirPages} onChange={setAvoirPage} />
</section>
</div>
</div>
);
}
const css = `
.fa-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#2E4A6B; --accent-light:#E7EDF3;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.fa-root *{ box-sizing:border-box; }
.fa-wrap{ width:100%; padding:28px 32px 64px; }
.fa-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.fa-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.fa-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.fa-section{ margin-bottom:32px; }
.fa-section-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
.fa-h2{ font-size:15px; font-weight:600; margin:0; }
.fa-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.fa-btn-primary:hover{ background:#25405C; }
.fa-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.fa-btn-ghost:hover{ border-color:var(--accent); }
.fa-btn-small{ font-family:var(--sans); font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; white-space:nowrap; }
.fa-btn-small:hover{ border-color:var(--accent); color:var(--accent); }
.fa-btn-danger{ border:none; color:var(--danger); background:transparent; }
.fa-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.fa-panel-title{ font-size:15px; font-weight:600; margin:0 0 16px; }
.fa-muted-inline{ text-transform:none; color:var(--text-muted); font-size:12px; font-weight:400; }
.fa-type-toggle{ display:flex; gap:6px; margin-bottom:16px; }
.fa-type-toggle button{ font-family:var(--sans); font-size:13px; padding:6px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.fa-type-toggle button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.fa-facturx-box{ background:var(--surface-2); border:1px dashed var(--border); border-radius:var(--radius); padding:14px 16px; margin-bottom:16px; }
.fa-facturx-label{ font-family:var(--sans); font-size:13px; font-weight:600; color:var(--accent); cursor:pointer; }
.fa-facturx-info{ font-size:12.5px; color:var(--text-2); margin:8px 0 0; }
.fa-facturx-ok{ font-size:12.5px; color:var(--success); margin:8px 0 0; }
.fa-facturx-warn{ font-size:12.5px; color:var(--warning); margin:8px 0 0; }
.fa-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; }
.fa-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.fa-field input, .fa-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.fa-field input:focus, .fa-field select:focus{ outline:none; border-color:var(--accent); }
.fa-duplicate-warning{ margin-top:14px; background:var(--danger-light); color:var(--danger); border-radius:6px; padding:10px 14px; font-size:13px; }
.fa-doublon-badge{ display:inline-block; margin-left:8px; font-family:var(--sans); font-size:11px; font-weight:600; color:var(--danger); background:var(--danger-light); border-radius:10px; padding:2px 8px; white-space:nowrap; }
.fa-comment-input{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.fa-comment-input:focus{ outline:none; border-color:var(--accent); }
.fa-form-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }
.fa-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.fa-table-head{ padding:14px 16px; border-bottom:1px solid var(--border); }
.fa-filter-tabs{ display:flex; gap:6px; flex-wrap:wrap; }
.fa-filter-tabs button{ font-family:var(--sans); font-size:12px; padding:6px 12px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.fa-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.fa-table{ width:100%; border-collapse:collapse; font-size:13px; }
.fa-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:8px 10px; border-bottom:1px solid var(--border); }
.fa-table td{ padding:9px 10px; border-bottom:1px solid var(--border); }
.fa-row{ cursor:pointer; }
.fa-row:hover{ background:var(--surface-2); }
.fa-amount{ font-family:var(--mono); }
.fa-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.fa-empty{ text-align:center; color:var(--text-muted); padding:24px !important; }
.fa-chevron{ display:inline-block; transition:transform .15s ease; color:var(--text-muted); }
.fa-chevron.open{ transform:rotate(90deg); }
.fa-status-select{ font-family:var(--sans); font-size:12px; padding:4px 8px; border-radius:20px; border:1px solid transparent; cursor:pointer; background:var(--surface-2); color:var(--text-2); }
.fa-status-select[data-statut="attente"]{ background:var(--surface-2); color:var(--text-2); }
.fa-status-select[data-statut="validee"]{ background:var(--accent-light); color:var(--accent); }
.fa-status-select[data-statut="litige"]{ background:var(--danger-light); color:var(--danger); }
.fa-status-select[data-statut="payee"]{ background:var(--success-light); color:var(--success); }
.fa-avoir-tag{ display:inline-flex; align-items:center; gap:6px; font-size:12px; background:var(--success-light); color:var(--success); border-radius:20px; padding:3px 6px 3px 10px; }
.fa-avoir-tag button{ border:none; background:transparent; color:var(--success); cursor:pointer; font-size:13px; line-height:1; padding:0 2px; }
.fa-inline-select{ font-family:var(--sans); font-size:12px; padding:4px 8px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.fa-detail-row td{ padding:0; border-bottom:1px solid var(--border); background:var(--surface-2); }
.fa-detail{ padding:16px 20px 18px 46px; }
.fa-detail-cols{ display:grid; grid-template-columns:1fr 1fr; gap:14px; }
.fa-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 4px; }
.fa-detail-value{ font-size:13.5px; color:var(--text); margin:0; }
.fa-justif-list{ display:flex; flex-wrap:wrap; gap:8px; }
.fa-justif-link{ font-size:13px; color:var(--accent); text-decoration:none; }
.fa-justif-link:hover{ text-decoration:underline; }
.fa-echeance-warning{ text-transform:none; color:var(--danger); letter-spacing:0; }
.fa-subtable{ background:var(--surface); border-radius:6px; overflow:hidden; margin-bottom:8px; }
.fa-subtable th, .fa-subtable td{ padding:6px 10px; }
.fa-subtable input{ width:100%; font-family:var(--mono); font-size:12.5px; padding:4px 6px; border:1px solid var(--border); border-radius:4px; }
.fa-subtable input[type="date"]{ font-family:var(--sans); }
.fa-pagination{ display:flex; align-items:center; justify-content:center; gap:6px; padding:18px 0 4px; }
.fa-page-btn{ min-width:32px; height:32px; padding:0 8px; border-radius:7px; font-family:var(--sans); font-size:13px; font-weight:500; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; }
.fa-page-btn:hover:not(:disabled){ border-color:var(--accent); color:var(--accent); }
.fa-page-btn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.fa-page-btn:disabled{ opacity:0.4; cursor:default; }
.fa-page-ellipsis{ width:24px; text-align:center; color:var(--text-muted); font-size:13px; }
@media (max-width:900px){
.fa-form-grid{ grid-template-columns:1fr; }
.fa-detail-cols{ grid-template-columns:1fr; }
}
`;
@@ -0,0 +1,183 @@
import { Fragment, useMemo, useState } from "react";
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Décision du responsable pédagogique sur un écart d'heures signalé par le
Contrôle conformité : Valider (l'écart est accepté, la facture peut être payée
telle quelle) ou Rejeter (l'écart n'est pas retenu, la facture doit être corrigée).
*/
type StatutArbitrage = "en_attente" | "valide" | "rejete";
interface DossierArbitrage {
id: number;
reference: string;
organisme: string;
factureNumero: string;
ecartHeures: number;
motif: string;
statut: StatutArbitrage;
}
const STATUT_LABEL: Record<StatutArbitrage, string> = { en_attente: "En attente", valide: "Validé", rejete: "Rejeté" };
const INITIAL: DossierArbitrage[] = [
{ id: 1, reference: "ARB-2026-001", organisme: "FormaPro SAS", factureNumero: "FAC-ST-2026-001", ecartHeures: 2, motif: "2h supplémentaires facturées pour rattraper un retard de groupe (Excel avancé). Décision du responsable pédagogique requise.", statut: "en_attente" },
{ id: 2, reference: "ARB-2026-002", organisme: "LinguaLearn", factureNumero: "FAC-ST-2026-002", ecartHeures: -2, motif: "Séance de clôture raccourcie suite à 2 absences stagiaires.", statut: "en_attente" },
{ id: 3, reference: "ARB-2025-014", organisme: "Académie Ldsh.", factureNumero: "FAC-ST-2025-041", ecartHeures: 1, motif: "1h de dépassement pour Q&R finale.", statut: "valide" },
];
export default function Arbitrage() {
const [items, setItems] = useState<DossierArbitrage[]>(INITIAL);
const [filter, setFilter] = useState<"TOUTES" | StatutArbitrage>("TOUTES");
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const counts = useMemo(() => {
const base: Record<string, number> = { TOUTES: items.length };
(["en_attente", "valide", "rejete"] as StatutArbitrage[]).forEach((s) => (base[s] = items.filter((i) => i.statut === s).length));
return base;
}, [items]);
const filtered = useMemo(() => (filter === "TOUTES" ? items : items.filter((i) => i.statut === filter)), [items, filter]);
const toggleOpen = (id: number) => {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const trancher = (id: number, statut: StatutArbitrage) => setItems((prev) => prev.map((d) => (d.id === id ? { ...d, statut } : d)));
return (
<div className="arb-root">
<style>{css}</style>
<div className="arb-wrap">
<header className="arb-header">
<div>
<p className="arb-eyebrow">Sous-traitants · arbitrage</p>
<h1 className="arb-h1">Arbitrage</h1>
<p className="arb-sub">Décisions sur les écarts d'heures signalés</p>
</div>
</header>
<div className="arb-filter-tabs">
{(["TOUTES", "en_attente", "valide", "rejete"] as const).map((s) => (
<button key={s} className={filter === s ? "active" : ""} onClick={() => setFilter(s)}>
{s === "TOUTES" ? "Tous" : STATUT_LABEL[s]} <span className="arb-tab-count">{counts[s] ?? 0}</span>
</button>
))}
</div>
<div className="arb-table-wrap">
<table className="arb-table">
<thead>
<tr>
<th style={{ width: 24 }}></th>
<th>Réf</th><th>Organisme</th><th>Facture</th>
<th style={{ textAlign: "right" }}>Écart</th><th>Statut</th><th style={{ width: 220 }}>Décision</th>
</tr>
</thead>
<tbody>
{filtered.map((d) => {
const isOpen = openIds.has(d.id);
return (
<Fragment key={d.id}>
<tr className="arb-row" onClick={() => toggleOpen(d.id)}>
<td><span className={`arb-chevron${isOpen ? " open" : ""}`}>›</span></td>
<td className="arb-mono-cell">{d.reference}</td>
<td>{d.organisme}</td>
<td className="arb-mono-cell">{d.factureNumero}</td>
<td className="arb-amount">{d.ecartHeures > 0 ? "+" : ""}{d.ecartHeures}h</td>
<td><span className="arb-status-pill" data-statut={d.statut}>{STATUT_LABEL[d.statut]}</span></td>
<td onClick={(e) => e.stopPropagation()}>
{d.statut === "en_attente" ? (
<div className="arb-actions">
<button className="arb-btn-danger" onClick={() => trancher(d.id, "rejete")}>Rejeter</button>
<button className="arb-btn-success" onClick={() => trancher(d.id, "valide")}>Valider</button>
</div>
) : (
<span className="arb-muted-inline">—</span>
)}
</td>
</tr>
{isOpen && (
<tr className="arb-detail-row">
<td colSpan={7}>
<div className="arb-detail">
<p className="arb-detail-label">Motif</p>
<p className="arb-detail-value">{d.motif}</p>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
{filtered.length === 0 && (
<tr><td colSpan={7} className="arb-empty">Aucun dossier dans cette catégorie.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const css = `
.arb-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.arb-root *{ box-sizing:border-box; }
.arb-wrap{ max-width:1180px; margin:0 auto; }
.arb-header{ margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.arb-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.arb-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.arb-sub{ font-size:13px; color:var(--text-muted); margin:6px 0 0; }
.arb-filter-tabs{ display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.arb-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.arb-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.arb-tab-count{ font-size:11px; opacity:0.75; }
.arb-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.arb-table{ width:100%; border-collapse:collapse; font-size:13px; }
.arb-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.arb-table td{ padding:10px 16px; border-bottom:1px solid var(--border); }
.arb-table tbody tr:last-child td{ border-bottom:none; }
.arb-row{ cursor:pointer; }
.arb-row:hover{ background:var(--surface-2); }
.arb-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.arb-amount{ font-family:var(--mono); text-align:right; }
.arb-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.arb-muted-inline{ color:var(--text-muted); font-size:12px; }
.arb-chevron{ display:inline-block; transition:transform .15s ease; color:var(--text-muted); }
.arb-chevron.open{ transform:rotate(90deg); }
.arb-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; }
.arb-status-pill[data-statut="en_attente"]{ background:var(--warning-light); color:var(--warning); }
.arb-status-pill[data-statut="valide"]{ background:var(--success-light); color:var(--success); }
.arb-status-pill[data-statut="rejete"]{ background:var(--danger-light); color:var(--danger); }
.arb-actions{ display:flex; gap:6px; }
.arb-btn-success{ font-family:var(--sans); font-size:12px; font-weight:600; padding:6px 10px; border-radius:6px; border:1px solid var(--success); background:var(--surface); color:var(--success); cursor:pointer; white-space:nowrap; }
.arb-btn-success:hover{ background:var(--success-light); }
.arb-btn-danger{ font-family:var(--sans); font-size:12px; font-weight:600; padding:6px 10px; border-radius:6px; border:1px solid var(--danger); background:var(--surface); color:var(--danger); cursor:pointer; white-space:nowrap; }
.arb-btn-danger:hover{ background:var(--danger-light); }
.arb-detail-row td{ padding:0; border-bottom:1px solid var(--border); background:var(--surface-2); }
.arb-detail{ padding:14px 20px 16px 46px; }
.arb-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 4px; }
.arb-detail-value{ font-size:13.5px; color:var(--text); margin:0; }
`;
@@ -0,0 +1,143 @@
import { useMemo, useState } from "react";
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Vue de consultation en lecture seule des dossiers clos (CDC archivés, contrats
expirés, factures payées).
*/
type TypeArchive = "CDC" | "CONTRAT" | "FACTURE";
interface ArchiveItem {
id: number;
type: TypeArchive;
reference: string;
intitule: string;
organisme: string;
dateCloture: string;
}
const TYPE_LABEL: Record<TypeArchive, string> = { CDC: "Cahier des charges", CONTRAT: "Contrat", FACTURE: "Facture" };
const INITIAL: ArchiveItem[] = [
{ id: 1, type: "CDC", reference: "CDC-2025-018", intitule: "Communication interne", organisme: "LinguaLearn", dateCloture: "2025-12-20" },
{ id: 2, type: "CONTRAT", reference: "ST-CONT-2025-014", intitule: "Bureautique — Excel avancé", organisme: "FormaPro", dateCloture: "2025-12-31" },
{ id: 3, type: "FACTURE", reference: "FAC-ST-2026-004", intitule: "Prestations juin 2026", organisme: "FormaPro", dateCloture: "2026-07-22" },
];
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
export default function ArchivesST() {
const [filter, setFilter] = useState<"TOUS" | TypeArchive>("TOUS");
const [query, setQuery] = useState("");
const counts = useMemo(() => {
const base: Record<string, number> = { TOUS: INITIAL.length };
(["CDC", "CONTRAT", "FACTURE"] as TypeArchive[]).forEach((t) => (base[t] = INITIAL.filter((i) => i.type === t).length));
return base;
}, []);
const filtered = useMemo(() => {
return INITIAL.filter((i) => {
const matchType = filter === "TOUS" || i.type === filter;
const matchQuery = query.trim() === "" || i.intitule.toLowerCase().includes(query.toLowerCase()) || i.reference.toLowerCase().includes(query.toLowerCase());
return matchType && matchQuery;
});
}, [filter, query]);
return (
<div className="arv-root">
<style>{css}</style>
<div className="arv-wrap">
<header className="arv-header">
<div>
<p className="arv-eyebrow">Sous-traitants · documents</p>
<h1 className="arv-h1">Archives</h1>
</div>
</header>
<div className="arv-toolbar">
<div className="arv-search">
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Rechercher..." />
</div>
<div className="arv-filter-tabs">
{(["TOUS", "CDC", "CONTRAT", "FACTURE"] as const).map((t) => (
<button key={t} className={filter === t ? "active" : ""} onClick={() => setFilter(t)}>
{t === "TOUS" ? "Tous" : TYPE_LABEL[t]} <span className="arv-tab-count">{counts[t] ?? 0}</span>
</button>
))}
</div>
</div>
<div className="arv-table-wrap">
<table className="arv-table">
<thead>
<tr>
<th>Type</th><th>Réf</th><th>Intitulé</th><th>Organisme</th><th style={{ textAlign: "right" }}>Clôturé le</th>
</tr>
</thead>
<tbody>
{filtered.map((i) => (
<tr key={i.id}>
<td><span className="arv-type-pill" data-type={i.type}>{TYPE_LABEL[i.type]}</span></td>
<td className="arv-mono-cell">{i.reference}</td>
<td className="arv-objet-cell">{i.intitule}</td>
<td>{i.organisme}</td>
<td style={{ textAlign: "right" }}>{fmtDate(i.dateCloture)}</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={5} className="arv-empty">Aucune archive ne correspond à ces filtres.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const css = `
.arv-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--info:#1D4ED8; --info-light:#E8EEFC;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.arv-root *{ box-sizing:border-box; }
.arv-wrap{ max-width:1180px; margin:0 auto; }
.arv-header{ margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.arv-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.arv-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.arv-toolbar{ display:flex; align-items:center; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
.arv-search input{ width:220px; font-family:var(--sans); font-size:13px; padding:8px 12px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); }
.arv-search input:focus{ outline:none; border-color:var(--accent); }
.arv-filter-tabs{ display:flex; gap:6px; flex-wrap:wrap; }
.arv-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.arv-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.arv-tab-count{ font-size:11px; opacity:0.75; }
.arv-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.arv-table{ width:100%; border-collapse:collapse; font-size:13px; }
.arv-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.arv-table td{ padding:12px 16px; border-bottom:1px solid var(--border); }
.arv-table tbody tr:last-child td{ border-bottom:none; }
.arv-objet-cell{ font-weight:500; }
.arv-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.arv-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.arv-type-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; }
.arv-type-pill[data-type="CDC"]{ background:var(--accent-light); color:var(--accent); }
.arv-type-pill[data-type="CONTRAT"]{ background:var(--warning-light); color:var(--warning); }
.arv-type-pill[data-type="FACTURE"]{ background:var(--info-light); color:var(--info); }
`;
@@ -0,0 +1,247 @@
import { useMemo, useState } from "react";
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Les cahiers des charges sont des documents REÇUS d'un autre service (pédagogie) —
ce module ne fait que les consulter, pas les créer/éditer. Pas d'upload réel tant
que SharePoint n'est pas branché (cf. Contrats/Factures du Module Achats) : on ouvre
un panneau de consultation avec les métadonnées, l'ouverture réelle du fichier viendra
avec l'intégration SharePoint.
*/
type StatutLecture = "non_lu" | "lu";
interface DocumentCdc {
id: number;
reference: string;
nomFichier: string;
serviceEmetteur: string;
formateur: string;
organisme: string;
groupe: string;
dateReception: string;
statutLecture: StatutLecture;
}
// Un cahier des charges par formateur (mêmes intervenants que Prestations.tsx pour rester cohérent).
const INITIAL: DocumentCdc[] = [
{ id: 1, reference: "CDC-2026-001", nomFichier: "CDC_Dupont_Marketing_GroupeA.pdf", serviceEmetteur: "Pédagogie", formateur: "M. Dupont", organisme: "FormaPro", groupe: "Groupe A", dateReception: "2026-05-02", statutLecture: "lu" },
{ id: 2, reference: "CDC-2026-002", nomFichier: "CDC_Martin_VenteDistribution_GroupeB.pdf", serviceEmetteur: "Pédagogie", formateur: "Mme Martin", organisme: "FormaPro", groupe: "Groupe B", dateReception: "2026-05-10", statutLecture: "lu" },
{ id: 3, reference: "CDC-2026-003", nomFichier: "CDC_Smith_StrategieCommerciale.pdf", serviceEmetteur: "Direction commerciale", formateur: "Mr. Smith", organisme: "LinguaLearn", groupe: "Groupe Strat1", dateReception: "2026-05-14", statutLecture: "lu" },
{ id: 4, reference: "CDC-2026-004", nomFichier: "CDC_Lefevre_NegociationCommerciale.pdf", serviceEmetteur: "Direction commerciale", formateur: "Mme Lefèvre", organisme: "Académie Ldsh.", groupe: "Groupe Négo", dateReception: "2026-05-20", statutLecture: "non_lu" },
{ id: 5, reference: "CDC-2026-005", nomFichier: "CDC_Dupont_ExcelAvance.pdf", serviceEmetteur: "Pédagogie", formateur: "M. Dupont", organisme: "FormaPro", groupe: "Groupe C", dateReception: "2026-07-01", statutLecture: "non_lu" },
];
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
function DocumentDrawer({ doc, onClose, onLu }: { doc: DocumentCdc; onClose: () => void; onLu: (id: number) => void }) {
return (
<div className="cdc-drawer-overlay" onClick={onClose}>
<div className="cdc-drawer" onClick={(e) => e.stopPropagation()}>
<div className="cdc-drawer-header">
<div>
<p className="cdc-eyebrow" style={{ margin: "0 0 4px" }}>{doc.reference}</p>
<h2 className="cdc-drawer-title">{doc.nomFichier}</h2>
</div>
<button className="cdc-drawer-close" onClick={onClose} aria-label="Fermer">×</button>
</div>
<div className="cdc-drawer-grid">
<div>
<p className="cdc-detail-label">Formateur</p>
<p className="cdc-detail-value">{doc.formateur}</p>
</div>
<div>
<p className="cdc-detail-label">Service émetteur</p>
<p className="cdc-detail-value">{doc.serviceEmetteur}</p>
</div>
<div>
<p className="cdc-detail-label">Organisme</p>
<p className="cdc-detail-value">{doc.organisme}</p>
</div>
<div>
<p className="cdc-detail-label">Groupe</p>
<p className="cdc-detail-value">{doc.groupe}</p>
</div>
<div>
<p className="cdc-detail-label">Reçu le</p>
<p className="cdc-detail-value">{fmtDate(doc.dateReception)}</p>
</div>
</div>
<div className="cdc-viewer-placeholder">
<p>Aperçu du document indisponible pour l'instant.</p>
<p className="cdc-viewer-note">L'ouverture directe depuis SharePoint sera disponible une fois l'intégration branchée.</p>
</div>
<button className="cdc-btn-primary" style={{ width: "100%" }} onClick={() => onLu(doc.id)}>
{doc.statutLecture === "lu" ? "Marquer comme non lu" : "Marquer comme lu"}
</button>
</div>
</div>
);
}
export default function CahierDesCharges() {
const [items, setItems] = useState<DocumentCdc[]>(INITIAL);
const [filter, setFilter] = useState<"TOUS" | StatutLecture>("TOUS");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
const counts = useMemo(() => {
const base: Record<string, number> = { TOUS: items.length };
(["non_lu", "lu"] as StatutLecture[]).forEach((s) => (base[s] = items.filter((i) => i.statutLecture === s).length));
return base;
}, [items]);
const filtered = useMemo(() => {
return items.filter((i) => {
const matchStatut = filter === "TOUS" || i.statutLecture === filter;
const matchQuery = query.trim() === "" || i.nomFichier.toLowerCase().includes(query.toLowerCase()) || i.reference.toLowerCase().includes(query.toLowerCase()) || i.formateur.toLowerCase().includes(query.toLowerCase());
return matchStatut && matchQuery;
});
}, [items, filter, query]);
const selected = items.find((i) => i.id === selectedId) ?? null;
const toggleLu = (id: number) => {
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, statutLecture: i.statutLecture === "lu" ? "non_lu" : "lu" } : i)));
};
return (
<div className="cdc-root">
<style>{css}</style>
<div className="cdc-wrap">
<header className="cdc-header">
<div>
<p className="cdc-eyebrow">Sous-traitants · pédagogie</p>
<h1 className="cdc-h1">Cahiers des charges</h1>
<p className="cdc-sub">Documents reçus du service pédagogie / direction commerciale — consultation uniquement</p>
</div>
</header>
<div className="cdc-toolbar">
<div className="cdc-search">
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Rechercher..." />
</div>
<div className="cdc-filter-tabs">
{(["TOUS", "non_lu", "lu"] as const).map((s) => (
<button key={s} className={filter === s ? "active" : ""} onClick={() => setFilter(s)}>
{s === "TOUS" ? "Tous" : s === "non_lu" ? "Non lus" : "Lus"} <span className="cdc-tab-count">{counts[s] ?? 0}</span>
</button>
))}
</div>
</div>
<div className="cdc-table-wrap">
<table className="cdc-table">
<thead>
<tr>
<th>Réf</th><th>Document</th><th>Formateur</th><th>Organisme</th><th>Groupe</th>
<th>Reçu le</th><th style={{ textAlign: "right" }}>Statut</th>
</tr>
</thead>
<tbody>
{filtered.map((c) => (
<tr key={c.id} className="cdc-row" onClick={() => setSelectedId(c.id)}>
<td className="cdc-mono-cell">{c.reference}</td>
<td className="cdc-objet-cell">📄 {c.nomFichier}</td>
<td>{c.formateur}</td>
<td>{c.organisme}</td>
<td>{c.groupe}</td>
<td>{fmtDate(c.dateReception)}</td>
<td style={{ textAlign: "right" }}>
<span className="cdc-status-pill" data-statut={c.statutLecture}>{c.statutLecture === "lu" ? "Lu" : "Non lu"}</span>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={7} className="cdc-empty">Aucun document ne correspond à ces filtres.</td></tr>
)}
</tbody>
</table>
</div>
</div>
{selected && <DocumentDrawer doc={selected} onClose={() => setSelectedId(null)} onLu={toggleLu} />}
</div>
);
}
const css = `
.cdc-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.cdc-root *{ box-sizing:border-box; }
.cdc-wrap{ max-width:1180px; margin:0 auto; }
.cdc-header{ margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.cdc-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.cdc-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.cdc-sub{ font-size:13px; color:var(--text-muted); margin:6px 0 0; }
.cdc-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.cdc-btn-primary:hover{ opacity:0.92; }
.cdc-toolbar{ display:flex; align-items:center; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
.cdc-search input{ width:220px; font-family:var(--sans); font-size:13px; padding:8px 12px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); }
.cdc-search input:focus{ outline:none; border-color:var(--accent); }
.cdc-filter-tabs{ display:flex; gap:6px; flex-wrap:wrap; }
.cdc-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.cdc-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.cdc-tab-count{ font-size:11px; opacity:0.75; }
.cdc-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.cdc-table{ width:100%; border-collapse:collapse; font-size:13px; }
.cdc-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.cdc-table td{ padding:12px 16px; border-bottom:1px solid var(--border); }
.cdc-table tbody tr:last-child td{ border-bottom:none; }
.cdc-row{ cursor:pointer; }
.cdc-row:hover{ background:var(--surface-2); }
.cdc-objet-cell{ font-weight:500; }
.cdc-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.cdc-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.cdc-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; background:var(--surface-2); color:var(--text-2); }
.cdc-status-pill[data-statut="non_lu"]{ background:var(--warning-light); color:var(--warning); }
.cdc-status-pill[data-statut="lu"]{ background:var(--success-light); color:var(--success); }
.cdc-drawer-overlay{ position:fixed; inset:0; background:rgba(28,30,27,0.45); z-index:50; animation:cdc-fade-in .18s ease; }
.cdc-drawer{
position:fixed; top:0; right:0; bottom:0; width:100%; max-width:440px;
background:var(--surface); padding:28px 32px; overflow-y:auto;
box-shadow:-8px 0 32px rgba(20,20,15,0.18);
animation:cdc-slide-in .22s ease;
display:flex; flex-direction:column;
}
@keyframes cdc-fade-in{ from{ opacity:0; } to{ opacity:1; } }
@keyframes cdc-slide-in{ from{ transform:translateX(100%); } to{ transform:translateX(0); } }
.cdc-drawer-header{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:20px; }
.cdc-drawer-title{ font-size:16px; font-weight:700; margin:0; letter-spacing:-0.01em; word-break:break-word; }
.cdc-drawer-close{ font-family:var(--sans); font-size:18px; line-height:1; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px; flex-shrink:0; }
.cdc-drawer-close:hover{ color:var(--text); }
.cdc-drawer-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; padding-bottom:20px; border-bottom:1px solid var(--border); margin-bottom:20px; }
.cdc-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 4px; }
.cdc-detail-value{ font-size:13.5px; font-weight:600; color:var(--text); margin:0; }
.cdc-viewer-placeholder{ flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; gap:6px; background:var(--surface-2); border:1px dashed var(--border); border-radius:var(--radius); padding:32px 20px; margin-bottom:20px; color:var(--text-2); font-size:13.5px; }
.cdc-viewer-note{ font-size:12px; color:var(--text-muted); margin:0; }
@media (max-width:520px){
.cdc-drawer{ max-width:100%; }
}
`;
@@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from "react";
import { soustraitantsApi, type ContratPrestataire, type OrganismeST, type StatutContratST } from "../../data";
/*
Composant autonome — données réelles via soustraitantsApi (schéma st.organismes /
st.contrats_prestataires). Structure simplifiée par rapport à Contratsfournisseurs.tsx
(pas d'échéancier ni de documents détaillés pour ce premier jet) — liste + panneau de
création.
*/
const STATUT_LABEL: Record<StatutContratST, string> = { ACTIF: "Actif", EXPIRE: "Expiré", A_RENOUVELER: "À renouveler" };
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const euro = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const emptyForm = { organismeId: "", objet: "", dateDebut: "", dateFin: "", montant: "" };
export default function ContratsPrestataires() {
const [items, setItems] = useState<ContratPrestataire[]>([]);
const [organismes, setOrganismes] = useState<OrganismeST[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<"TOUTES" | StatutContratST>("TOUTES");
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
useEffect(() => {
Promise.all([soustraitantsApi.getContrats(), soustraitantsApi.getOrganismes()])
.then(([contrats, orgs]) => {
setItems(contrats);
setOrganismes(orgs);
})
.catch((e) => setError(e instanceof Error ? e.message : "Erreur de chargement."))
.finally(() => setLoading(false));
}, []);
const counts = useMemo(() => {
const base: Record<string, number> = { TOUTES: items.length };
(["ACTIF", "A_RENOUVELER", "EXPIRE"] as StatutContratST[]).forEach((s) => (base[s] = items.filter((i) => i.statut === s).length));
return base;
}, [items]);
const filtered = useMemo(() => (filter === "TOUTES" ? items : items.filter((i) => i.statut === filter)), [items, filter]);
const totalEngage = filtered.reduce((s, c) => s + c.montantTotal, 0);
const update = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) => setForm((f) => ({ ...f, [key]: value }));
const create = async () => {
if (!form.organismeId || !form.objet.trim() || !form.dateDebut || !form.dateFin) return;
setSaving(true);
setFormError(null);
try {
const contrat = await soustraitantsApi.createContrat({
organismeId: Number(form.organismeId),
objet: form.objet.trim(),
dateDebut: form.dateDebut,
dateFin: form.dateFin,
montantTotal: parseFloat(form.montant) || 0,
});
setItems((prev) => [contrat, ...prev]);
setForm(emptyForm);
setShowForm(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : "Impossible d'enregistrer le contrat.");
} finally {
setSaving(false);
}
};
return (
<div className="cts-root">
<style>{css}</style>
<div className="cts-wrap">
<header className="cts-header">
<div>
<p className="cts-eyebrow">Sous-traitants · contrats</p>
<h1 className="cts-h1">Contrats prestataires</h1>
<p className="cts-sub">{filtered.length} contrat{filtered.length !== 1 ? "s" : ""} · {euro(totalEngage)} engagé</p>
</div>
<button className="cts-btn-primary" onClick={() => setShowForm((v) => !v)}>+ Nouveau contrat</button>
</header>
{showForm && (
<div className="cts-panel" style={{ marginBottom: 16 }}>
<p className="cts-panel-title">Nouveau contrat prestataire</p>
<div className="cts-form-grid">
<div className="cts-field">
<label>Organisme</label>
<select value={form.organismeId} onChange={(e) => update("organismeId", e.target.value)}>
<option value="">Sélectionner…</option>
{organismes.map((o) => <option key={o.id} value={o.id}>{o.raisonSociale}</option>)}
</select>
</div>
<div className="cts-field">
<label>Objet</label>
<input value={form.objet} onChange={(e) => update("objet", e.target.value)} placeholder="Ex. Formations Marketing — S2 2026" />
</div>
<div className="cts-field">
<label>Date de début</label>
<input type="date" value={form.dateDebut} onChange={(e) => update("dateDebut", e.target.value)} />
</div>
<div className="cts-field">
<label>Date de fin</label>
<input type="date" value={form.dateFin} onChange={(e) => update("dateFin", e.target.value)} />
</div>
<div className="cts-field">
<label>Montant (EUR)</label>
<input value={form.montant} onChange={(e) => update("montant", e.target.value)} placeholder="18 000" />
</div>
</div>
{formError && <p className="cts-form-error">{formError}</p>}
<div className="cts-form-actions">
<button className="cts-btn-ghost" onClick={() => { setForm(emptyForm); setFormError(null); setShowForm(false); }}>Annuler</button>
<button className="cts-btn-primary" disabled={saving} onClick={create}>{saving ? "Enregistrement…" : "Créer"}</button>
</div>
</div>
)}
<div className="cts-filter-tabs">
{(["TOUTES", "ACTIF", "A_RENOUVELER", "EXPIRE"] as const).map((s) => (
<button key={s} className={filter === s ? "active" : ""} onClick={() => setFilter(s)}>
{s === "TOUTES" ? "Tous" : STATUT_LABEL[s]} <span className="cts-tab-count">{counts[s] ?? 0}</span>
</button>
))}
</div>
{error && <p className="cts-form-error" style={{ marginBottom: 12 }}>{error}</p>}
<div className="cts-table-wrap">
<table className="cts-table">
<thead>
<tr>
<th>Réf</th><th>Organisme</th><th>Objet</th><th>Début</th><th>Fin</th>
<th style={{ textAlign: "right" }}>Montant</th><th style={{ textAlign: "right" }}>Statut</th>
</tr>
</thead>
<tbody>
{filtered.map((c) => (
<tr key={c.id}>
<td className="cts-mono-cell">{c.reference}</td>
<td>{c.organisme}</td>
<td className="cts-objet-cell">{c.objet}</td>
<td>{fmtDate(c.dateDebut)}</td>
<td>{fmtDate(c.dateFin)}</td>
<td className="cts-amount">{euro(c.montantTotal)}</td>
<td style={{ textAlign: "right" }}>
<span className="cts-status-pill" data-statut={c.statut}>{STATUT_LABEL[c.statut]}</span>
</td>
</tr>
))}
{!loading && filtered.length === 0 && (
<tr><td colSpan={7} className="cts-empty">Aucun contrat dans cette catégorie.</td></tr>
)}
{loading && (
<tr><td colSpan={7} className="cts-empty">Chargement…</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const css = `
.cts-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.cts-root *{ box-sizing:border-box; }
.cts-wrap{ max-width:1180px; margin:0 auto; }
.cts-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.cts-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.cts-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.cts-sub{ font-size:13px; color:var(--text-muted); margin:6px 0 0; }
.cts-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.cts-btn-primary:hover{ opacity:0.92; }
.cts-btn-primary:disabled{ opacity:0.6; cursor:default; }
.cts-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.cts-btn-ghost:hover{ border-color:var(--accent); }
.cts-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.cts-panel-title{ font-size:15px; font-weight:600; margin:0 0 16px; }
.cts-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; }
.cts-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.cts-field input, .cts-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.cts-field input:focus, .cts-field select:focus{ outline:none; border-color:var(--accent); }
.cts-form-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }
.cts-form-error{ font-size:12.5px; color:var(--danger); margin:12px 0 0; }
.cts-filter-tabs{ display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.cts-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.cts-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.cts-tab-count{ font-size:11px; opacity:0.75; }
.cts-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.cts-table{ width:100%; border-collapse:collapse; font-size:13px; }
.cts-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.cts-table td{ padding:12px 16px; border-bottom:1px solid var(--border); }
.cts-table tbody tr:last-child td{ border-bottom:none; }
.cts-objet-cell{ font-weight:500; }
.cts-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.cts-amount{ font-family:var(--mono); text-align:right; }
.cts-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.cts-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; background:var(--surface-2); color:var(--text-2); }
.cts-status-pill[data-statut="ACTIF"]{ background:var(--success-light); color:var(--success); }
.cts-status-pill[data-statut="A_RENOUVELER"]{ background:var(--warning-light); color:var(--warning); }
.cts-status-pill[data-statut="EXPIRE"]{ background:var(--surface-2); color:var(--text-2); }
@media (max-width:900px){
.cts-form-grid{ grid-template-columns:1fr; }
}
`;
@@ -0,0 +1,162 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Confronte les heures facturées (côté Factures) aux heures réellement dispensées
(côté Prestations). "Signaler" bascule le dossier vers l'écran Arbitrage.
*/
type StatutControle = "en_attente" | "conforme" | "non_conforme";
interface Controle {
id: number;
reference: string;
factureNumero: string;
organisme: string;
heuresFacturees: number;
heuresRealisees: number;
statut: StatutControle;
}
const STATUT_LABEL: Record<StatutControle, string> = { en_attente: "En attente", conforme: "Conforme", non_conforme: "Non conforme" };
const INITIAL: Controle[] = [
{ id: 1, reference: "CTRL-2026-041", factureNumero: "FAC-ST-2026-001", organisme: "FormaPro", heuresFacturees: 16, heuresRealisees: 16, statut: "non_conforme" },
{ id: 2, reference: "CTRL-2026-042", factureNumero: "FAC-ST-2026-002", organisme: "LinguaLearn", heuresFacturees: 18, heuresRealisees: 18, statut: "en_attente" },
{ id: 3, reference: "CTRL-2026-043", factureNumero: "FAC-ST-2026-003", organisme: "Académie Ldsh.", heuresFacturees: 14, heuresRealisees: 12, statut: "en_attente" },
{ id: 4, reference: "CTRL-2026-039", factureNumero: "FAC-ST-2026-004", organisme: "FormaPro", heuresFacturees: 7, heuresRealisees: 7, statut: "conforme" },
];
const euroHeures = (n: number) => `${n} h`;
export default function ControleConformite() {
const navigate = useNavigate();
const [items, setItems] = useState<Controle[]>(INITIAL);
const [filter, setFilter] = useState<"TOUTES" | StatutControle>("TOUTES");
const counts = useMemo(() => {
const base: Record<string, number> = { TOUTES: items.length };
(["en_attente", "conforme", "non_conforme"] as StatutControle[]).forEach((s) => (base[s] = items.filter((i) => i.statut === s).length));
return base;
}, [items]);
const filtered = useMemo(() => (filter === "TOUTES" ? items : items.filter((i) => i.statut === filter)), [items, filter]);
const marquerConforme = (id: number) => setItems((prev) => prev.map((c) => (c.id === id ? { ...c, statut: "conforme" } : c)));
const signalerEcart = (id: number) => {
setItems((prev) => prev.map((c) => (c.id === id ? { ...c, statut: "non_conforme" } : c)));
navigate("/soustraitants/arbitrage");
};
return (
<div className="cnf-root">
<style>{css}</style>
<div className="cnf-wrap">
<header className="cnf-header">
<div>
<p className="cnf-eyebrow">Sous-traitants · contrôle</p>
<h1 className="cnf-h1">Contrôle conformité</h1>
<p className="cnf-sub">Vérification des heures facturées face aux prestations réalisées</p>
</div>
</header>
<div className="cnf-filter-tabs">
{(["TOUTES", "en_attente", "conforme", "non_conforme"] as const).map((s) => (
<button key={s} className={filter === s ? "active" : ""} onClick={() => setFilter(s)}>
{s === "TOUTES" ? "Tous" : STATUT_LABEL[s]} <span className="cnf-tab-count">{counts[s] ?? 0}</span>
</button>
))}
</div>
<div className="cnf-table-wrap">
<table className="cnf-table">
<thead>
<tr>
<th>Réf</th><th>Facture</th><th>Organisme</th>
<th style={{ textAlign: "right" }}>Facturées</th><th style={{ textAlign: "right" }}>Réalisées</th>
<th>Statut</th><th style={{ width: 220 }}>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((c) => {
const ecart = c.heuresFacturees - c.heuresRealisees;
return (
<tr key={c.id}>
<td className="cnf-mono-cell">{c.reference}</td>
<td className="cnf-mono-cell">{c.factureNumero}</td>
<td>{c.organisme}</td>
<td className="cnf-amount">{euroHeures(c.heuresFacturees)}</td>
<td className="cnf-amount">{euroHeures(c.heuresRealisees)}{ecart !== 0 && <span className="cnf-ecart"> ({ecart > 0 ? "+" : ""}{ecart}h)</span>}</td>
<td><span className="cnf-status-pill" data-statut={c.statut}>{STATUT_LABEL[c.statut]}</span></td>
<td>
{c.statut === "en_attente" ? (
<div className="cnf-actions">
<button className="cnf-btn-danger" onClick={() => signalerEcart(c.id)}>Signaler écart</button>
<button className="cnf-btn-success" onClick={() => marquerConforme(c.id)}>Marquer conforme</button>
</div>
) : (
<span className="cnf-muted-inline">—</span>
)}
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr><td colSpan={7} className="cnf-empty">Aucun contrôle dans cette catégorie.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const css = `
.cnf-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.cnf-root *{ box-sizing:border-box; }
.cnf-wrap{ max-width:1180px; margin:0 auto; }
.cnf-header{ margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.cnf-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.cnf-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.cnf-sub{ font-size:13px; color:var(--text-muted); margin:6px 0 0; }
.cnf-filter-tabs{ display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.cnf-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.cnf-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.cnf-tab-count{ font-size:11px; opacity:0.75; }
.cnf-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.cnf-table{ width:100%; border-collapse:collapse; font-size:13px; }
.cnf-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.cnf-table td{ padding:10px 16px; border-bottom:1px solid var(--border); }
.cnf-table tbody tr:last-child td{ border-bottom:none; }
.cnf-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.cnf-amount{ font-family:var(--mono); text-align:right; }
.cnf-ecart{ color:var(--danger); }
.cnf-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.cnf-muted-inline{ color:var(--text-muted); font-size:12px; }
.cnf-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; }
.cnf-status-pill[data-statut="en_attente"]{ background:var(--warning-light); color:var(--warning); }
.cnf-status-pill[data-statut="conforme"]{ background:var(--success-light); color:var(--success); }
.cnf-status-pill[data-statut="non_conforme"]{ background:var(--danger-light); color:var(--danger); }
.cnf-actions{ display:flex; gap:6px; }
.cnf-btn-success{ font-family:var(--sans); font-size:12px; font-weight:600; padding:6px 10px; border-radius:6px; border:1px solid var(--success); background:var(--surface); color:var(--success); cursor:pointer; white-space:nowrap; }
.cnf-btn-success:hover{ background:var(--success-light); }
.cnf-btn-danger{ font-family:var(--sans); font-size:12px; font-weight:600; padding:6px 10px; border-radius:6px; border:1px solid var(--danger); background:var(--surface); color:var(--danger); cursor:pointer; white-space:nowrap; }
.cnf-btn-danger:hover{ background:var(--danger-light); }
`;
@@ -0,0 +1,185 @@
import { useNavigate } from "react-router-dom";
/*
Composant autonome — aucune dépendance externe hormis React et react-router.
Mêmes tokens de structure que les pages du Module Achats (Contratsfournisseurs.tsx,
Demandes.tsx, etc.) mais accent vert dédié au Module Sous-Traitants.
Données mockées pour l'instant — pas encore de backend pour ce domaine
(cahiers des charges / prestations / conformité / arbitrage).
*/
const euro = (n: number) => `${n.toLocaleString("fr-FR")} €`;
type EcartStatut = "conforme" | "ecart_plus" | "ecart_moins";
interface PrestationRecente {
id: number;
intervenant: string;
organisme: string;
groupe: string;
module: string;
heuresRealisees: number;
heuresPrevues: number;
statut: EcartStatut;
}
const ECART_LABEL: Record<EcartStatut, string> = {
conforme: "Conforme",
ecart_plus: "Écart +h",
ecart_moins: "Écart −h",
};
const PRESTATIONS_RECENTES: PrestationRecente[] = [
{ id: 1, intervenant: "M. Dupont", organisme: "FormaPro", groupe: "Groupe A", module: "Marketing opérationnel", heuresRealisees: 16, heuresPrevues: 14, statut: "ecart_plus" },
{ id: 2, intervenant: "Mme Martin", organisme: "FormaPro", groupe: "Groupe B", module: "Vente distribution", heuresRealisees: 7, heuresPrevues: 7, statut: "conforme" },
{ id: 3, intervenant: "Mr. Smith", organisme: "LinguaLearn", groupe: "Groupe Strat1", module: "Stratégie commerciale", heuresRealisees: 18, heuresPrevues: 20, statut: "ecart_moins" },
{ id: 4, intervenant: "Mme Lefèvre", organisme: "Académie Ldsh.", groupe: "Groupe Négo", module: "Négociation commerciale", heuresRealisees: 12, heuresPrevues: 14, statut: "ecart_moins" },
];
type AlerteSeverite = "warning" | "danger" | "info" | "primary";
interface AlerteST {
titre: string;
detail: string;
severite: AlerteSeverite;
lien: string;
}
const ALERTES: AlerteST[] = [
{ titre: "Écart +2h — FormaPro SAS", detail: "PREST-2026-0041 · Excel avancé · arbitrage requis", severite: "warning", lien: "/soustraitants/arbitrage" },
{ titre: "Facture non conforme — FAC-ST-2026-001", detail: "FormaPro SAS · 4 800 € · heures facturées > réalisées", severite: "danger", lien: "/soustraitants/conformite" },
{ titre: "Contrat expirant — LinguaLearn", detail: "ST-CONT-2026-002 · fin le 30/06/2026 · renouvellement ?", severite: "info", lien: "/soustraitants/contrats" },
{ titre: "Arbitrage en attente — ARB-2026-001", detail: "FormaPro SAS · décision du responsable pédagogique requise", severite: "primary", lien: "/soustraitants/arbitrage" },
];
export default function DashboardST() {
const navigate = useNavigate();
return (
<div className="st-root">
<style>{css}</style>
<div className="st-wrap">
<header className="st-header">
<div>
<p className="st-eyebrow">Sous-traitants · pilotage</p>
<h1 className="st-h1">Tableau de bord</h1>
<p className="st-sub">Vue globale — prestations &amp; paiements</p>
</div>
</header>
<div className="st-kpi-grid">
<div className="st-kpi-card">
<p className="st-kpi-label">CDC actifs</p>
<p className="st-kpi-value">3</p>
<p className="st-kpi-sub">En cours de réalisation</p>
</div>
<div className="st-kpi-card">
<p className="st-kpi-label">Contrats actifs</p>
<p className="st-kpi-value">3</p>
<p className="st-kpi-sub">Valeur totale : {euro(55500)}</p>
</div>
<div className="st-kpi-card">
<p className="st-kpi-label">Factures à traiter</p>
<p className="st-kpi-value">3</p>
<p className="st-kpi-sub danger">dont 1 non conforme</p>
</div>
<div className="st-kpi-card">
<p className="st-kpi-label">Écarts d'heures</p>
<p className="st-kpi-value">3</p>
<p className="st-kpi-sub danger">dossiers à arbitrer</p>
</div>
</div>
<div className="st-panel-grid">
<div className="st-panel">
<div className="st-panel-head">
<span className="st-panel-title">Prestations récentes</span>
<span className="st-panel-action" onClick={() => navigate("/soustraitants/prestations")}>Tout voir →</span>
</div>
{PRESTATIONS_RECENTES.map((p) => (
<div className="st-row" key={p.id} onClick={() => navigate("/soustraitants/prestations")}>
<div className="st-row-main">
<p className="st-row-title">{p.intervenant} ({p.organisme})</p>
<p className="st-row-sub">{p.groupe} — {p.module}</p>
</div>
<span className="st-row-heures">{p.heuresRealisees}h / {p.heuresPrevues}h prév.</span>
<span className="st-pill" data-statut={p.statut}>{ECART_LABEL[p.statut]}</span>
</div>
))}
</div>
<div className="st-panel">
<div className="st-panel-head">
<span className="st-panel-title">Alertes</span>
<span className="st-badge">{ALERTES.length}</span>
</div>
{ALERTES.map((a, i) => (
<div className="st-row" key={`${a.titre}-${i}`} onClick={() => navigate(a.lien)}>
<span className={`st-dot st-dot-${a.severite}`} />
<div className="st-row-main">
<p className="st-row-title">{a.titre}</p>
<p className="st-row-sub">{a.detail}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
const css = `
.st-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--info:#1D4ED8; --info-light:#E8EEFC;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.st-root *{ box-sizing:border-box; }
.st-wrap{ max-width:1180px; margin:0 auto; }
.st-header{ margin-bottom:20px; }
.st-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.st-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.st-sub{ font-size:14px; color:var(--text-2); margin:4px 0 0; }
.st-kpi-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:16px; margin-bottom:20px; }
.st-kpi-card{ background:var(--surface); border:1px solid var(--border); border-radius:16px; padding:20px; box-shadow:0 1px 2px rgba(20,20,15,0.03); }
.st-kpi-label{ font-size:13px; color:var(--text-2); margin:0 0 10px; }
.st-kpi-value{ font-size:26px; font-weight:700; color:var(--text); margin:0; letter-spacing:-0.01em; }
.st-kpi-sub{ font-size:13px; color:var(--text-2); margin:8px 0 0; }
.st-kpi-sub.danger{ color:var(--danger); }
.st-panel-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(320px, 1fr)); gap:16px; }
.st-panel{ background:var(--surface); border:1px solid var(--border); border-radius:16px; overflow:hidden; box-shadow:0 1px 2px rgba(20,20,15,0.03); }
.st-panel-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.st-panel-title{ font-size:15px; font-weight:700; color:var(--text); }
.st-panel-action{ font-size:13.5px; color:var(--accent); font-weight:600; cursor:pointer; }
.st-badge{ font-size:12px; font-weight:700; color:var(--danger); background:var(--danger-light); border-radius:10px; padding:1px 8px; }
.st-row{ display:flex; align-items:center; gap:12px; padding:12px 20px; border-bottom:1px solid var(--border); cursor:pointer; }
.st-row:last-child{ border-bottom:none; }
.st-row:hover{ background:var(--surface-2); }
.st-row-main{ flex:1; min-width:0; }
.st-row-title{ font-size:14px; font-weight:600; color:var(--text); margin:0; }
.st-row-sub{ font-size:12.5px; color:var(--text-2); margin:2px 0 0; }
.st-row-heures{ font-size:13.5px; font-weight:600; color:var(--text); font-variant-numeric:tabular-nums; white-space:nowrap; }
.st-dot{ width:7px; height:7px; border-radius:50%; flex-shrink:0; }
.st-dot-warning{ background:var(--warning); }
.st-dot-danger{ background:var(--danger); }
.st-dot-info{ background:var(--info); }
.st-dot-primary{ background:var(--accent); }
.st-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; }
.st-pill[data-statut="conforme"]{ background:var(--success-light); color:var(--success); }
.st-pill[data-statut="ecart_plus"]{ background:var(--warning-light); color:var(--warning); }
.st-pill[data-statut="ecart_moins"]{ background:var(--danger-light); color:var(--danger); }
`;
@@ -0,0 +1,215 @@
import { useMemo, useState } from "react";
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Le statut "non conforme" est normalement déclenché par le Contrôle conformité
(comparaison heures facturées / réalisées) — ici saisi manuellement à la création.
*/
type StatutFactureST = "a_traiter" | "conforme" | "non_conforme" | "payee";
interface FactureST {
id: number;
numero: string;
organisme: string;
montant: number;
date: string;
dateEcheance: string;
statut: StatutFactureST;
}
const STATUT_LABEL: Record<StatutFactureST, string> = { a_traiter: "À traiter", conforme: "Conforme", non_conforme: "Non conforme", payee: "Payée" };
const ORGANISMES = ["FormaPro", "LinguaLearn", "Académie Ldsh."];
const INITIAL: FactureST[] = [
{ id: 1, numero: "FAC-ST-2026-001", organisme: "FormaPro", montant: 4800, date: "2026-07-15", dateEcheance: "2026-08-15", statut: "non_conforme" },
{ id: 2, numero: "FAC-ST-2026-002", organisme: "LinguaLearn", montant: 3200, date: "2026-07-10", dateEcheance: "2026-08-10", statut: "a_traiter" },
{ id: 3, numero: "FAC-ST-2026-003", organisme: "Académie Ldsh.", montant: 2100, date: "2026-07-05", dateEcheance: "2026-08-05", statut: "a_traiter" },
{ id: 4, numero: "FAC-ST-2026-004", organisme: "FormaPro", montant: 1500, date: "2026-06-20", dateEcheance: "2026-07-20", statut: "payee" },
];
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const euro = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const emptyForm = { numero: "", organisme: ORGANISMES[0], montant: "", date: "", dateEcheance: "" };
export default function FacturesST() {
const [items, setItems] = useState<FactureST[]>(INITIAL);
const [filter, setFilter] = useState<"TOUTES" | StatutFactureST>("TOUTES");
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const counts = useMemo(() => {
const base: Record<string, number> = { TOUTES: items.length };
(["a_traiter", "conforme", "non_conforme", "payee"] as StatutFactureST[]).forEach((s) => (base[s] = items.filter((i) => i.statut === s).length));
return base;
}, [items]);
const filtered = useMemo(() => (filter === "TOUTES" ? items : items.filter((i) => i.statut === filter)), [items, filter]);
const update = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) => setForm((f) => ({ ...f, [key]: value }));
const changeStatut = (id: number, statut: StatutFactureST) => {
setItems((prev) => prev.map((f) => (f.id === id ? { ...f, statut } : f)));
};
const create = () => {
if (!form.numero.trim() || !form.montant) return;
setItems((prev) => [
{ id: Date.now(), numero: form.numero.trim(), organisme: form.organisme, montant: parseFloat(form.montant) || 0, date: form.date, dateEcheance: form.dateEcheance, statut: "a_traiter" },
...prev,
]);
setForm(emptyForm);
setShowForm(false);
};
return (
<div className="fst-root">
<style>{css}</style>
<div className="fst-wrap">
<header className="fst-header">
<div>
<p className="fst-eyebrow">Sous-traitants · facturation</p>
<h1 className="fst-h1">Factures</h1>
</div>
<button className="fst-btn-primary" onClick={() => setShowForm((v) => !v)}>+ Nouvelle facture</button>
</header>
{showForm && (
<div className="fst-panel" style={{ marginBottom: 16 }}>
<p className="fst-panel-title">Enregistrer une facture</p>
<div className="fst-form-grid">
<div className="fst-field">
<label>Numéro</label>
<input value={form.numero} onChange={(e) => update("numero", e.target.value)} placeholder="FAC-ST-2026-005" />
</div>
<div className="fst-field">
<label>Organisme</label>
<select value={form.organisme} onChange={(e) => update("organisme", e.target.value)}>
{ORGANISMES.map((o) => <option key={o}>{o}</option>)}
</select>
</div>
<div className="fst-field">
<label>Montant TTC (EUR)</label>
<input value={form.montant} onChange={(e) => update("montant", e.target.value)} placeholder="3 200" />
</div>
<div className="fst-field">
<label>Date de facture</label>
<input type="date" value={form.date} onChange={(e) => update("date", e.target.value)} />
</div>
<div className="fst-field">
<label>Date d'échéance</label>
<input type="date" value={form.dateEcheance} onChange={(e) => update("dateEcheance", e.target.value)} />
</div>
</div>
<div className="fst-form-actions">
<button className="fst-btn-ghost" onClick={() => { setForm(emptyForm); setShowForm(false); }}>Annuler</button>
<button className="fst-btn-primary" onClick={create}>Enregistrer</button>
</div>
</div>
)}
<div className="fst-filter-tabs">
{(["TOUTES", "a_traiter", "conforme", "non_conforme", "payee"] as const).map((s) => (
<button key={s} className={filter === s ? "active" : ""} onClick={() => setFilter(s)}>
{s === "TOUTES" ? "Toutes" : STATUT_LABEL[s]} <span className="fst-tab-count">{counts[s] ?? 0}</span>
</button>
))}
</div>
<div className="fst-table-wrap">
<table className="fst-table">
<thead>
<tr>
<th>Numéro</th><th>Organisme</th><th>Échéance</th>
<th style={{ textAlign: "right" }}>Montant</th><th>Statut</th>
</tr>
</thead>
<tbody>
{filtered.map((f) => (
<tr key={f.id}>
<td className="fst-mono-cell">{f.numero}</td>
<td>{f.organisme}</td>
<td>{fmtDate(f.dateEcheance)}</td>
<td className="fst-amount">{euro(f.montant)}</td>
<td>
<select className="fst-status-select" data-statut={f.statut} value={f.statut} onChange={(e) => changeStatut(f.id, e.target.value as StatutFactureST)}>
<option value="a_traiter">À traiter</option>
<option value="conforme">Conforme</option>
<option value="non_conforme">Non conforme</option>
<option value="payee">Payée</option>
</select>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={5} className="fst-empty">Aucune facture dans cette catégorie.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const css = `
.fst-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.fst-root *{ box-sizing:border-box; }
.fst-wrap{ max-width:1180px; margin:0 auto; }
.fst-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.fst-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.fst-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.fst-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.fst-btn-primary:hover{ opacity:0.92; }
.fst-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.fst-btn-ghost:hover{ border-color:var(--accent); }
.fst-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.fst-panel-title{ font-size:15px; font-weight:600; margin:0 0 16px; }
.fst-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; }
.fst-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.fst-field input, .fst-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.fst-field input:focus, .fst-field select:focus{ outline:none; border-color:var(--accent); }
.fst-form-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }
.fst-filter-tabs{ display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.fst-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.fst-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.fst-tab-count{ font-size:11px; opacity:0.75; }
.fst-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.fst-table{ width:100%; border-collapse:collapse; font-size:13px; }
.fst-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.fst-table td{ padding:10px 16px; border-bottom:1px solid var(--border); }
.fst-table tbody tr:last-child td{ border-bottom:none; }
.fst-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.fst-amount{ font-family:var(--mono); text-align:right; }
.fst-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.fst-status-select{ font-family:var(--sans); font-size:12px; padding:4px 8px; border-radius:20px; border:1px solid transparent; cursor:pointer; background:var(--surface-2); color:var(--text-2); }
.fst-status-select[data-statut="a_traiter"]{ background:var(--surface-2); color:var(--text-2); }
.fst-status-select[data-statut="conforme"]{ background:var(--success-light); color:var(--success); }
.fst-status-select[data-statut="non_conforme"]{ background:var(--danger-light); color:var(--danger); }
.fst-status-select[data-statut="payee"]{ background:var(--accent-light); color:var(--accent); }
@media (max-width:900px){
.fst-form-grid{ grid-template-columns:1fr; }
}
`;
@@ -0,0 +1,192 @@
import { useMemo, useState } from "react";
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Mêmes conventions que Paiements.tsx (Module Achats) : liste + toggle rapproché.
*/
type StatutPaiement = "programme" | "execute" | "rejete";
interface PaiementST {
id: number;
reference: string;
organisme: string;
factureNumero: string;
montant: number;
datePaiement: string;
modePaiement: string;
statut: StatutPaiement;
rapproche: boolean;
}
const STATUT_LABEL: Record<StatutPaiement, string> = { programme: "Programmé", execute: "Exécuté", rejete: "Rejeté" };
const MODES_PAIEMENT = ["Virement", "Prélèvement"];
const FACTURES_DISPONIBLES = [
{ numero: "FAC-ST-2026-004", organisme: "FormaPro", montant: 1500 },
{ numero: "FAC-ST-2026-003", organisme: "Académie Ldsh.", montant: 2100 },
];
const INITIAL: PaiementST[] = [
{ id: 1, reference: "VIR-ST-2026-014", organisme: "FormaPro", factureNumero: "FAC-ST-2026-004", montant: 1500, datePaiement: "2026-07-22", modePaiement: "Virement", statut: "execute", rapproche: true },
{ id: 2, reference: "VIR-ST-2026-013", organisme: "LinguaLearn", factureNumero: "FAC-ST-2025-041", montant: 2400, datePaiement: "2026-06-15", modePaiement: "Virement", statut: "execute", rapproche: false },
];
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const euro = (n: number) => `${n.toLocaleString("fr-FR")} €`;
export default function PaiementsST() {
const [items, setItems] = useState<PaiementST[]>(INITIAL);
const [showForm, setShowForm] = useState(false);
const [factureNumero, setFactureNumero] = useState("");
const [modePaiement, setModePaiement] = useState(MODES_PAIEMENT[0]);
const [datePaiement, setDatePaiement] = useState("");
const facturesRestantes = useMemo(
() => FACTURES_DISPONIBLES.filter((f) => !items.some((p) => p.factureNumero === f.numero)),
[items]
);
const toggleRapproche = (id: number) => setItems((prev) => prev.map((p) => (p.id === id ? { ...p, rapproche: !p.rapproche } : p)));
const create = () => {
const facture = FACTURES_DISPONIBLES.find((f) => f.numero === factureNumero);
if (!facture || !datePaiement) return;
const nextSeq = items.length + 14;
setItems((prev) => [
{ id: Date.now(), reference: `VIR-ST-2026-${String(nextSeq).padStart(3, "0")}`, organisme: facture.organisme, factureNumero: facture.numero, montant: facture.montant, datePaiement, modePaiement, statut: "programme", rapproche: false },
...prev,
]);
setFactureNumero("");
setDatePaiement("");
setShowForm(false);
};
return (
<div className="pst-root">
<style>{css}</style>
<div className="pst-wrap">
<header className="pst-header">
<div>
<p className="pst-eyebrow">Sous-traitants · paiements</p>
<h1 className="pst-h1">Paiements</h1>
</div>
<button className="pst-btn-primary" onClick={() => setShowForm((v) => !v)}>+ Nouveau paiement</button>
</header>
{showForm && (
<div className="pst-panel" style={{ marginBottom: 16 }}>
<p className="pst-panel-title">Nouveau paiement</p>
<div className="pst-form-grid">
<div className="pst-field">
<label>Facture</label>
<select value={factureNumero} onChange={(e) => setFactureNumero(e.target.value)}>
<option value="">Sélectionner…</option>
{facturesRestantes.map((f) => <option key={f.numero} value={f.numero}>{f.numero} — {f.organisme} ({euro(f.montant)})</option>)}
</select>
</div>
<div className="pst-field">
<label>Mode de paiement</label>
<select value={modePaiement} onChange={(e) => setModePaiement(e.target.value)}>
{MODES_PAIEMENT.map((m) => <option key={m}>{m}</option>)}
</select>
</div>
<div className="pst-field">
<label>Date de paiement</label>
<input type="date" value={datePaiement} onChange={(e) => setDatePaiement(e.target.value)} />
</div>
</div>
<div className="pst-form-actions">
<button className="pst-btn-ghost" onClick={() => setShowForm(false)}>Annuler</button>
<button className="pst-btn-primary" onClick={create}>Enregistrer</button>
</div>
</div>
)}
<div className="pst-table-wrap">
<table className="pst-table">
<thead>
<tr>
<th>Référence</th><th>Organisme</th><th>Facture</th><th>Mode</th><th>Date</th>
<th style={{ textAlign: "right" }}>Montant</th><th>Statut</th><th style={{ textAlign: "center" }}>Rapproché</th>
</tr>
</thead>
<tbody>
{items.map((p) => (
<tr key={p.id}>
<td className="pst-mono-cell">{p.reference}</td>
<td>{p.organisme}</td>
<td className="pst-mono-cell">{p.factureNumero}</td>
<td>{p.modePaiement}</td>
<td>{fmtDate(p.datePaiement)}</td>
<td className="pst-amount">{euro(p.montant)}</td>
<td><span className="pst-status-pill" data-statut={p.statut}>{STATUT_LABEL[p.statut]}</span></td>
<td style={{ textAlign: "center" }}>
<input type="checkbox" checked={p.rapproche} onChange={() => toggleRapproche(p.id)} />
</td>
</tr>
))}
{items.length === 0 && (
<tr><td colSpan={8} className="pst-empty">Aucun paiement enregistré.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const css = `
.pst-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.pst-root *{ box-sizing:border-box; }
.pst-wrap{ max-width:1180px; margin:0 auto; }
.pst-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.pst-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.pst-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.pst-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.pst-btn-primary:hover{ opacity:0.92; }
.pst-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.pst-btn-ghost:hover{ border-color:var(--accent); }
.pst-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.pst-panel-title{ font-size:15px; font-weight:600; margin:0 0 16px; }
.pst-form-grid{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:14px 16px; }
.pst-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.pst-field input, .pst-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.pst-field input:focus, .pst-field select:focus{ outline:none; border-color:var(--accent); }
.pst-form-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }
.pst-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.pst-table{ width:100%; border-collapse:collapse; font-size:13px; }
.pst-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.pst-table td{ padding:10px 16px; border-bottom:1px solid var(--border); }
.pst-table tbody tr:last-child td{ border-bottom:none; }
.pst-mono-cell{ font-family:var(--mono); font-size:12px; color:var(--text-2); }
.pst-amount{ font-family:var(--mono); text-align:right; }
.pst-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.pst-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; }
.pst-status-pill[data-statut="programme"]{ background:var(--surface-2); color:var(--text-2); }
.pst-status-pill[data-statut="execute"]{ background:var(--success-light); color:var(--success); }
.pst-status-pill[data-statut="rejete"]{ background:var(--danger-light); color:var(--danger); }
@media (max-width:900px){
.pst-form-grid{ grid-template-columns:1fr; }
}
`;
@@ -0,0 +1,155 @@
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Mêmes conventions que Pilotage.tsx (Module Achats), accent vert.
*/
const euro = (n: number) => new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
const HEURES_PAR_ORGANISME = [
{ label: "FormaPro", prevues: 24, realisees: 26 },
{ label: "LinguaLearn", prevues: 20, realisees: 18 },
{ label: "Académie Ldsh.", prevues: 14, realisees: 12 },
];
const MOIS = [
{ mois: "Fév", engage: 4200, paye: 2400 },
{ mois: "Mar", engage: 5600, paye: 4200 },
{ mois: "Avr", engage: 3800, paye: 3800 },
{ mois: "Mai", engage: 6100, paye: 4800 },
{ mois: "Juin", engage: 4800, paye: 2400 },
{ mois: "Juil", engage: 6300, paye: 1500 },
];
export default function PilotageST() {
const maxHeures = Math.max(...HEURES_PAR_ORGANISME.flatMap((h) => [h.prevues, h.realisees]));
const maxMois = Math.max(...MOIS.flatMap((m) => [m.engage, m.paye]));
return (
<div className="pil-root">
<style>{css}</style>
<div className="pil-wrap">
<header className="pil-header">
<p className="pil-eyebrow">Sous-traitants · pilotage</p>
<h1 className="pil-h1">Pilotage</h1>
<p className="pil-sub">Suivi des heures et des paiements prestataires</p>
</header>
<div className="pil-kpi-grid">
<div className="pil-kpi-card">
<p className="pil-kpi-label">Total engagé</p>
<p className="pil-kpi-value">{euro(55500)}</p>
</div>
<div className="pil-kpi-card">
<p className="pil-kpi-label">Total payé</p>
<p className="pil-kpi-value">{euro(19100)}</p>
</div>
<div className="pil-kpi-card">
<p className="pil-kpi-label">Reste à payer</p>
<p className="pil-kpi-value">{euro(36400)}</p>
</div>
<div className="pil-kpi-card">
<p className="pil-kpi-label">Écart d'heures cumulé</p>
<p className="pil-kpi-value">+4 h</p>
</div>
</div>
<div className="pil-panel-grid">
<div className="pil-panel">
<p className="pil-panel-title">Heures prévues vs réalisées — par organisme</p>
{HEURES_PAR_ORGANISME.map((h) => (
<div className="pil-bar-row" key={h.label}>
<div className="pil-bar-head">
<span>{h.label}</span>
<span className="pil-bar-value">{h.realisees}h / {h.prevues}h prév.</span>
</div>
<div className="pil-bar-track">
<div className="pil-bar-fill pil-bar-prevue" style={{ width: `${Math.max(3, Math.round((h.prevues / maxHeures) * 100))}%` }} />
</div>
<div className="pil-bar-track" style={{ marginTop: 4 }}>
<div className={`pil-bar-fill ${h.realisees > h.prevues ? "pil-bar-over" : "pil-bar-real"}`} style={{ width: `${Math.max(3, Math.round((h.realisees / maxHeures) * 100))}%` }} />
</div>
</div>
))}
<div className="pil-legend">
<span className="pil-legend-item"><span className="pil-legend-dot prevue" /> Prévues</span>
<span className="pil-legend-item"><span className="pil-legend-dot real" /> Réalisées</span>
</div>
</div>
<div className="pil-panel">
<p className="pil-panel-title">Engagé vs payé — par mois</p>
<div className="pil-chart">
{MOIS.map((m) => (
<div className="pil-chart-col" key={m.mois}>
<div className="pil-chart-bars">
<div className="pil-chart-bar engage" style={{ height: `${Math.max(4, Math.round((m.engage / maxMois) * 100))}%` }} />
<div className="pil-chart-bar paye" style={{ height: `${Math.max(4, Math.round((m.paye / maxMois) * 100))}%` }} />
</div>
<span className="pil-chart-label">{m.mois}</span>
</div>
))}
</div>
<div className="pil-legend">
<span className="pil-legend-item"><span className="pil-legend-dot engage" /> Engagé</span>
<span className="pil-legend-item"><span className="pil-legend-dot paye" /> Payé</span>
</div>
</div>
</div>
</div>
</div>
);
}
const css = `
.pil-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.pil-root *{ box-sizing:border-box; }
.pil-wrap{ max-width:1180px; margin:0 auto; }
.pil-header{ margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.pil-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.pil-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.pil-sub{ font-size:13px; color:var(--text-muted); margin:6px 0 0; }
.pil-kpi-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; }
.pil-kpi-card{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:16px 20px; }
.pil-kpi-label{ font-size:12.5px; color:var(--text-2); margin:0 0 8px; }
.pil-kpi-value{ font-size:22px; font-weight:600; color:var(--text); margin:0; letter-spacing:-0.01em; }
.pil-panel-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(340px, 1fr)); gap:16px; }
.pil-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:16px 20px; }
.pil-panel-title{ font-size:13.5px; font-weight:600; color:var(--text); margin:0 0 16px; }
.pil-bar-row{ margin-bottom:16px; }
.pil-bar-row:last-child{ margin-bottom:0; }
.pil-bar-head{ display:flex; justify-content:space-between; margin-bottom:6px; font-size:13px; color:var(--text-2); }
.pil-bar-value{ color:var(--text); font-variant-numeric:tabular-nums; font-family:var(--mono); }
.pil-bar-track{ height:5px; border-radius:3px; background:var(--surface-2); }
.pil-bar-fill{ height:100%; border-radius:3px; }
.pil-bar-fill.pil-bar-prevue{ background:var(--text-muted); }
.pil-bar-fill.pil-bar-real{ background:var(--accent); }
.pil-bar-fill.pil-bar-over{ background:var(--warning); }
.pil-chart{ display:flex; align-items:flex-end; justify-content:space-between; height:130px; }
.pil-chart-col{ display:flex; flex-direction:column; align-items:center; gap:8px; flex:1; }
.pil-chart-bars{ display:flex; align-items:flex-end; gap:4px; height:100px; }
.pil-chart-bar{ width:9px; border-radius:2px 2px 0 0; }
.pil-chart-bar.engage{ background:var(--accent); }
.pil-chart-bar.paye{ background:var(--text-muted); }
.pil-chart-label{ font-size:11px; color:var(--text-muted); }
.pil-legend{ display:flex; align-items:center; gap:16px; margin-top:16px; }
.pil-legend-item{ display:flex; align-items:center; gap:6px; font-size:12px; color:var(--text-2); }
.pil-legend-dot{ width:8px; height:8px; border-radius:2px; display:inline-block; }
.pil-legend-dot.prevue{ background:var(--text-muted); }
.pil-legend-dot.real{ background:var(--accent); }
.pil-legend-dot.engage{ background:var(--accent); }
.pil-legend-dot.paye{ background:var(--text-muted); }
`;
@@ -0,0 +1,378 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
/*
Composant autonome — données mockées (pas encore de backend pour ce domaine).
Le statut (conforme / écart) est dérivé de la comparaison heures réalisées vs prévues,
pas un champ saisi séparément. Le contrat et le cahier des charges liés sont résolus
via une référence (mêmes données que ContratsPrestataires.tsx / CahierDesCharges.tsx,
dupliquées ici en local — pas de store partagé entre pages pour l'instant).
*/
type EcartStatut = "conforme" | "ecart_plus" | "ecart_moins";
type StatutContrat = "ACTIF" | "EXPIRE" | "A_RENOUVELER";
interface Prestation {
id: number;
intervenant: string;
organisme: string;
groupe: string;
module: string;
date: string;
heuresPrevues: number;
heuresRealisees: number;
commentaire?: string;
cdcRef: string;
contratRef: string;
}
const ORGANISMES = ["FormaPro", "LinguaLearn", "Académie Ldsh."];
const ECART_LABEL: Record<EcartStatut, string> = { conforme: "Conforme", ecart_plus: "Écart +h", ecart_moins: "Écart −h" };
const CONTRAT_STATUT_LABEL: Record<StatutContrat, string> = { ACTIF: "Actif", EXPIRE: "Expiré", A_RENOUVELER: "À renouveler" };
const statutDe = (p: Prestation): EcartStatut => {
if (p.heuresRealisees === p.heuresPrevues) return "conforme";
return p.heuresRealisees > p.heuresPrevues ? "ecart_plus" : "ecart_moins";
};
const INITIAL: Prestation[] = [
{ id: 1, intervenant: "M. Dupont", organisme: "FormaPro", groupe: "Groupe A", module: "Marketing opérationnel", date: "2026-07-20", heuresPrevues: 14, heuresRealisees: 16, commentaire: "2h supplémentaires pour rattraper un retard de groupe.", cdcRef: "CDC-2026-001", contratRef: "ST-CONT-2026-001" },
{ id: 2, intervenant: "Mme Martin", organisme: "FormaPro", groupe: "Groupe B", module: "Vente distribution", date: "2026-07-18", heuresPrevues: 7, heuresRealisees: 7, cdcRef: "CDC-2026-002", contratRef: "ST-CONT-2026-001" },
{ id: 3, intervenant: "Mr. Smith", organisme: "LinguaLearn", groupe: "Groupe Strat1", module: "Stratégie commerciale", date: "2026-07-15", heuresPrevues: 20, heuresRealisees: 18, commentaire: "2 stagiaires absents, séance de clôture raccourcie.", cdcRef: "CDC-2026-003", contratRef: "ST-CONT-2026-002" },
{ id: 4, intervenant: "Mme Lefèvre", organisme: "Académie Ldsh.", groupe: "Groupe Négo", module: "Négociation commerciale", date: "2026-07-12", heuresPrevues: 14, heuresRealisees: 12, cdcRef: "CDC-2026-004", contratRef: "ST-CONT-2026-003" },
{ id: 5, intervenant: "M. Dupont", organisme: "FormaPro", groupe: "Groupe C", module: "Excel avancé", date: "2026-07-05", heuresPrevues: 10, heuresRealisees: 10, cdcRef: "CDC-2026-005", contratRef: "ST-CONT-2025-014" },
];
const CDC_MOCK: Record<string, { nomFichier: string; statutLecture: "lu" | "non_lu" }> = {
"CDC-2026-001": { nomFichier: "CDC_Dupont_Marketing_GroupeA.pdf", statutLecture: "lu" },
"CDC-2026-002": { nomFichier: "CDC_Martin_VenteDistribution_GroupeB.pdf", statutLecture: "lu" },
"CDC-2026-003": { nomFichier: "CDC_Smith_StrategieCommerciale.pdf", statutLecture: "lu" },
"CDC-2026-004": { nomFichier: "CDC_Lefevre_NegociationCommerciale.pdf", statutLecture: "non_lu" },
"CDC-2026-005": { nomFichier: "CDC_Dupont_ExcelAvance.pdf", statutLecture: "non_lu" },
};
const CONTRATS_MOCK: Record<string, { objet: string; dateDebut: string; dateFin: string; montant: number; statut: StatutContrat }> = {
"ST-CONT-2026-001": { objet: "Formations Marketing — S1 2026", dateDebut: "2026-01-01", dateFin: "2026-12-31", montant: 24500, statut: "ACTIF" },
"ST-CONT-2026-002": { objet: "Stratégie & négociation commerciale", dateDebut: "2025-07-01", dateFin: "2026-06-30", montant: 18000, statut: "A_RENOUVELER" },
"ST-CONT-2026-003": { objet: "Cycle Négociation commerciale", dateDebut: "2026-02-01", dateFin: "2027-02-01", montant: 13000, statut: "ACTIF" },
"ST-CONT-2025-014": { objet: "Bureautique — Excel avancé", dateDebut: "2025-01-01", dateFin: "2025-12-31", montant: 9800, statut: "EXPIRE" },
};
const fmtDate = (iso: string) => {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
const euro = (n: number) => `${n.toLocaleString("fr-FR")} €`;
const emptyForm = { intervenant: "", organisme: ORGANISMES[0], groupe: "", module: "", date: "", heuresPrevues: "", heuresRealisees: "" };
function PrestationDrawer({ prestation, onClose }: { prestation: Prestation; onClose: () => void }) {
const navigate = useNavigate();
const statut = statutDe(prestation);
const ecart = prestation.heuresRealisees - prestation.heuresPrevues;
const cdc = CDC_MOCK[prestation.cdcRef];
const contrat = CONTRATS_MOCK[prestation.contratRef];
return (
<div className="prs-drawer-overlay" onClick={onClose}>
<div className="prs-drawer" onClick={(e) => e.stopPropagation()}>
<div className="prs-drawer-header">
<div>
<p className="prs-eyebrow" style={{ margin: "0 0 4px" }}>{prestation.organisme} · {prestation.groupe}</p>
<h2 className="prs-drawer-title">{prestation.intervenant}</h2>
</div>
<button className="prs-drawer-close" onClick={onClose} aria-label="Fermer">×</button>
</div>
<span className="prs-status-pill" data-statut={statut}>{ECART_LABEL[statut]}</span>
<div className="prs-drawer-heures">
<div>
<p className="prs-detail-label">Heures réalisées</p>
<p className="prs-drawer-heures-value">{prestation.heuresRealisees} h</p>
</div>
<div>
<p className="prs-detail-label">Heures prévues</p>
<p className="prs-drawer-heures-value">{prestation.heuresPrevues} h</p>
</div>
<div>
<p className="prs-detail-label">Écart</p>
<p className={`prs-drawer-heures-value${ecart !== 0 ? " ecart" : ""}`}>{ecart > 0 ? "+" : ""}{ecart} h</p>
</div>
</div>
<div className="prs-drawer-grid">
<div>
<p className="prs-detail-label">Module</p>
<p className="prs-detail-value">{prestation.module}</p>
</div>
<div>
<p className="prs-detail-label">Date</p>
<p className="prs-detail-value">{fmtDate(prestation.date)}</p>
</div>
</div>
<p className="prs-detail-label" style={{ marginTop: 18 }}>Commentaire</p>
<p className="prs-drawer-desc">{prestation.commentaire ?? "Aucun commentaire."}</p>
<p className="prs-section-title">Cahier des charges lié</p>
{cdc ? (
<div className="prs-linked-card" onClick={() => navigate("/soustraitants/cahier-des-charges")}>
<div className="prs-linked-main">
<p className="prs-linked-title">📄 {cdc.nomFichier}</p>
<p className="prs-linked-sub">{prestation.cdcRef}</p>
</div>
<span className="prs-status-pill" data-lecture={cdc.statutLecture}>{cdc.statutLecture === "lu" ? "Lu" : "Non lu"}</span>
</div>
) : (
<p className="prs-drawer-empty-inline">Aucun cahier des charges lié.</p>
)}
<p className="prs-section-title">Contrat lié</p>
{contrat ? (
<div className="prs-linked-card" onClick={() => navigate("/soustraitants/contrats")}>
<div className="prs-linked-main">
<p className="prs-linked-title">{contrat.objet}</p>
<p className="prs-linked-sub">{prestation.contratRef} · {fmtDate(contrat.dateDebut)} → {fmtDate(contrat.dateFin)} · {euro(contrat.montant)}</p>
</div>
<span className="prs-contrat-status-pill" data-statut={contrat.statut}>{CONTRAT_STATUT_LABEL[contrat.statut]}</span>
</div>
) : (
<p className="prs-drawer-empty-inline">Aucun contrat lié.</p>
)}
</div>
</div>
);
}
export default function Prestations() {
const [items, setItems] = useState<Prestation[]>(INITIAL);
const [filter, setFilter] = useState<"TOUTES" | EcartStatut>("TOUTES");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const counts = useMemo(() => {
const base: Record<string, number> = { TOUTES: items.length };
(["conforme", "ecart_plus", "ecart_moins"] as EcartStatut[]).forEach((s) => (base[s] = items.filter((i) => statutDe(i) === s).length));
return base;
}, [items]);
const filtered = useMemo(() => (filter === "TOUTES" ? items : items.filter((i) => statutDe(i) === filter)), [items, filter]);
const selected = items.find((i) => i.id === selectedId) ?? null;
const update = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) => setForm((f) => ({ ...f, [key]: value }));
const create = () => {
if (!form.intervenant.trim() || !form.module.trim() || !form.date) return;
setItems((prev) => [
{
id: Date.now(), intervenant: form.intervenant.trim(), organisme: form.organisme, groupe: form.groupe.trim(),
module: form.module.trim(), date: form.date, heuresPrevues: parseFloat(form.heuresPrevues) || 0, heuresRealisees: parseFloat(form.heuresRealisees) || 0,
cdcRef: "", contratRef: "",
},
...prev,
]);
setForm(emptyForm);
setShowForm(false);
};
return (
<div className="prs-root">
<style>{css}</style>
<div className="prs-wrap">
<header className="prs-header">
<div>
<p className="prs-eyebrow">Sous-traitants · suivi</p>
<h1 className="prs-h1">Prestations</h1>
</div>
<button className="prs-btn-primary" onClick={() => setShowForm((v) => !v)}>+ Nouvelle prestation</button>
</header>
{showForm && (
<div className="prs-panel" style={{ marginBottom: 16 }}>
<p className="prs-panel-title">Nouvelle prestation</p>
<div className="prs-form-grid">
<div className="prs-field">
<label>Intervenant</label>
<input value={form.intervenant} onChange={(e) => update("intervenant", e.target.value)} placeholder="Ex. M. Dupont" />
</div>
<div className="prs-field">
<label>Organisme</label>
<select value={form.organisme} onChange={(e) => update("organisme", e.target.value)}>
{ORGANISMES.map((o) => <option key={o}>{o}</option>)}
</select>
</div>
<div className="prs-field">
<label>Groupe</label>
<input value={form.groupe} onChange={(e) => update("groupe", e.target.value)} placeholder="Ex. Groupe A" />
</div>
<div className="prs-field">
<label>Module</label>
<input value={form.module} onChange={(e) => update("module", e.target.value)} placeholder="Ex. Marketing opérationnel" />
</div>
<div className="prs-field">
<label>Date</label>
<input type="date" value={form.date} onChange={(e) => update("date", e.target.value)} />
</div>
<div className="prs-field">
<label>Heures prévues</label>
<input value={form.heuresPrevues} onChange={(e) => update("heuresPrevues", e.target.value)} placeholder="14" />
</div>
<div className="prs-field">
<label>Heures réalisées</label>
<input value={form.heuresRealisees} onChange={(e) => update("heuresRealisees", e.target.value)} placeholder="14" />
</div>
</div>
<div className="prs-form-actions">
<button className="prs-btn-ghost" onClick={() => { setForm(emptyForm); setShowForm(false); }}>Annuler</button>
<button className="prs-btn-primary" onClick={create}>Enregistrer</button>
</div>
</div>
)}
<div className="prs-filter-tabs">
{(["TOUTES", "conforme", "ecart_plus", "ecart_moins"] as const).map((s) => (
<button key={s} className={filter === s ? "active" : ""} onClick={() => setFilter(s)}>
{s === "TOUTES" ? "Toutes" : ECART_LABEL[s]} <span className="prs-tab-count">{counts[s] ?? 0}</span>
</button>
))}
</div>
<div className="prs-table-wrap">
<table className="prs-table">
<thead>
<tr>
<th>Intervenant</th><th>Organisme</th><th>Groupe / Module</th><th>Date</th>
<th style={{ textAlign: "right" }}>Heures</th><th style={{ textAlign: "right" }}>Statut</th>
</tr>
</thead>
<tbody>
{filtered.map((p) => {
const statut = statutDe(p);
return (
<tr key={p.id} className="prs-row" onClick={() => setSelectedId(p.id)}>
<td className="prs-objet-cell">{p.intervenant}</td>
<td>{p.organisme}</td>
<td>{p.groupe} — {p.module}</td>
<td>{fmtDate(p.date)}</td>
<td className="prs-amount">{p.heuresRealisees}h / {p.heuresPrevues}h prév.</td>
<td style={{ textAlign: "right" }}>
<span className="prs-status-pill" data-statut={statut}>{ECART_LABEL[statut]}</span>
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr><td colSpan={6} className="prs-empty">Aucune prestation dans cette catégorie.</td></tr>
)}
</tbody>
</table>
</div>
</div>
{selected && <PrestationDrawer prestation={selected} onClose={() => setSelectedId(null)} />}
</div>
);
}
const css = `
.prs-root{
--bg:#F5F4EF; --surface:#FFFFFF; --surface-2:#EFEDE6;
--text:#1C1E1B; --text-2:#65655D; --text-muted:#95948A; --border:#DEDCD3;
--accent:#3B6E52; --accent-light:#E4EEE7;
--success:#3B6E52; --success-light:#E4EEE7;
--warning:#9C6B1E; --warning-light:#F6ECD9;
--danger:#A63D34; --danger-light:#F5E4E1;
--radius:8px;
--mono:'IBM Plex Mono', ui-monospace, monospace;
--sans:'Inter', system-ui, sans-serif;
background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.5;
}
.prs-root *{ box-sizing:border-box; }
.prs-wrap{ max-width:1180px; margin:0 auto; }
.prs-header{ display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:24px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.prs-eyebrow{ font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-muted); margin:0 0 6px; }
.prs-h1{ font-size:24px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.prs-btn-primary{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; }
.prs-btn-primary:hover{ opacity:0.92; }
.prs-btn-ghost{ font-family:var(--sans); font-size:13px; padding:9px 16px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
.prs-btn-ghost:hover{ border-color:var(--accent); }
.prs-panel{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; }
.prs-panel-title{ font-size:15px; font-weight:600; margin:0 0 16px; }
.prs-form-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; }
.prs-field label{ font-size:12px; color:var(--text-2); display:block; margin-bottom:4px; }
.prs-field input, .prs-field select{ width:100%; font-family:var(--sans); font-size:13px; padding:8px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); }
.prs-field input:focus, .prs-field select:focus{ outline:none; border-color:var(--accent); }
.prs-form-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }
.prs-filter-tabs{ display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.prs-filter-tabs button{ font-family:var(--sans); font-size:12.5px; padding:7px 14px; border-radius:20px; border:1px solid var(--border); background:var(--surface); color:var(--text-2); cursor:pointer; display:flex; align-items:center; gap:6px; }
.prs-filter-tabs button.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.prs-tab-count{ font-size:11px; opacity:0.75; }
.prs-table-wrap{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.prs-table{ width:100%; border-collapse:collapse; font-size:13px; }
.prs-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); font-weight:500; padding:10px 16px; border-bottom:1px solid var(--border); }
.prs-table td{ padding:12px 16px; border-bottom:1px solid var(--border); }
.prs-table tbody tr:last-child td{ border-bottom:none; }
.prs-row{ cursor:pointer; }
.prs-row:hover{ background:var(--surface-2); }
.prs-objet-cell{ font-weight:500; }
.prs-amount{ font-family:var(--mono); text-align:right; }
.prs-empty{ text-align:center; color:var(--text-muted); padding:32px !important; }
.prs-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; }
.prs-status-pill[data-statut="conforme"]{ background:var(--success-light); color:var(--success); }
.prs-status-pill[data-statut="ecart_plus"]{ background:var(--warning-light); color:var(--warning); }
.prs-status-pill[data-statut="ecart_moins"]{ background:var(--danger-light); color:var(--danger); }
.prs-status-pill[data-lecture="non_lu"]{ background:var(--warning-light); color:var(--warning); }
.prs-status-pill[data-lecture="lu"]{ background:var(--success-light); color:var(--success); }
.prs-contrat-status-pill{ font-size:12px; font-weight:600; padding:3px 10px; border-radius:20px; white-space:nowrap; flex-shrink:0; }
.prs-contrat-status-pill[data-statut="ACTIF"]{ background:var(--success-light); color:var(--success); }
.prs-contrat-status-pill[data-statut="A_RENOUVELER"]{ background:var(--warning-light); color:var(--warning); }
.prs-contrat-status-pill[data-statut="EXPIRE"]{ background:var(--surface-2); color:var(--text-2); }
.prs-drawer-overlay{ position:fixed; inset:0; background:rgba(28,30,27,0.45); z-index:50; animation:prs-fade-in .18s ease; }
.prs-drawer{
position:fixed; top:0; right:0; bottom:0; width:100%; max-width:460px;
background:var(--surface); padding:28px 32px; overflow-y:auto;
box-shadow:-8px 0 32px rgba(20,20,15,0.18);
animation:prs-slide-in .22s ease;
}
@keyframes prs-fade-in{ from{ opacity:0; } to{ opacity:1; } }
@keyframes prs-slide-in{ from{ transform:translateX(100%); } to{ transform:translateX(0); } }
.prs-drawer-header{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:12px; }
.prs-drawer-title{ font-size:18px; font-weight:700; margin:0; letter-spacing:-0.01em; }
.prs-drawer-close{ font-family:var(--sans); font-size:18px; line-height:1; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px; flex-shrink:0; }
.prs-drawer-close:hover{ color:var(--text); }
.prs-drawer-heures{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; margin:16px 0 20px; padding:16px; background:var(--surface-2); border-radius:var(--radius); }
.prs-drawer-heures-value{ font-size:20px; font-weight:700; margin:0; font-variant-numeric:tabular-nums; }
.prs-drawer-heures-value.ecart{ color:var(--warning); }
.prs-drawer-grid{ display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; padding-bottom:16px; border-bottom:1px solid var(--border); }
.prs-detail-label{ font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:0 0 4px; }
.prs-detail-value{ font-size:13.5px; font-weight:600; color:var(--text); margin:0; }
.prs-drawer-desc{ font-size:13.5px; color:var(--text-2); margin:0; background:var(--surface-2); border-radius:8px; padding:12px 14px; }
.prs-drawer-empty-inline{ font-size:13px; color:var(--text-muted); margin:0; }
.prs-section-title{ font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-muted); margin:20px 0 8px; }
.prs-linked-card{ display:flex; align-items:center; gap:10px; background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; cursor:pointer; }
.prs-linked-card:hover{ border-color:var(--accent); }
.prs-linked-main{ flex:1; min-width:0; }
.prs-linked-title{ font-size:13.5px; font-weight:600; color:var(--text); margin:0; }
.prs-linked-sub{ font-size:12px; color:var(--text-muted); margin:2px 0 0; }
@media (max-width:900px){
.prs-form-grid{ grid-template-columns:1fr; }
}
@media (max-width:520px){
.prs-drawer{ max-width:100%; }
}
`;
+78
View File
@@ -0,0 +1,78 @@
// src/theme.ts — thème clair professionnel (beige + bleu marine)
import { createTheme } from '@mui/material/styles';
import { frFR } from '@mui/material/locale';
export const tk = {
canvas: '#f5f4ef', // fond de page (beige, ex: #f4f5f7)
surface: '#ffffff',
hoverBg: '#efede6', // fond de survol / encadrés discrets (ex: #f8fafc)
ink: '#1c1e1b', // texte principal (ex: #0f172a)
muted: '#65655d', // texte secondaire (ex: #64748b)
faint: '#95948a', // texte discret (ex: #94a3b8)
line: '#dedcd3', // bordures (ex: #e2e8f0)
lineSoft: '#efede6', // bordures/fonds légers, badges (ex: #eef1f5)
primary: '#2e4a6b', // bleu marine (ex: #4f46e5)
primaryDark: '#25405c', // hover du bouton primaire (ex: #4338ca)
primarySoft: '#e7edf3', // fond clair de l'accent, item de menu actif (ex: #eef2ff)
green: '#3b6e52', greenSoft: '#e4eee7',
amber: '#9c6b1e', amberSoft: '#f6ecd9',
red: '#a63d34', redSoft: '#f5e4e1',
violet: '#5b4b8a', violetSoft: '#ede9f6',
mono: "'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace",
};
export const theme = createTheme(
{
palette: {
mode: 'light',
primary: { main: tk.primary, dark: tk.primaryDark },
success: { main: tk.green },
background: { default: tk.canvas, paper: tk.surface },
text: { primary: tk.ink, secondary: tk.muted },
divider: tk.line,
},
shape: { borderRadius: 10 },
typography: {
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
h5: { fontWeight: 700, letterSpacing: '-0.02em', fontSize: 22 },
h6: { fontWeight: 700, letterSpacing: '-0.01em' },
button: { textTransform: 'none', fontWeight: 600 },
},
components: {
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
border: `1px solid ${tk.line}`,
boxShadow: '0 1px 2px rgba(16,24,40,0.04)',
},
},
},
MuiButton: {
styleOverrides: { root: { textTransform: 'none', fontWeight: 600, borderRadius: 8 } },
},
MuiTableCell: {
styleOverrides: {
head: {
color: tk.muted, fontWeight: 600, fontSize: 12, letterSpacing: '0.03em',
textTransform: 'uppercase', borderColor: tk.line, background: tk.surface,
},
root: { borderColor: tk.lineSoft, fontSize: 14 },
},
},
MuiDrawer: {
styleOverrides: { paper: { backgroundColor: tk.surface, borderColor: tk.line } },
},
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 8, margin: '2px 10px',
'&.Mui-selected': { backgroundColor: tk.primarySoft, '&:hover': { backgroundColor: tk.primarySoft } },
},
},
},
MuiChip: { styleOverrides: { root: { fontWeight: 600, borderRadius: 6 } } },
},
},
frFR,
);
+134
View File
@@ -0,0 +1,134 @@
// src/ui.tsx — helpers de format + composants partagés
import type { ReactNode } from 'react';
import { Box, Paper, Stack, Typography, Chip } from '@mui/material';
import { tk } from './theme';
import type { StatutDemande, Echeance, Alerte } from './data';
export const euro = (n: number, digits = 0) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: digits }).format(n);
export const dateFr = (iso: string) => new Date(iso).toLocaleDateString('fr-FR');
// Référence en style « code » monospace
export function Ref({ children }: { children: ReactNode }) {
return <Box component="span" sx={{ fontFamily: tk.mono, fontSize: 13, color: tk.muted, letterSpacing: '-0.02em' }}>{children}</Box>;
}
// Montant aligné, chiffres tabulaires
export function Montant({ value, bold }: { value: number; bold?: boolean }) {
return <Box component="span" sx={{ fontVariantNumeric: 'tabular-nums', fontWeight: bold ? 700 : 500 }}>{euro(value)}</Box>;
}
const STATUT: Partial<Record<StatutDemande, { l: string; c: string; b: string }>> = {
BROUILLON: { l: 'Brouillon', c: tk.muted, b: '#f1f5f9' },
SOUMISE: { l: 'Soumise', c: tk.primary, b: tk.primarySoft },
EN_VALIDATION: { l: 'À valider', c: tk.amber, b: tk.amberSoft },
COMPLEMENT_REQUIS: { l: 'Complément', c: tk.violet, b: tk.violetSoft },
VALIDEE: { l: 'Validée', c: tk.green, b: tk.greenSoft },
REFUSEE: { l: 'Refusée', c: tk.red, b: tk.redSoft },
FACTUREE: { l: 'Facturée', c: tk.primary, b: tk.primarySoft },
ANNULEE: { l: 'Annulée', c: tk.muted, b: '#f1f5f9' },
ARCHIVEE: { l: 'Archivée', c: tk.muted, b: '#f1f5f9' },
};
export function StatutChip({ statut }: { statut: StatutDemande }) {
const s = STATUT[statut] ?? { l: statut.replaceAll('_', ' '), c: tk.muted, b: '#f1f5f9' };
return <Chip label={s.l} size="small" sx={{ bgcolor: s.b, color: s.c, height: 22 }} />;
}
export function KpiCard({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) {
return (
<Paper sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography sx={{ fontSize: 13, color: tk.muted }}>{label}</Typography>
<Typography sx={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', mt: 0.5, fontVariantNumeric: 'tabular-nums' }}>{value}</Typography>
{sub && <Typography sx={{ fontSize: 12, mt: 0.5, color: subColor ?? tk.faint }}>{sub}</Typography>}
</Paper>
);
}
export function Panel({ title, badge, action, children, flex }: {
title: string; badge?: number; action?: ReactNode; children: ReactNode; flex?: number;
}) {
return (
<Paper sx={{ flex: flex ?? 1, overflow: 'hidden' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center"
sx={{ px: 2.5, py: 1.75, borderBottom: `1px solid ${tk.line}` }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography sx={{ fontWeight: 600, fontSize: 15 }}>{title}</Typography>
{badge != null && <Chip label={badge} size="small" sx={{ height: 20, minWidth: 20, bgcolor: tk.lineSoft, color: tk.muted }} />}
</Stack>
{action}
</Stack>
<Box sx={{ p: 2.5 }}>{children}</Box>
</Paper>
);
}
export function Bar({ label, value, max, display }: { label: string; value: number; max: number; display: string }) {
const pct = max > 0 ? Math.max(2, Math.round((value / max) * 100)) : 0;
return (
<Box sx={{ mb: 1.75 }}>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.75 }}>
<Typography sx={{ fontSize: 13 }}>{label}</Typography>
<Typography sx={{ fontSize: 13, color: tk.muted, fontVariantNumeric: 'tabular-nums' }}>{display}</Typography>
</Stack>
<Box sx={{ height: 6, borderRadius: 3, bgcolor: tk.lineSoft }}>
<Box sx={{ height: '100%', width: `${pct}%`, borderRadius: 3, bgcolor: tk.primary }} />
</Box>
</Box>
);
}
export function EcheanceRow({ e, onClick }: { e: Echeance; onClick?: () => void }) {
const color = e.etat === 'echu' ? tk.red : e.etat === 'proche' ? tk.amber : tk.muted;
return (
<Stack direction="row" alignItems="center" spacing={1.5} onClick={onClick}
sx={{
py: 1.25, borderRadius: 1,
'&:not(:last-of-type)': { borderBottom: `1px solid ${tk.lineSoft}` },
...(onClick && { cursor: 'pointer', mx: -1, px: 1, '&:hover': { bgcolor: tk.hoverBg } }),
}}>
<Box sx={{ width: 46, textAlign: 'center', py: 0.5, borderRadius: 1.5, border: `1px solid ${tk.line}` }}>
<Typography sx={{ fontWeight: 700, fontSize: 15, lineHeight: 1.1, color }}>{e.jour}</Typography>
<Typography sx={{ fontSize: 10, color: tk.muted }}>{e.mois}</Typography>
</Box>
<Box flex={1} sx={{ minWidth: 0 }}>
<Typography sx={{ fontSize: 14, fontWeight: 600 }} noWrap>{e.fournisseur}</Typography>
<Ref>{e.ref}</Ref>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography sx={{ fontSize: 14, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{euro(e.montant)}</Typography>
<Typography sx={{ fontSize: 11, color }}>{e.etatLabel}</Typography>
</Box>
</Stack>
);
}
export function AlerteRow({ a, onClick }: { a: Alerte; onClick?: () => void }) {
const dot = a.couleur === 'warn' ? tk.amber : a.couleur === 'error' ? tk.red : a.couleur === 'info' ? tk.primary : tk.violet;
return (
<Stack direction="row" spacing={1.5} onClick={onClick}
sx={{
py: 1.25, borderRadius: 1,
'&:not(:last-of-type)': { borderBottom: `1px solid ${tk.lineSoft}` },
...(onClick && { cursor: 'pointer', mx: -1, px: 1, '&:hover': { bgcolor: tk.hoverBg } }),
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: dot, mt: 0.75, flex: 'none' }} />
<Box>
<Typography sx={{ fontSize: 14, fontWeight: 600 }}>{a.titre}</Typography>
<Typography sx={{ fontSize: 12, color: tk.muted }}>{a.detail}</Typography>
</Box>
</Stack>
);
}
// Badge logo réutilisable
export function Logo({ size = 40 }: { size?: number }) {
return (
<Box sx={{
width: size, height: size, borderRadius: 2.5, flex: 'none',
background: `linear-gradient(135deg, ${tk.primary}, #6366f1)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, color: '#fff', fontSize: size * 0.38,
}}>SI</Box>
);
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})