Premier commit
This commit is contained in:
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+3611
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
},
|
||||
};
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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); }
|
||||
`;
|
||||
@@ -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; }
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
`;
|
||||
@@ -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%; }
|
||||
}
|
||||
`;
|
||||
@@ -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; }
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
`;
|
||||
@@ -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; }
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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%; }
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user