Initial commit - Facturation SANTINOVA
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
backend/node_modules
|
||||||
|
backend/dist
|
||||||
|
backend/uploads/*
|
||||||
|
!backend/uploads/.gitkeep
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
15
.env
Normal file
15
.env
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Facturation SANTINOVA - Configuration Docker
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
DB_ROOT_PASSWORD=santinova_db_pass
|
||||||
|
DB_PORT=3306
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_PORT=8080
|
||||||
|
JWT_SECRET=santinova-jwt-secret-change-in-production
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
|
# OpenAI API (pour l'OCR)
|
||||||
|
OPENAI_API_KEY=your-openai-api-key-here
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
backend/uploads/*
|
||||||
|
!backend/uploads/.gitkeep
|
||||||
74
Dockerfile
Normal file
74
Dockerfile
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# ============================================================
|
||||||
|
# STAGE 1: Build Frontend
|
||||||
|
# ============================================================
|
||||||
|
FROM node:22-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml* frontend/yarn.lock* frontend/package-lock.json* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# STAGE 2: Build Backend
|
||||||
|
# ============================================================
|
||||||
|
FROM node:22-alpine AS backend-build
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY backend/package.json backend/pnpm-lock.yaml* backend/yarn.lock* backend/package-lock.json* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# STAGE 3: Production
|
||||||
|
# ============================================================
|
||||||
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
|
# Install necessary system packages
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy backend build and dependencies
|
||||||
|
COPY --from=backend-build /app/backend/dist ./backend/dist
|
||||||
|
COPY --from=backend-build /app/backend/node_modules ./backend/node_modules
|
||||||
|
COPY --from=backend-build /app/backend/package.json ./backend/
|
||||||
|
|
||||||
|
# Copy frontend build
|
||||||
|
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3001
|
||||||
|
ENV UPLOAD_DIR=/app/uploads
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3001/api/health || exit 1
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
WORKDIR /app/backend
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
16
Dockerfile.migrate
Normal file
16
Dockerfile.migrate
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Dockerfile for database migration and seeding
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY backend/package.json backend/pnpm-lock.yaml* backend/yarn.lock* backend/package-lock.json* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# Run migration then seed
|
||||||
|
CMD ["sh", "-c", "npx tsx src/db/migrate.ts && npx tsx src/db/seed.ts"]
|
||||||
236
README.md
Normal file
236
README.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Facturation SANTINOVA
|
||||||
|
|
||||||
|
Application web complète de gestion de la réception et du traitement des factures pour l'entreprise SANTINOVA.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Réception multi-canal des factures
|
||||||
|
- Upload manuel (PDF, images, scans)
|
||||||
|
- Préparation pour réception par email (intégration future)
|
||||||
|
- Préparation pour import depuis portail fournisseur (intégration future)
|
||||||
|
- Support des documents scannés
|
||||||
|
|
||||||
|
### Extraction automatique par OCR/IA
|
||||||
|
- Extraction via OpenAI Vision API (GPT-4.1-mini)
|
||||||
|
- Détection automatique : fournisseur, numéro de facture, dates, montants
|
||||||
|
- Extraction des lignes de détail
|
||||||
|
- Score de confiance OCR
|
||||||
|
- Création automatique des fournisseurs détectés
|
||||||
|
|
||||||
|
### Classement et archivage
|
||||||
|
- Classement par fournisseur, date, statut
|
||||||
|
- Archivage des documents originaux (PDF/images)
|
||||||
|
- Recherche full-text sur le contenu des factures
|
||||||
|
|
||||||
|
### Workflow de validation/approbation
|
||||||
|
- Statuts : Reçue → En vérification → Validée → Approuvée → Payée → Archivée
|
||||||
|
- Rôles : Opérateur, Validateur, Approbateur, Administrateur
|
||||||
|
- Historique complet des actions (audit log)
|
||||||
|
- Contrôle des permissions par rôle
|
||||||
|
|
||||||
|
### Rapprochement avec bons de commande
|
||||||
|
- Gestion complète des bons de commande
|
||||||
|
- Rapprochement automatique/manuel facture ↔ bon de commande
|
||||||
|
- Détection des écarts de montant
|
||||||
|
- Alertes en cas d'écart significatif
|
||||||
|
|
||||||
|
### Suivi des échéances de paiement
|
||||||
|
- Tableau de bord des échéances
|
||||||
|
- Alertes pour factures en retard
|
||||||
|
- Alertes pour factures proches de l'échéance (7 jours)
|
||||||
|
- Statistiques par fournisseur et par période
|
||||||
|
|
||||||
|
### Exports comptables
|
||||||
|
- Export CSV (compatible tableurs)
|
||||||
|
- Export Excel (XLSX formaté)
|
||||||
|
- Module préparatoire CEGI Compta First (intégration future)
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Vue d'ensemble : KPIs, factures en attente, montants
|
||||||
|
- Graphiques : répartition par statut, évolution mensuelle, top fournisseurs
|
||||||
|
- Actions en attente selon le rôle de l'utilisateur
|
||||||
|
|
||||||
|
### Authentification et gestion des utilisateurs
|
||||||
|
- Authentification JWT
|
||||||
|
- Gestion des rôles et permissions
|
||||||
|
- CRUD utilisateurs (admin uniquement)
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Composant | Technologie |
|
||||||
|
|-----------|------------|
|
||||||
|
| Backend | Node.js + Express + TypeScript |
|
||||||
|
| Frontend | React + Vite + TypeScript + Tailwind CSS |
|
||||||
|
| Base de données | MySQL 8.0 avec Drizzle ORM |
|
||||||
|
| OCR/IA | OpenAI Vision API (GPT-4.1-mini) |
|
||||||
|
| Graphiques | Chart.js + react-chartjs-2 |
|
||||||
|
| Authentification | JWT (jsonwebtoken + bcryptjs) |
|
||||||
|
| Upload | Multer |
|
||||||
|
| Export | xlsx (SheetJS) |
|
||||||
|
| Conteneurisation | Docker + Docker Compose |
|
||||||
|
|
||||||
|
## Déploiement avec Docker
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
- Docker et Docker Compose installés
|
||||||
|
- Clé API OpenAI (optionnelle, pour l'OCR)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. Copier et modifier le fichier `.env` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Modifier les variables dans `.env.local` :
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_ROOT_PASSWORD=votre_mot_de_passe_db
|
||||||
|
JWT_SECRET=votre_secret_jwt_unique
|
||||||
|
OPENAI_API_KEY=votre_cle_api_openai
|
||||||
|
APP_PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lancement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Construire et démarrer tous les services
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Vérifier les logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Arrêter les services
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accès
|
||||||
|
|
||||||
|
- **Application** : http://localhost:8080
|
||||||
|
- **API** : http://localhost:8080/api
|
||||||
|
- **Health check** : http://localhost:8080/api/health
|
||||||
|
|
||||||
|
### Compte administrateur par défaut
|
||||||
|
|
||||||
|
| Champ | Valeur |
|
||||||
|
|-------|--------|
|
||||||
|
| Email | `adminItinova@santinova-soft.org` |
|
||||||
|
| Mot de passe | `Itinova69!` |
|
||||||
|
|
||||||
|
## Développement local
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# Modifier .env avec vos paramètres
|
||||||
|
npm run db:migrate
|
||||||
|
npm run db:seed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend est accessible sur http://localhost:5173 et proxifie les appels API vers le backend sur le port 3001.
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
facturation-santinova/
|
||||||
|
├── backend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── config/ # Configuration DB
|
||||||
|
│ │ ├── db/ # Schéma, migrations, seed
|
||||||
|
│ │ ├── middleware/ # Auth JWT, upload Multer
|
||||||
|
│ │ ├── routes/ # Routes API Express
|
||||||
|
│ │ ├── services/ # Services (OCR, audit)
|
||||||
|
│ │ └── index.ts # Point d'entrée serveur
|
||||||
|
│ ├── uploads/ # Stockage des fichiers
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # Composants React (Layout)
|
||||||
|
│ │ ├── context/ # Contexte Auth
|
||||||
|
│ │ ├── pages/ # Pages de l'application
|
||||||
|
│ │ ├── services/ # Client API Axios
|
||||||
|
│ │ ├── types/ # Types TypeScript
|
||||||
|
│ │ └── utils/ # Utilitaires (formatage)
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
├── Dockerfile # Build multi-stage production
|
||||||
|
├── Dockerfile.migrate # Migration et seed DB
|
||||||
|
├── docker-compose.yml # Orchestration Docker
|
||||||
|
├── .env # Variables d'environnement
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
- `POST /api/auth/login` - Connexion
|
||||||
|
- `GET /api/auth/me` - Profil utilisateur courant
|
||||||
|
- `GET /api/auth/users` - Liste des utilisateurs (admin)
|
||||||
|
- `POST /api/auth/users` - Créer un utilisateur (admin)
|
||||||
|
- `PUT /api/auth/users/:id` - Modifier un utilisateur (admin)
|
||||||
|
- `DELETE /api/auth/users/:id` - Désactiver un utilisateur (admin)
|
||||||
|
|
||||||
|
### Factures
|
||||||
|
- `GET /api/invoices` - Liste des factures (paginée, filtrable)
|
||||||
|
- `GET /api/invoices/stats` - Statistiques
|
||||||
|
- `GET /api/invoices/:id` - Détail d'une facture
|
||||||
|
- `POST /api/invoices/upload` - Upload et OCR
|
||||||
|
- `PUT /api/invoices/:id` - Modifier une facture
|
||||||
|
- `PUT /api/invoices/:id/lines` - Modifier les lignes
|
||||||
|
- `POST /api/invoices/:id/status` - Changer le statut (workflow)
|
||||||
|
- `POST /api/invoices/:id/match` - Rapprochement BC
|
||||||
|
- `GET /api/invoices/:id/file` - Télécharger le fichier original
|
||||||
|
- `DELETE /api/invoices/:id` - Supprimer (admin)
|
||||||
|
|
||||||
|
### Fournisseurs
|
||||||
|
- `GET /api/suppliers` - Liste
|
||||||
|
- `GET /api/suppliers/:id` - Détail
|
||||||
|
- `POST /api/suppliers` - Créer
|
||||||
|
- `PUT /api/suppliers/:id` - Modifier
|
||||||
|
- `DELETE /api/suppliers/:id` - Désactiver
|
||||||
|
|
||||||
|
### Bons de commande
|
||||||
|
- `GET /api/purchase-orders` - Liste
|
||||||
|
- `GET /api/purchase-orders/:id` - Détail
|
||||||
|
- `POST /api/purchase-orders` - Créer
|
||||||
|
- `PUT /api/purchase-orders/:id` - Modifier
|
||||||
|
- `DELETE /api/purchase-orders/:id` - Supprimer
|
||||||
|
|
||||||
|
### Exports
|
||||||
|
- `GET /api/exports/invoices/csv` - Export CSV
|
||||||
|
- `GET /api/exports/invoices/excel` - Export Excel
|
||||||
|
- `GET /api/exports/cegi` - Export CEGI (préparatoire)
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- `GET /api/dashboard` - Données du tableau de bord
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- `GET /api/notifications` - Liste
|
||||||
|
- `PUT /api/notifications/:id/read` - Marquer comme lue
|
||||||
|
- `PUT /api/notifications/read-all` - Tout marquer comme lu
|
||||||
|
- `GET /api/notifications/count` - Compteur non lues
|
||||||
|
|
||||||
|
## Intégrations futures prévues
|
||||||
|
|
||||||
|
- **CEGI Compta First** : Module d'export comptable complet
|
||||||
|
- **Réception email** : Parsing automatique des factures reçues par email
|
||||||
|
- **Portail fournisseur** : Import automatisé depuis les portails
|
||||||
|
- **Notifications email** : Alertes par email pour les échéances
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Propriétaire - SANTINOVA
|
||||||
7
backend/.env.example
Normal file
7
backend/.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
PORT=3001
|
||||||
|
DATABASE_URL=mysql://root:santinova_db_pass@localhost:3306/facturation_santinova
|
||||||
|
JWT_SECRET=santinova-jwt-secret-change-in-production
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
|
OPENAI_API_KEY=your-openai-api-key-here
|
||||||
|
NODE_ENV=development
|
||||||
11
backend/drizzle.config.ts
Normal file
11
backend/drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'mysql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova',
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
3500
backend/package-lock.json
generated
Normal file
3500
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/package.json
Normal file
39
backend/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "facturation-santinova-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend API pour Facturation SANTINOVA",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
|
"db:seed": "tsx src/db/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"drizzle-orm": "^0.35.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.11.0",
|
||||||
|
"openai": "^4.60.0",
|
||||||
|
"uuid": "^10.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"drizzle-kit": "^0.27.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/src/config/database.ts
Normal file
27
backend/src/config/database.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/mysql2';
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import * as schema from '../db/schema';
|
||||||
|
|
||||||
|
let db: ReturnType<typeof drizzle>;
|
||||||
|
let pool: mysql.Pool;
|
||||||
|
|
||||||
|
export async function initDatabase() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova';
|
||||||
|
|
||||||
|
pool = mysql.createPool(dbUrl);
|
||||||
|
db = drizzle(pool, { schema, mode: 'default' });
|
||||||
|
|
||||||
|
console.log('✅ Connexion à la base de données établie');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb() {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Base de données non initialisée. Appelez initDatabase() d\'abord.');
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPool() {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
196
backend/src/db/migrate.ts
Normal file
196
backend/src/db/migrate.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import { drizzle } from 'drizzle-orm/mysql2';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova';
|
||||||
|
|
||||||
|
// Parse URL to get database name
|
||||||
|
const url = new URL(dbUrl);
|
||||||
|
const dbName = url.pathname.slice(1);
|
||||||
|
const baseUrl = `${url.protocol}//${url.username}:${url.password}@${url.host}`;
|
||||||
|
|
||||||
|
// Create database if not exists
|
||||||
|
const tempPool = mysql.createPool(baseUrl);
|
||||||
|
await tempPool.execute(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||||
|
await tempPool.end();
|
||||||
|
|
||||||
|
// Connect to the database
|
||||||
|
const pool = mysql.createPool(dbUrl);
|
||||||
|
const db = drizzle(pool, { schema, mode: 'default' });
|
||||||
|
|
||||||
|
console.log('🔄 Création des tables...');
|
||||||
|
|
||||||
|
// Create tables using raw SQL
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
role ENUM('admin', 'approbateur', 'validateur', 'operateur') NOT NULL DEFAULT 'operateur',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS suppliers (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
siret VARCHAR(20),
|
||||||
|
address TEXT,
|
||||||
|
city VARCHAR(100),
|
||||||
|
postal_code VARCHAR(10),
|
||||||
|
country VARCHAR(100) DEFAULT 'France',
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
contact_name VARCHAR(255),
|
||||||
|
iban VARCHAR(50),
|
||||||
|
notes TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
INDEX supplier_name_idx (name),
|
||||||
|
INDEX supplier_siret_idx (siret)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_orders (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
order_number VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
supplier_id INT,
|
||||||
|
supplier_name VARCHAR(255),
|
||||||
|
order_date DATETIME NOT NULL,
|
||||||
|
expected_delivery_date DATETIME,
|
||||||
|
amount_ht DECIMAL(12,2),
|
||||||
|
amount_tva DECIMAL(12,2),
|
||||||
|
amount_ttc DECIMAL(12,2),
|
||||||
|
status ENUM('brouillon', 'envoyee', 'recue', 'facturee', 'annulee') NOT NULL DEFAULT 'brouillon',
|
||||||
|
description TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_by INT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
INDEX po_order_number_idx (order_number),
|
||||||
|
INDEX po_supplier_idx (supplier_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
invoice_number VARCHAR(100),
|
||||||
|
supplier_id INT,
|
||||||
|
supplier_name VARCHAR(255),
|
||||||
|
supplier_siret VARCHAR(20),
|
||||||
|
supplier_address TEXT,
|
||||||
|
invoice_date DATETIME,
|
||||||
|
due_date DATETIME,
|
||||||
|
reception_date DATETIME NOT NULL,
|
||||||
|
amount_ht DECIMAL(12,2),
|
||||||
|
amount_tva DECIMAL(12,2),
|
||||||
|
amount_ttc DECIMAL(12,2),
|
||||||
|
tva_rate DECIMAL(5,2),
|
||||||
|
currency VARCHAR(3) DEFAULT 'EUR',
|
||||||
|
status ENUM('recue', 'en_verification', 'validee', 'approuvee', 'payee', 'rejetee', 'archivee') NOT NULL DEFAULT 'recue',
|
||||||
|
source ENUM('upload', 'email', 'portail', 'scan') NOT NULL DEFAULT 'upload',
|
||||||
|
original_file_name VARCHAR(500),
|
||||||
|
file_path VARCHAR(1000),
|
||||||
|
file_type VARCHAR(50),
|
||||||
|
ocr_raw_data JSON,
|
||||||
|
ocr_confidence DECIMAL(5,2),
|
||||||
|
purchase_order_id INT,
|
||||||
|
matching_status ENUM('non_rapproche', 'rapproche', 'ecart_detecte') DEFAULT 'non_rapproche',
|
||||||
|
matching_notes TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
full_text TEXT,
|
||||||
|
created_by INT,
|
||||||
|
validated_by INT,
|
||||||
|
approved_by INT,
|
||||||
|
validated_at DATETIME,
|
||||||
|
approved_at DATETIME,
|
||||||
|
paid_at DATETIME,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
INDEX invoice_status_idx (status),
|
||||||
|
INDEX invoice_supplier_idx (supplier_id),
|
||||||
|
INDEX invoice_due_date_idx (due_date),
|
||||||
|
INDEX invoice_number_idx (invoice_number),
|
||||||
|
FULLTEXT INDEX invoice_fulltext_idx (full_text)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
invoice_id INT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
quantity DECIMAL(10,3),
|
||||||
|
unit_price DECIMAL(12,2),
|
||||||
|
amount_ht DECIMAL(12,2),
|
||||||
|
tva_rate DECIMAL(5,2),
|
||||||
|
amount_tva DECIMAL(12,2),
|
||||||
|
amount_ttc DECIMAL(12,2),
|
||||||
|
line_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_order_lines (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
purchase_order_id INT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
quantity DECIMAL(10,3),
|
||||||
|
unit_price DECIMAL(12,2),
|
||||||
|
amount_ht DECIMAL(12,2),
|
||||||
|
line_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (purchase_order_id) REFERENCES purchase_orders(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id INT NOT NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
details JSON,
|
||||||
|
user_id INT,
|
||||||
|
user_name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
INDEX audit_entity_idx (entity_type, entity_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
type ENUM('info', 'warning', 'error', 'success') DEFAULT 'info',
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
link VARCHAR(500),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ Tables créées avec succès');
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((err) => {
|
||||||
|
console.error('❌ Erreur de migration:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
265
backend/src/db/schema.ts
Normal file
265
backend/src/db/schema.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import {
|
||||||
|
mysqlTable,
|
||||||
|
varchar,
|
||||||
|
int,
|
||||||
|
text,
|
||||||
|
decimal,
|
||||||
|
datetime,
|
||||||
|
boolean,
|
||||||
|
mysqlEnum,
|
||||||
|
json,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from 'drizzle-orm/mysql-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// UTILISATEURS
|
||||||
|
// ============================================================
|
||||||
|
export const users = mysqlTable('users', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
|
password: varchar('password', { length: 255 }).notNull(),
|
||||||
|
firstName: varchar('first_name', { length: 100 }).notNull(),
|
||||||
|
lastName: varchar('last_name', { length: 100 }).notNull(),
|
||||||
|
role: mysqlEnum('role', ['admin', 'approbateur', 'validateur', 'operateur']).notNull().default('operateur'),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FOURNISSEURS
|
||||||
|
// ============================================================
|
||||||
|
export const suppliers = mysqlTable('suppliers', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
siret: varchar('siret', { length: 20 }),
|
||||||
|
address: text('address'),
|
||||||
|
city: varchar('city', { length: 100 }),
|
||||||
|
postalCode: varchar('postal_code', { length: 10 }),
|
||||||
|
country: varchar('country', { length: 100 }).default('France'),
|
||||||
|
email: varchar('email', { length: 255 }),
|
||||||
|
phone: varchar('phone', { length: 20 }),
|
||||||
|
contactName: varchar('contact_name', { length: 255 }),
|
||||||
|
iban: varchar('iban', { length: 50 }),
|
||||||
|
notes: text('notes'),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
nameIdx: index('supplier_name_idx').on(table.name),
|
||||||
|
siretIdx: index('supplier_siret_idx').on(table.siret),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FACTURES
|
||||||
|
// ============================================================
|
||||||
|
export const invoices = mysqlTable('invoices', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
invoiceNumber: varchar('invoice_number', { length: 100 }),
|
||||||
|
supplierId: int('supplier_id'),
|
||||||
|
supplierName: varchar('supplier_name', { length: 255 }),
|
||||||
|
supplierSiret: varchar('supplier_siret', { length: 20 }),
|
||||||
|
supplierAddress: text('supplier_address'),
|
||||||
|
|
||||||
|
invoiceDate: datetime('invoice_date'),
|
||||||
|
dueDate: datetime('due_date'),
|
||||||
|
receptionDate: datetime('reception_date').notNull(),
|
||||||
|
|
||||||
|
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
|
||||||
|
amountTVA: decimal('amount_tva', { precision: 12, scale: 2 }),
|
||||||
|
amountTTC: decimal('amount_ttc', { precision: 12, scale: 2 }),
|
||||||
|
tvaRate: decimal('tva_rate', { precision: 5, scale: 2 }),
|
||||||
|
currency: varchar('currency', { length: 3 }).default('EUR'),
|
||||||
|
|
||||||
|
status: mysqlEnum('status', [
|
||||||
|
'recue',
|
||||||
|
'en_verification',
|
||||||
|
'validee',
|
||||||
|
'approuvee',
|
||||||
|
'payee',
|
||||||
|
'rejetee',
|
||||||
|
'archivee'
|
||||||
|
]).notNull().default('recue'),
|
||||||
|
|
||||||
|
source: mysqlEnum('source', ['upload', 'email', 'portail', 'scan']).notNull().default('upload'),
|
||||||
|
|
||||||
|
originalFileName: varchar('original_file_name', { length: 500 }),
|
||||||
|
filePath: varchar('file_path', { length: 1000 }),
|
||||||
|
fileType: varchar('file_type', { length: 50 }),
|
||||||
|
|
||||||
|
ocrRawData: json('ocr_raw_data'),
|
||||||
|
ocrConfidence: decimal('ocr_confidence', { precision: 5, scale: 2 }),
|
||||||
|
|
||||||
|
purchaseOrderId: int('purchase_order_id'),
|
||||||
|
matchingStatus: mysqlEnum('matching_status', ['non_rapproche', 'rapproche', 'ecart_detecte']).default('non_rapproche'),
|
||||||
|
matchingNotes: text('matching_notes'),
|
||||||
|
|
||||||
|
notes: text('notes'),
|
||||||
|
fullText: text('full_text'),
|
||||||
|
|
||||||
|
createdBy: int('created_by'),
|
||||||
|
validatedBy: int('validated_by'),
|
||||||
|
approvedBy: int('approved_by'),
|
||||||
|
validatedAt: datetime('validated_at'),
|
||||||
|
approvedAt: datetime('approved_at'),
|
||||||
|
paidAt: datetime('paid_at'),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
statusIdx: index('invoice_status_idx').on(table.status),
|
||||||
|
supplierIdx: index('invoice_supplier_idx').on(table.supplierId),
|
||||||
|
dueDateIdx: index('invoice_due_date_idx').on(table.dueDate),
|
||||||
|
invoiceNumberIdx: index('invoice_number_idx').on(table.invoiceNumber),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LIGNES DE FACTURE
|
||||||
|
// ============================================================
|
||||||
|
export const invoiceLines = mysqlTable('invoice_lines', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
invoiceId: int('invoice_id').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
quantity: decimal('quantity', { precision: 10, scale: 3 }),
|
||||||
|
unitPrice: decimal('unit_price', { precision: 12, scale: 2 }),
|
||||||
|
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
|
||||||
|
tvaRate: decimal('tva_rate', { precision: 5, scale: 2 }),
|
||||||
|
amountTVA: decimal('amount_tva', { precision: 12, scale: 2 }),
|
||||||
|
amountTTC: decimal('amount_ttc', { precision: 12, scale: 2 }),
|
||||||
|
lineOrder: int('line_order').default(0),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BONS DE COMMANDE
|
||||||
|
// ============================================================
|
||||||
|
export const purchaseOrders = mysqlTable('purchase_orders', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
orderNumber: varchar('order_number', { length: 100 }).notNull().unique(),
|
||||||
|
supplierId: int('supplier_id'),
|
||||||
|
supplierName: varchar('supplier_name', { length: 255 }),
|
||||||
|
|
||||||
|
orderDate: datetime('order_date').notNull(),
|
||||||
|
expectedDeliveryDate: datetime('expected_delivery_date'),
|
||||||
|
|
||||||
|
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
|
||||||
|
amountTVA: decimal('amount_tva', { precision: 12, scale: 2 }),
|
||||||
|
amountTTC: decimal('amount_ttc', { precision: 12, scale: 2 }),
|
||||||
|
|
||||||
|
status: mysqlEnum('status', ['brouillon', 'envoyee', 'recue', 'facturee', 'annulee']).notNull().default('brouillon'),
|
||||||
|
|
||||||
|
description: text('description'),
|
||||||
|
notes: text('notes'),
|
||||||
|
|
||||||
|
createdBy: int('created_by'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
orderNumberIdx: index('po_order_number_idx').on(table.orderNumber),
|
||||||
|
supplierIdx: index('po_supplier_idx').on(table.supplierId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LIGNES DE BON DE COMMANDE
|
||||||
|
// ============================================================
|
||||||
|
export const purchaseOrderLines = mysqlTable('purchase_order_lines', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
purchaseOrderId: int('purchase_order_id').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
quantity: decimal('quantity', { precision: 10, scale: 3 }),
|
||||||
|
unitPrice: decimal('unit_price', { precision: 12, scale: 2 }),
|
||||||
|
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
|
||||||
|
lineOrder: int('line_order').default(0),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HISTORIQUE DES ACTIONS
|
||||||
|
// ============================================================
|
||||||
|
export const auditLog = mysqlTable('audit_log', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
entityType: varchar('entity_type', { length: 50 }).notNull(),
|
||||||
|
entityId: int('entity_id').notNull(),
|
||||||
|
action: varchar('action', { length: 100 }).notNull(),
|
||||||
|
details: json('details'),
|
||||||
|
userId: int('user_id'),
|
||||||
|
userName: varchar('user_name', { length: 255 }),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
entityIdx: index('audit_entity_idx').on(table.entityType, table.entityId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// NOTIFICATIONS
|
||||||
|
// ============================================================
|
||||||
|
export const notifications = mysqlTable('notifications', {
|
||||||
|
id: int('id').primaryKey().autoincrement(),
|
||||||
|
userId: int('user_id').notNull(),
|
||||||
|
title: varchar('title', { length: 255 }).notNull(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
type: mysqlEnum('type', ['info', 'warning', 'error', 'success']).default('info'),
|
||||||
|
isRead: boolean('is_read').notNull().default(false),
|
||||||
|
link: varchar('link', { length: 500 }),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// RELATIONS
|
||||||
|
// ============================================================
|
||||||
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
invoicesCreated: many(invoices),
|
||||||
|
notifications: many(notifications),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const suppliersRelations = relations(suppliers, ({ many }) => ({
|
||||||
|
invoices: many(invoices),
|
||||||
|
purchaseOrders: many(purchaseOrders),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
|
||||||
|
supplier: one(suppliers, {
|
||||||
|
fields: [invoices.supplierId],
|
||||||
|
references: [suppliers.id],
|
||||||
|
}),
|
||||||
|
purchaseOrder: one(purchaseOrders, {
|
||||||
|
fields: [invoices.purchaseOrderId],
|
||||||
|
references: [purchaseOrders.id],
|
||||||
|
}),
|
||||||
|
creator: one(users, {
|
||||||
|
fields: [invoices.createdBy],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
lines: many(invoiceLines),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const invoiceLinesRelations = relations(invoiceLines, ({ one }) => ({
|
||||||
|
invoice: one(invoices, {
|
||||||
|
fields: [invoiceLines.invoiceId],
|
||||||
|
references: [invoices.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const purchaseOrdersRelations = relations(purchaseOrders, ({ one, many }) => ({
|
||||||
|
supplier: one(suppliers, {
|
||||||
|
fields: [purchaseOrders.supplierId],
|
||||||
|
references: [suppliers.id],
|
||||||
|
}),
|
||||||
|
lines: many(purchaseOrderLines),
|
||||||
|
invoices: many(invoices),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const purchaseOrderLinesRelations = relations(purchaseOrderLines, ({ one }) => ({
|
||||||
|
purchaseOrder: one(purchaseOrders, {
|
||||||
|
fields: [purchaseOrderLines.purchaseOrderId],
|
||||||
|
references: [purchaseOrders.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [notifications.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
46
backend/src/db/seed.ts
Normal file
46
backend/src/db/seed.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova';
|
||||||
|
const pool = mysql.createPool(dbUrl);
|
||||||
|
|
||||||
|
console.log('🌱 Seeding de la base de données...');
|
||||||
|
|
||||||
|
// Créer l'utilisateur admin par défaut
|
||||||
|
const hashedPassword = await bcrypt.hash('Itinova69!', 12);
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
INSERT IGNORE INTO users (email, password, first_name, last_name, role, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, ['adminItinova@santinova-soft.org', hashedPassword, 'Admin', 'Itinova', 'admin', true]);
|
||||||
|
|
||||||
|
console.log('✅ Utilisateur admin créé: adminItinova@santinova-soft.org');
|
||||||
|
|
||||||
|
// Créer quelques fournisseurs de démonstration
|
||||||
|
const suppliers = [
|
||||||
|
['Office Dépôt France', '32365094200034', '26 Bd de Villiers', 'Neuilly-sur-Seine', '92200', 'France', 'contact@officedepot.fr'],
|
||||||
|
['EDF Entreprises', '55208131766522', '22-30 Avenue de Wagram', 'Paris', '75008', 'France', 'pro@edf.fr'],
|
||||||
|
['Orange Business Services', '38012986600048', '1 Place des Droits de l\'Homme', 'Arcueil', '94110', 'France', 'contact@orange-business.com'],
|
||||||
|
['Boulanger Pro', '34800207600012', 'Avenue de la Motte', 'Lesquin', '59810', 'France', 'pro@boulanger.com'],
|
||||||
|
['Amazon Business', '48aborneE000000', '67 Boulevard du Général Leclerc', 'Clichy', '92110', 'France', 'business@amazon.fr'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of suppliers) {
|
||||||
|
await pool.execute(`
|
||||||
|
INSERT IGNORE INTO suppliers (name, siret, address, city, postal_code, country, email)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Fournisseurs de démonstration créés');
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch((err) => {
|
||||||
|
console.error('❌ Erreur de seeding:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
77
backend/src/index.ts
Normal file
77
backend/src/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
|
import { initDatabase } from './config/database';
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import invoiceRoutes from './routes/invoices';
|
||||||
|
import supplierRoutes from './routes/suppliers';
|
||||||
|
import purchaseOrderRoutes from './routes/purchaseOrders';
|
||||||
|
import exportRoutes from './routes/exports';
|
||||||
|
import dashboardRoutes from './routes/dashboard';
|
||||||
|
import notificationRoutes from './routes/notifications';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || '*',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
|
// Serve uploaded files
|
||||||
|
const uploadDir = process.env.UPLOAD_DIR || './uploads';
|
||||||
|
app.use('/uploads', express.static(path.resolve(uploadDir)));
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/invoices', invoiceRoutes);
|
||||||
|
app.use('/api/suppliers', supplierRoutes);
|
||||||
|
app.use('/api/purchase-orders', purchaseOrderRoutes);
|
||||||
|
app.use('/api/exports', exportRoutes);
|
||||||
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString(), version: '1.0.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve frontend in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
const frontendPath = path.join(__dirname, '../../frontend/dist');
|
||||||
|
app.use(express.static(frontendPath));
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
if (!req.path.startsWith('/api')) {
|
||||||
|
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Erreur non gérée:', err);
|
||||||
|
res.status(500).json({ error: 'Erreur interne du serveur' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await initDatabase();
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`🚀 Serveur Facturation SANTINOVA démarré sur le port ${PORT}`);
|
||||||
|
console.log(`📊 API disponible sur http://localhost:${PORT}/api`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur au démarrage:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
53
backend/src/middleware/auth.ts
Normal file
53
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Token d\'authentification requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = process.env.JWT_SECRET || 'santinova-jwt-secret';
|
||||||
|
const decoded = jwt.verify(token, secret) as any;
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id: decoded.id,
|
||||||
|
email: decoded.email,
|
||||||
|
role: decoded.role,
|
||||||
|
firstName: decoded.firstName,
|
||||||
|
lastName: decoded.lastName,
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ error: 'Token invalide ou expiré' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorize(...roles: string[]) {
|
||||||
|
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Non authentifié' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.length > 0 && !roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Permissions insuffisantes' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
61
backend/src/middleware/upload.ts
Normal file
61
backend/src/middleware/upload.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const uploadDir = process.env.UPLOAD_DIR || './uploads';
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const yearMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
|
||||||
|
const destDir = path.join(uploadDir, yearMonth);
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, destDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const uniqueName = `${uuidv4()}${ext}`;
|
||||||
|
cb(null, uniqueName);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/tiff',
|
||||||
|
'image/webp',
|
||||||
|
'image/bmp',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allowedTypes.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Type de fichier non supporté: ${file.mimetype}. Formats acceptés: PDF, JPEG, PNG, TIFF, WebP, BMP`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadSingle = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 20 * 1024 * 1024, // 20MB max
|
||||||
|
},
|
||||||
|
}).single('file');
|
||||||
|
|
||||||
|
export const uploadMultiple = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 20 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
}).array('files', 10);
|
||||||
198
backend/src/routes/auth.ts
Normal file
198
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
import { authenticate, AuthRequest, authorize } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email et mot de passe requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
'SELECT * FROM users WHERE email = ? AND is_active = TRUE',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return res.status(401).json({ error: 'Identifiants incorrects' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
const validPassword = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
|
if (!validPassword) {
|
||||||
|
return res.status(401).json({ error: 'Identifiants incorrects' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = process.env.JWT_SECRET || 'santinova-jwt-secret';
|
||||||
|
const expiresIn = process.env.JWT_EXPIRES_IN || '24h';
|
||||||
|
|
||||||
|
const token = (jwt.sign as any)(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
},
|
||||||
|
secret,
|
||||||
|
{ expiresIn }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur login:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users WHERE id = ?',
|
||||||
|
[req.user!.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return res.status(404).json({ error: 'Utilisateur non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
res.json({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.is_active,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur me:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/users - Liste des utilisateurs (admin)
|
||||||
|
router.get('/users', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
'SELECT id, email, first_name, last_name, role, is_active, created_at, updated_at FROM users ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = rows.map((u: any) => ({
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
firstName: u.first_name,
|
||||||
|
lastName: u.last_name,
|
||||||
|
role: u.role,
|
||||||
|
isActive: u.is_active,
|
||||||
|
createdAt: u.created_at,
|
||||||
|
updatedAt: u.updated_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur liste users:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/users - Créer un utilisateur (admin)
|
||||||
|
router.post('/users', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password, firstName, lastName, role } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password || !firstName || !lastName) {
|
||||||
|
return res.status(400).json({ error: 'Tous les champs sont requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [result]: any = await pool.execute(
|
||||||
|
'INSERT INTO users (email, password, first_name, last_name, role) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[email, hashedPassword, firstName, lastName, role || 'operateur']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: result.insertId,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
role: role || 'operateur',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') {
|
||||||
|
return res.status(409).json({ error: 'Cet email est déjà utilisé' });
|
||||||
|
}
|
||||||
|
console.error('Erreur création user:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/auth/users/:id - Modifier un utilisateur (admin)
|
||||||
|
router.put('/users/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { email, firstName, lastName, role, isActive, password } = req.body;
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
let query = 'UPDATE users SET email = ?, first_name = ?, last_name = ?, role = ?, is_active = ?';
|
||||||
|
let params: any[] = [email, firstName, lastName, role, isActive !== false];
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
query += ', password = ?';
|
||||||
|
params.push(hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' WHERE id = ?';
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
await pool.execute(query, params);
|
||||||
|
|
||||||
|
res.json({ message: 'Utilisateur mis à jour' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur update user:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/auth/users/:id - Désactiver un utilisateur (admin)
|
||||||
|
router.delete('/users/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
await pool.execute('UPDATE users SET is_active = FALSE WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
res.json({ message: 'Utilisateur désactivé' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur delete user:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
125
backend/src/routes/dashboard.ts
Normal file
125
backend/src/routes/dashboard.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/dashboard
|
||||||
|
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Compteurs par statut
|
||||||
|
const [statusCounts]: any = await pool.execute(
|
||||||
|
`SELECT status, COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
|
||||||
|
FROM invoices GROUP BY status`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Total factures actives
|
||||||
|
const [activeTotals]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
|
||||||
|
FROM invoices WHERE status NOT IN ('rejetee', 'archivee')`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factures en retard
|
||||||
|
const [overdue]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
|
||||||
|
FROM invoices WHERE due_date < NOW() AND status NOT IN ('payee', 'archivee', 'rejetee')`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factures à échéance dans les 7 jours
|
||||||
|
const [dueSoon]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
|
||||||
|
FROM invoices WHERE due_date BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY) AND status NOT IN ('payee', 'archivee', 'rejetee')`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factures à échéance dans les 30 jours
|
||||||
|
const [dueMonth]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
|
||||||
|
FROM invoices WHERE due_date BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY) AND status NOT IN ('payee', 'archivee', 'rejetee')`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dernières factures
|
||||||
|
const [recentInvoices]: any = await pool.execute(
|
||||||
|
`SELECT i.id, i.invoice_number, i.supplier_name, i.amount_ttc, i.status, i.due_date, i.created_at
|
||||||
|
FROM invoices i ORDER BY i.created_at DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top 5 fournisseurs
|
||||||
|
const [topSuppliers]: any = await pool.execute(
|
||||||
|
`SELECT supplier_name, COUNT(*) as invoice_count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
|
||||||
|
FROM invoices WHERE supplier_name IS NOT NULL AND supplier_name != ''
|
||||||
|
GROUP BY supplier_name ORDER BY total DESC LIMIT 5`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Évolution mensuelle (6 derniers mois)
|
||||||
|
const [monthlyEvolution]: any = await pool.execute(
|
||||||
|
`SELECT DATE_FORMAT(invoice_date, '%Y-%m') as month,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
|
||||||
|
FROM invoices
|
||||||
|
WHERE invoice_date >= DATE_SUB(NOW(), INTERVAL 6 MONTH) AND invoice_date IS NOT NULL
|
||||||
|
GROUP BY month ORDER BY month ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Répartition par source
|
||||||
|
const [sourceCounts]: any = await pool.execute(
|
||||||
|
'SELECT source, COUNT(*) as count FROM invoices GROUP BY source'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factures en attente d'action selon le rôle
|
||||||
|
let pendingForUser: any[] = [];
|
||||||
|
const role = req.user!.role;
|
||||||
|
|
||||||
|
if (role === 'operateur' || role === 'admin') {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'recue'`
|
||||||
|
);
|
||||||
|
pendingForUser.push({ action: 'À vérifier', count: rows[0].count });
|
||||||
|
}
|
||||||
|
if (role === 'validateur' || role === 'admin') {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'en_verification'`
|
||||||
|
);
|
||||||
|
pendingForUser.push({ action: 'À valider', count: rows[0].count });
|
||||||
|
}
|
||||||
|
if (role === 'approbateur' || role === 'admin') {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'validee'`
|
||||||
|
);
|
||||||
|
pendingForUser.push({ action: 'À approuver', count: rows[0].count });
|
||||||
|
}
|
||||||
|
if (role === 'admin') {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'approuvee'`
|
||||||
|
);
|
||||||
|
pendingForUser.push({ action: 'À payer', count: rows[0].count });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
statusCounts: statusCounts.map((s: any) => ({ status: s.status, count: s.count, total: parseFloat(s.total) || 0 })),
|
||||||
|
activeTotals: { count: activeTotals[0].count, total: parseFloat(activeTotals[0].total) || 0 },
|
||||||
|
overdue: { count: overdue[0].count, total: parseFloat(overdue[0].total) || 0 },
|
||||||
|
dueSoon: { count: dueSoon[0].count, total: parseFloat(dueSoon[0].total) || 0 },
|
||||||
|
dueMonth: { count: dueMonth[0].count, total: parseFloat(dueMonth[0].total) || 0 },
|
||||||
|
recentInvoices: recentInvoices.map((i: any) => ({
|
||||||
|
id: i.id,
|
||||||
|
invoiceNumber: i.invoice_number,
|
||||||
|
supplierName: i.supplier_name,
|
||||||
|
amountTTC: i.amount_ttc,
|
||||||
|
status: i.status,
|
||||||
|
dueDate: i.due_date,
|
||||||
|
createdAt: i.created_at,
|
||||||
|
})),
|
||||||
|
topSuppliers,
|
||||||
|
monthlyEvolution,
|
||||||
|
sourceCounts,
|
||||||
|
pendingForUser,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur dashboard:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
196
backend/src/routes/exports.ts
Normal file
196
backend/src/routes/exports.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||||
|
import XLSX from 'xlsx';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/exports/invoices/csv
|
||||||
|
router.get('/invoices/csv', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { status, startDate, endDate, supplierId } = req.query;
|
||||||
|
|
||||||
|
let query = `SELECT i.*, s.name as supplier_display_name
|
||||||
|
FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += ' AND i.status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
query += ' AND i.invoice_date >= ?';
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
query += ' AND i.invoice_date <= ?';
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
if (supplierId) {
|
||||||
|
query += ' AND i.supplier_id = ?';
|
||||||
|
params.push(supplierId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY i.invoice_date ASC';
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(query, params);
|
||||||
|
|
||||||
|
// Format CSV
|
||||||
|
const headers = [
|
||||||
|
'N° Facture', 'Fournisseur', 'SIRET', 'Date Facture', 'Date Échéance',
|
||||||
|
'Montant HT', 'TVA', 'Montant TTC', 'Taux TVA', 'Devise',
|
||||||
|
'Statut', 'Source', 'N° BC', 'Date Réception'
|
||||||
|
];
|
||||||
|
|
||||||
|
let csv = headers.join(';') + '\n';
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const line = [
|
||||||
|
row.invoice_number || '',
|
||||||
|
row.supplier_name || row.supplier_display_name || '',
|
||||||
|
row.supplier_siret || '',
|
||||||
|
row.invoice_date ? new Date(row.invoice_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
row.due_date ? new Date(row.due_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
row.amount_ht || '0.00',
|
||||||
|
row.amount_tva || '0.00',
|
||||||
|
row.amount_ttc || '0.00',
|
||||||
|
row.tva_rate || '',
|
||||||
|
row.currency || 'EUR',
|
||||||
|
row.status,
|
||||||
|
row.source,
|
||||||
|
row.purchase_order_id || '',
|
||||||
|
row.reception_date ? new Date(row.reception_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
];
|
||||||
|
csv += line.join(';') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="export_factures_${new Date().toISOString().slice(0, 10)}.csv"`);
|
||||||
|
res.send('\uFEFF' + csv); // BOM for Excel compatibility
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur export CSV:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/exports/invoices/excel
|
||||||
|
router.get('/invoices/excel', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { status, startDate, endDate, supplierId } = req.query;
|
||||||
|
|
||||||
|
let query = `SELECT i.*, s.name as supplier_display_name
|
||||||
|
FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += ' AND i.status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
query += ' AND i.invoice_date >= ?';
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
query += ' AND i.invoice_date <= ?';
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
if (supplierId) {
|
||||||
|
query += ' AND i.supplier_id = ?';
|
||||||
|
params.push(supplierId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY i.invoice_date ASC';
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(query, params);
|
||||||
|
|
||||||
|
const data = rows.map((row: any) => ({
|
||||||
|
'N° Facture': row.invoice_number || '',
|
||||||
|
'Fournisseur': row.supplier_name || row.supplier_display_name || '',
|
||||||
|
'SIRET': row.supplier_siret || '',
|
||||||
|
'Date Facture': row.invoice_date ? new Date(row.invoice_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
'Date Échéance': row.due_date ? new Date(row.due_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
'Montant HT': parseFloat(row.amount_ht) || 0,
|
||||||
|
'TVA': parseFloat(row.amount_tva) || 0,
|
||||||
|
'Montant TTC': parseFloat(row.amount_ttc) || 0,
|
||||||
|
'Taux TVA': row.tva_rate ? `${row.tva_rate}%` : '',
|
||||||
|
'Devise': row.currency || 'EUR',
|
||||||
|
'Statut': row.status,
|
||||||
|
'Source': row.source,
|
||||||
|
'Rapprochement': row.matching_status || '',
|
||||||
|
'Date Réception': row.reception_date ? new Date(row.reception_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
const ws = XLSX.utils.json_to_sheet(data);
|
||||||
|
|
||||||
|
// Set column widths
|
||||||
|
ws['!cols'] = [
|
||||||
|
{ wch: 15 }, { wch: 30 }, { wch: 18 }, { wch: 14 }, { wch: 14 },
|
||||||
|
{ wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 10 }, { wch: 8 },
|
||||||
|
{ wch: 15 }, { wch: 10 }, { wch: 15 }, { wch: 14 },
|
||||||
|
];
|
||||||
|
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Factures');
|
||||||
|
|
||||||
|
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="export_factures_${new Date().toISOString().slice(0, 10)}.xlsx"`);
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur export Excel:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/exports/cegi - Préparation export CEGI Compta First (format futur)
|
||||||
|
router.get('/cegi', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let query = `SELECT i.*, s.name as supplier_display_name, s.siret as supplier_siret_ref
|
||||||
|
FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id
|
||||||
|
WHERE i.status IN ('approuvee', 'payee')`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += ' AND i.invoice_date >= ?';
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
query += ' AND i.invoice_date <= ?';
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY i.invoice_date ASC';
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(query, params);
|
||||||
|
|
||||||
|
// Format CEGI-compatible (structure préparatoire)
|
||||||
|
const cegiData = rows.map((row: any) => ({
|
||||||
|
codeJournal: 'ACH',
|
||||||
|
dateEcriture: row.invoice_date ? new Date(row.invoice_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
numeroCompte: '401000',
|
||||||
|
libelle: `${row.supplier_name || ''} - ${row.invoice_number || ''}`,
|
||||||
|
debit: 0,
|
||||||
|
credit: parseFloat(row.amount_ttc) || 0,
|
||||||
|
numeroPiece: row.invoice_number || '',
|
||||||
|
dateEcheance: row.due_date ? new Date(row.due_date).toLocaleDateString('fr-FR') : '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Export CEGI Compta First - Format préparatoire',
|
||||||
|
note: 'Ce format sera adapté lors de l\'intégration finale avec CEGI Compta First',
|
||||||
|
data: cegiData,
|
||||||
|
count: cegiData.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur export CEGI:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
542
backend/src/routes/invoices.ts
Normal file
542
backend/src/routes/invoices.ts
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
import { authenticate, AuthRequest, authorize } from '../middleware/auth';
|
||||||
|
import { uploadSingle } from '../middleware/upload';
|
||||||
|
import { logAction } from '../services/auditService';
|
||||||
|
import { extractInvoiceData } from '../services/ocrService';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/invoices
|
||||||
|
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { status, supplierId, search, startDate, endDate, page = '1', limit = '20', sortBy = 'created_at', sortOrder = 'DESC' } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT i.*, s.name as supplier_display_name FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE 1=1';
|
||||||
|
let countQuery = 'SELECT COUNT(*) as total FROM invoices i WHERE 1=1';
|
||||||
|
const params: any[] = [];
|
||||||
|
const countParams: any[] = [];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += ' AND i.status = ?';
|
||||||
|
countQuery += ' AND i.status = ?';
|
||||||
|
params.push(status);
|
||||||
|
countParams.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supplierId) {
|
||||||
|
query += ' AND i.supplier_id = ?';
|
||||||
|
countQuery += ' AND i.supplier_id = ?';
|
||||||
|
params.push(supplierId);
|
||||||
|
countParams.push(supplierId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query += ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ? OR i.full_text LIKE ?)';
|
||||||
|
countQuery += ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ? OR i.full_text LIKE ?)';
|
||||||
|
const s = `%${search}%`;
|
||||||
|
params.push(s, s, s);
|
||||||
|
countParams.push(s, s, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += ' AND i.invoice_date >= ?';
|
||||||
|
countQuery += ' AND i.invoice_date >= ?';
|
||||||
|
params.push(startDate);
|
||||||
|
countParams.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
query += ' AND i.invoice_date <= ?';
|
||||||
|
countQuery += ' AND i.invoice_date <= ?';
|
||||||
|
params.push(endDate);
|
||||||
|
countParams.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSortFields = ['created_at', 'invoice_date', 'due_date', 'amount_ttc', 'status', 'invoice_number'];
|
||||||
|
const sortField = allowedSortFields.includes(sortBy as string) ? sortBy : 'created_at';
|
||||||
|
const order = sortOrder === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
query += ` ORDER BY i.${sortField} ${order}`;
|
||||||
|
|
||||||
|
const pageNum = parseInt(page as string) || 1;
|
||||||
|
const limitNum = parseInt(limit as string) || 20;
|
||||||
|
const offset = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
query += ' LIMIT ? OFFSET ?';
|
||||||
|
params.push(limitNum, offset);
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(query, params);
|
||||||
|
const [countRows]: any = await pool.execute(countQuery, countParams);
|
||||||
|
|
||||||
|
const invoices = rows.map((i: any) => ({
|
||||||
|
id: i.id,
|
||||||
|
invoiceNumber: i.invoice_number,
|
||||||
|
supplierId: i.supplier_id,
|
||||||
|
supplierName: i.supplier_name || i.supplier_display_name,
|
||||||
|
supplierSiret: i.supplier_siret,
|
||||||
|
supplierAddress: i.supplier_address,
|
||||||
|
invoiceDate: i.invoice_date,
|
||||||
|
dueDate: i.due_date,
|
||||||
|
receptionDate: i.reception_date,
|
||||||
|
amountHT: i.amount_ht,
|
||||||
|
amountTVA: i.amount_tva,
|
||||||
|
amountTTC: i.amount_ttc,
|
||||||
|
tvaRate: i.tva_rate,
|
||||||
|
currency: i.currency,
|
||||||
|
status: i.status,
|
||||||
|
source: i.source,
|
||||||
|
originalFileName: i.original_file_name,
|
||||||
|
filePath: i.file_path,
|
||||||
|
fileType: i.file_type,
|
||||||
|
ocrConfidence: i.ocr_confidence,
|
||||||
|
purchaseOrderId: i.purchase_order_id,
|
||||||
|
matchingStatus: i.matching_status,
|
||||||
|
matchingNotes: i.matching_notes,
|
||||||
|
notes: i.notes,
|
||||||
|
createdBy: i.created_by,
|
||||||
|
validatedBy: i.validated_by,
|
||||||
|
approvedBy: i.approved_by,
|
||||||
|
validatedAt: i.validated_at,
|
||||||
|
approvedAt: i.approved_at,
|
||||||
|
paidAt: i.paid_at,
|
||||||
|
createdAt: i.created_at,
|
||||||
|
updatedAt: i.updated_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: invoices,
|
||||||
|
pagination: {
|
||||||
|
total: countRows[0].total,
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
totalPages: Math.ceil(countRows[0].total / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur liste factures:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/invoices/stats
|
||||||
|
router.get('/stats', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Statistiques globales
|
||||||
|
const [statusCounts]: any = await pool.execute(
|
||||||
|
'SELECT status, COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total FROM invoices GROUP BY status'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factures en retard
|
||||||
|
const [overdue]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
|
||||||
|
FROM invoices WHERE due_date < NOW() AND status NOT IN ('payee', 'archivee', 'rejetee')`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factures à échéance dans les 7 prochains jours
|
||||||
|
const [dueSoon]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
|
||||||
|
FROM invoices WHERE due_date BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY) AND status NOT IN ('payee', 'archivee', 'rejetee')`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top fournisseurs par montant
|
||||||
|
const [topSuppliers]: any = await pool.execute(
|
||||||
|
`SELECT supplier_name, COUNT(*) as invoice_count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
|
||||||
|
FROM invoices WHERE supplier_name IS NOT NULL GROUP BY supplier_name ORDER BY total DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factures par mois (12 derniers mois)
|
||||||
|
const [monthlyData]: any = await pool.execute(
|
||||||
|
`SELECT DATE_FORMAT(invoice_date, '%Y-%m') as month, COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
|
||||||
|
FROM invoices WHERE invoice_date >= DATE_SUB(NOW(), INTERVAL 12 MONTH) AND invoice_date IS NOT NULL
|
||||||
|
GROUP BY month ORDER BY month ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Total général
|
||||||
|
const [totals]: any = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as total_count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total_amount FROM invoices WHERE status NOT IN ('rejetee', 'archivee')`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
statusCounts: statusCounts.map((s: any) => ({ status: s.status, count: s.count, total: s.total })),
|
||||||
|
overdue: { count: overdue[0].count, total: overdue[0].total || 0 },
|
||||||
|
dueSoon: { count: dueSoon[0].count, total: dueSoon[0].total || 0 },
|
||||||
|
topSuppliers,
|
||||||
|
monthlyData,
|
||||||
|
totals: { count: totals[0].total_count, amount: totals[0].total_amount || 0 },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur stats factures:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/invoices/:id
|
||||||
|
router.get('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
'SELECT i.*, s.name as supplier_display_name FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE i.id = ?',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return res.status(404).json({ error: 'Facture non trouvée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = rows[0];
|
||||||
|
|
||||||
|
// Get invoice lines
|
||||||
|
const [lines]: any = await pool.execute(
|
||||||
|
'SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_order ASC',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get audit log
|
||||||
|
const [audit]: any = await pool.execute(
|
||||||
|
'SELECT * FROM audit_log WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC',
|
||||||
|
['invoice', req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: i.id,
|
||||||
|
invoiceNumber: i.invoice_number,
|
||||||
|
supplierId: i.supplier_id,
|
||||||
|
supplierName: i.supplier_name || i.supplier_display_name,
|
||||||
|
supplierSiret: i.supplier_siret,
|
||||||
|
supplierAddress: i.supplier_address,
|
||||||
|
invoiceDate: i.invoice_date,
|
||||||
|
dueDate: i.due_date,
|
||||||
|
receptionDate: i.reception_date,
|
||||||
|
amountHT: i.amount_ht,
|
||||||
|
amountTVA: i.amount_tva,
|
||||||
|
amountTTC: i.amount_ttc,
|
||||||
|
tvaRate: i.tva_rate,
|
||||||
|
currency: i.currency,
|
||||||
|
status: i.status,
|
||||||
|
source: i.source,
|
||||||
|
originalFileName: i.original_file_name,
|
||||||
|
filePath: i.file_path,
|
||||||
|
fileType: i.file_type,
|
||||||
|
ocrRawData: i.ocr_raw_data,
|
||||||
|
ocrConfidence: i.ocr_confidence,
|
||||||
|
purchaseOrderId: i.purchase_order_id,
|
||||||
|
matchingStatus: i.matching_status,
|
||||||
|
matchingNotes: i.matching_notes,
|
||||||
|
notes: i.notes,
|
||||||
|
fullText: i.full_text,
|
||||||
|
createdBy: i.created_by,
|
||||||
|
validatedBy: i.validated_by,
|
||||||
|
approvedBy: i.approved_by,
|
||||||
|
validatedAt: i.validated_at,
|
||||||
|
approvedAt: i.approved_at,
|
||||||
|
paidAt: i.paid_at,
|
||||||
|
createdAt: i.created_at,
|
||||||
|
updatedAt: i.updated_at,
|
||||||
|
lines: lines.map((l: any) => ({
|
||||||
|
id: l.id,
|
||||||
|
description: l.description,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: l.unit_price,
|
||||||
|
amountHT: l.amount_ht,
|
||||||
|
tvaRate: l.tva_rate,
|
||||||
|
amountTVA: l.amount_tva,
|
||||||
|
amountTTC: l.amount_ttc,
|
||||||
|
lineOrder: l.line_order,
|
||||||
|
})),
|
||||||
|
auditLog: audit.map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
action: a.action,
|
||||||
|
details: a.details,
|
||||||
|
userName: a.user_name,
|
||||||
|
createdAt: a.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur get facture:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/invoices/upload - Upload et OCR
|
||||||
|
router.post('/upload', authenticate, (req: AuthRequest, res: Response) => {
|
||||||
|
uploadSingle(req, res, async (err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Aucun fichier fourni' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const file = req.file;
|
||||||
|
const source = (req.body.source as string) || 'upload';
|
||||||
|
|
||||||
|
// Create initial invoice record
|
||||||
|
const [result]: any = await pool.execute(
|
||||||
|
`INSERT INTO invoices (reception_date, source, original_file_name, file_path, file_type, status, created_by)
|
||||||
|
VALUES (NOW(), ?, ?, ?, ?, 'recue', ?)`,
|
||||||
|
[source, file.originalname, file.path, file.mimetype, req.user!.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoiceId = result.insertId;
|
||||||
|
|
||||||
|
await logAction('invoice', invoiceId, 'upload', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`, {
|
||||||
|
fileName: file.originalname,
|
||||||
|
fileSize: file.size,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run OCR in background
|
||||||
|
extractInvoiceData(file.path, file.mimetype, invoiceId).catch((ocrError) => {
|
||||||
|
console.error('Erreur OCR:', ocrError);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: invoiceId,
|
||||||
|
message: 'Facture uploadée avec succès. L\'extraction OCR est en cours.',
|
||||||
|
fileName: file.originalname,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur upload facture:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/invoices/:id - Modifier une facture
|
||||||
|
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { invoiceNumber, supplierId, supplierName, supplierSiret, supplierAddress,
|
||||||
|
invoiceDate, dueDate, amountHT, amountTVA, amountTTC, tvaRate, currency, notes, purchaseOrderId } = req.body;
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`UPDATE invoices SET invoice_number = ?, supplier_id = ?, supplier_name = ?, supplier_siret = ?, supplier_address = ?,
|
||||||
|
invoice_date = ?, due_date = ?, amount_ht = ?, amount_tva = ?, amount_ttc = ?, tva_rate = ?, currency = ?, notes = ?, purchase_order_id = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[invoiceNumber || null, supplierId || null, supplierName || null, supplierSiret || null, supplierAddress || null,
|
||||||
|
invoiceDate || null, dueDate || null, amountHT || null, amountTVA || null, amountTTC || null, tvaRate || null,
|
||||||
|
currency || 'EUR', notes || null, purchaseOrderId || null, req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAction('invoice', parseInt(req.params.id), 'modification', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Facture mise à jour' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur update facture:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/invoices/:id/lines - Mettre à jour les lignes de facture
|
||||||
|
router.put('/:id/lines', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { lines } = req.body;
|
||||||
|
const invoiceId = parseInt(req.params.id);
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Delete existing lines
|
||||||
|
await pool.execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [invoiceId]);
|
||||||
|
|
||||||
|
// Insert new lines
|
||||||
|
if (lines && lines.length > 0) {
|
||||||
|
for (let idx = 0; idx < lines.length; idx++) {
|
||||||
|
const line = lines[idx];
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO invoice_lines (invoice_id, description, quantity, unit_price, amount_ht, tva_rate, amount_tva, amount_ttc, line_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[invoiceId, line.description || null, line.quantity || null, line.unitPrice || null,
|
||||||
|
line.amountHT || null, line.tvaRate || null, line.amountTVA || null, line.amountTTC || null, idx]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAction('invoice', invoiceId, 'modification_lignes', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Lignes de facture mises à jour' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur update lignes:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/invoices/:id/status - Changer le statut (workflow)
|
||||||
|
router.post('/:id/status', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { status, notes } = req.body;
|
||||||
|
const invoiceId = parseInt(req.params.id);
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Vérifier la facture actuelle
|
||||||
|
const [rows]: any = await pool.execute('SELECT status FROM invoices WHERE id = ?', [invoiceId]);
|
||||||
|
if (!rows.length) {
|
||||||
|
return res.status(404).json({ error: 'Facture non trouvée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStatus = rows[0].status;
|
||||||
|
|
||||||
|
// Vérifier les transitions valides
|
||||||
|
const validTransitions: Record<string, string[]> = {
|
||||||
|
'recue': ['en_verification', 'rejetee'],
|
||||||
|
'en_verification': ['validee', 'rejetee', 'recue'],
|
||||||
|
'validee': ['approuvee', 'rejetee', 'en_verification'],
|
||||||
|
'approuvee': ['payee', 'rejetee', 'validee'],
|
||||||
|
'payee': ['archivee'],
|
||||||
|
'rejetee': ['recue'],
|
||||||
|
'archivee': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!validTransitions[currentStatus]?.includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Transition invalide: ${currentStatus} → ${status}`,
|
||||||
|
validTransitions: validTransitions[currentStatus],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les permissions
|
||||||
|
const role = req.user!.role;
|
||||||
|
const rolePermissions: Record<string, string[]> = {
|
||||||
|
'operateur': ['en_verification', 'recue'],
|
||||||
|
'validateur': ['en_verification', 'validee', 'rejetee', 'recue'],
|
||||||
|
'approbateur': ['en_verification', 'validee', 'approuvee', 'rejetee', 'recue'],
|
||||||
|
'admin': ['en_verification', 'validee', 'approuvee', 'payee', 'rejetee', 'recue', 'archivee'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!rolePermissions[role]?.includes(status)) {
|
||||||
|
return res.status(403).json({ error: 'Vous n\'avez pas les permissions pour cette action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour le statut
|
||||||
|
let updateQuery = 'UPDATE invoices SET status = ?';
|
||||||
|
const updateParams: any[] = [status];
|
||||||
|
|
||||||
|
if (status === 'validee') {
|
||||||
|
updateQuery += ', validated_by = ?, validated_at = NOW()';
|
||||||
|
updateParams.push(req.user!.id);
|
||||||
|
} else if (status === 'approuvee') {
|
||||||
|
updateQuery += ', approved_by = ?, approved_at = NOW()';
|
||||||
|
updateParams.push(req.user!.id);
|
||||||
|
} else if (status === 'payee') {
|
||||||
|
updateQuery += ', paid_at = NOW()';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQuery += ' WHERE id = ?';
|
||||||
|
updateParams.push(invoiceId);
|
||||||
|
|
||||||
|
await pool.execute(updateQuery, updateParams);
|
||||||
|
|
||||||
|
await logAction('invoice', invoiceId, `statut_${status}`, req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`, {
|
||||||
|
previousStatus: currentStatus,
|
||||||
|
newStatus: status,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: `Statut mis à jour: ${status}`, previousStatus: currentStatus, newStatus: status });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur changement statut:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/invoices/:id/match - Rapprochement avec bon de commande
|
||||||
|
router.post('/:id/match', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { purchaseOrderId } = req.body;
|
||||||
|
const invoiceId = parseInt(req.params.id);
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Récupérer la facture
|
||||||
|
const [invoiceRows]: any = await pool.execute('SELECT * FROM invoices WHERE id = ?', [invoiceId]);
|
||||||
|
if (!invoiceRows.length) {
|
||||||
|
return res.status(404).json({ error: 'Facture non trouvée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le bon de commande
|
||||||
|
const [poRows]: any = await pool.execute('SELECT * FROM purchase_orders WHERE id = ?', [purchaseOrderId]);
|
||||||
|
if (!poRows.length) {
|
||||||
|
return res.status(404).json({ error: 'Bon de commande non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceRows[0];
|
||||||
|
const po = poRows[0];
|
||||||
|
|
||||||
|
// Comparer les montants
|
||||||
|
const invoiceAmount = parseFloat(invoice.amount_ttc) || 0;
|
||||||
|
const poAmount = parseFloat(po.amount_ttc) || 0;
|
||||||
|
const ecart = Math.abs(invoiceAmount - poAmount);
|
||||||
|
const ecartPourcent = poAmount > 0 ? (ecart / poAmount) * 100 : 0;
|
||||||
|
|
||||||
|
let matchingStatus = 'rapproche';
|
||||||
|
let matchingNotes = `Rapproché avec BC ${po.order_number}`;
|
||||||
|
|
||||||
|
if (ecartPourcent > 1) {
|
||||||
|
matchingStatus = 'ecart_detecte';
|
||||||
|
matchingNotes = `Écart de ${ecart.toFixed(2)}€ (${ecartPourcent.toFixed(1)}%) avec BC ${po.order_number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'UPDATE invoices SET purchase_order_id = ?, matching_status = ?, matching_notes = ? WHERE id = ?',
|
||||||
|
[purchaseOrderId, matchingStatus, matchingNotes, invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mettre à jour le statut du BC
|
||||||
|
await pool.execute('UPDATE purchase_orders SET status = ? WHERE id = ?', ['facturee', purchaseOrderId]);
|
||||||
|
|
||||||
|
await logAction('invoice', invoiceId, 'rapprochement', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`, {
|
||||||
|
purchaseOrderId,
|
||||||
|
matchingStatus,
|
||||||
|
ecart: ecart.toFixed(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ matchingStatus, matchingNotes, ecart: ecart.toFixed(2), ecartPourcent: ecartPourcent.toFixed(1) });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur rapprochement:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/invoices/:id/file - Télécharger le fichier original
|
||||||
|
router.get('/:id/file', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute('SELECT file_path, original_file_name, file_type FROM invoices WHERE id = ?', [req.params.id]);
|
||||||
|
|
||||||
|
if (!rows.length || !rows[0].file_path) {
|
||||||
|
return res.status(404).json({ error: 'Fichier non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { file_path, original_file_name, file_type } = rows[0];
|
||||||
|
res.setHeader('Content-Type', file_type || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${original_file_name}"`);
|
||||||
|
res.sendFile(path.resolve(file_path));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur download fichier:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/invoices/:id
|
||||||
|
router.delete('/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [req.params.id]);
|
||||||
|
await pool.execute('DELETE FROM invoices WHERE id = ?', [req.params.id]);
|
||||||
|
|
||||||
|
await logAction('invoice', parseInt(req.params.id), 'suppression', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Facture supprimée' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur suppression facture:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
80
backend/src/routes/notifications.ts
Normal file
80
backend/src/routes/notifications.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/notifications
|
||||||
|
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { unreadOnly } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM notifications WHERE user_id = ?';
|
||||||
|
const params: any[] = [req.user!.id];
|
||||||
|
|
||||||
|
if (unreadOnly === 'true') {
|
||||||
|
query += ' AND is_read = FALSE';
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC LIMIT 50';
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(query, params);
|
||||||
|
|
||||||
|
const notifications = rows.map((n: any) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
message: n.message,
|
||||||
|
type: n.type,
|
||||||
|
isRead: n.is_read,
|
||||||
|
link: n.link,
|
||||||
|
createdAt: n.created_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(notifications);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur notifications:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/notifications/:id/read
|
||||||
|
router.put('/:id/read', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute('UPDATE notifications SET is_read = TRUE WHERE id = ? AND user_id = ?', [req.params.id, req.user!.id]);
|
||||||
|
res.json({ message: 'Notification marquée comme lue' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur mark read:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/notifications/read-all
|
||||||
|
router.put('/read-all', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute('UPDATE notifications SET is_read = TRUE WHERE user_id = ?', [req.user!.id]);
|
||||||
|
res.json({ message: 'Toutes les notifications marquées comme lues' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur mark all read:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/notifications/count
|
||||||
|
router.get('/count', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
'SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND is_read = FALSE',
|
||||||
|
[req.user!.id]
|
||||||
|
);
|
||||||
|
res.json({ count: rows[0].count });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur count notifications:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
248
backend/src/routes/purchaseOrders.ts
Normal file
248
backend/src/routes/purchaseOrders.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||||
|
import { logAction } from '../services/auditService';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/purchase-orders
|
||||||
|
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { status, supplierId, search, page = '1', limit = '20' } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT po.*, s.name as supplier_display_name FROM purchase_orders po LEFT JOIN suppliers s ON po.supplier_id = s.id WHERE 1=1';
|
||||||
|
let countQuery = 'SELECT COUNT(*) as total FROM purchase_orders po WHERE 1=1';
|
||||||
|
const params: any[] = [];
|
||||||
|
const countParams: any[] = [];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += ' AND po.status = ?';
|
||||||
|
countQuery += ' AND po.status = ?';
|
||||||
|
params.push(status);
|
||||||
|
countParams.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supplierId) {
|
||||||
|
query += ' AND po.supplier_id = ?';
|
||||||
|
countQuery += ' AND po.supplier_id = ?';
|
||||||
|
params.push(supplierId);
|
||||||
|
countParams.push(supplierId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query += ' AND (po.order_number LIKE ? OR po.supplier_name LIKE ? OR po.description LIKE ?)';
|
||||||
|
countQuery += ' AND (po.order_number LIKE ? OR po.supplier_name LIKE ? OR po.description LIKE ?)';
|
||||||
|
const s = `%${search}%`;
|
||||||
|
params.push(s, s, s);
|
||||||
|
countParams.push(s, s, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY po.created_at DESC';
|
||||||
|
|
||||||
|
const pageNum = parseInt(page as string) || 1;
|
||||||
|
const limitNum = parseInt(limit as string) || 20;
|
||||||
|
const offset = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
query += ' LIMIT ? OFFSET ?';
|
||||||
|
params.push(limitNum, offset);
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(query, params);
|
||||||
|
const [countRows]: any = await pool.execute(countQuery, countParams);
|
||||||
|
|
||||||
|
const orders = rows.map((po: any) => ({
|
||||||
|
id: po.id,
|
||||||
|
orderNumber: po.order_number,
|
||||||
|
supplierId: po.supplier_id,
|
||||||
|
supplierName: po.supplier_name || po.supplier_display_name,
|
||||||
|
orderDate: po.order_date,
|
||||||
|
expectedDeliveryDate: po.expected_delivery_date,
|
||||||
|
amountHT: po.amount_ht,
|
||||||
|
amountTVA: po.amount_tva,
|
||||||
|
amountTTC: po.amount_ttc,
|
||||||
|
status: po.status,
|
||||||
|
description: po.description,
|
||||||
|
notes: po.notes,
|
||||||
|
createdBy: po.created_by,
|
||||||
|
createdAt: po.created_at,
|
||||||
|
updatedAt: po.updated_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: orders,
|
||||||
|
pagination: {
|
||||||
|
total: countRows[0].total,
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
totalPages: Math.ceil(countRows[0].total / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur liste BC:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/purchase-orders/:id
|
||||||
|
router.get('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
'SELECT po.*, s.name as supplier_display_name FROM purchase_orders po LEFT JOIN suppliers s ON po.supplier_id = s.id WHERE po.id = ?',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return res.status(404).json({ error: 'Bon de commande non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const po = rows[0];
|
||||||
|
|
||||||
|
const [lines]: any = await pool.execute(
|
||||||
|
'SELECT * FROM purchase_order_lines WHERE purchase_order_id = ? ORDER BY line_order ASC',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get linked invoices
|
||||||
|
const [linkedInvoices]: any = await pool.execute(
|
||||||
|
'SELECT id, invoice_number, amount_ttc, status, matching_status FROM invoices WHERE purchase_order_id = ?',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: po.id,
|
||||||
|
orderNumber: po.order_number,
|
||||||
|
supplierId: po.supplier_id,
|
||||||
|
supplierName: po.supplier_name || po.supplier_display_name,
|
||||||
|
orderDate: po.order_date,
|
||||||
|
expectedDeliveryDate: po.expected_delivery_date,
|
||||||
|
amountHT: po.amount_ht,
|
||||||
|
amountTVA: po.amount_tva,
|
||||||
|
amountTTC: po.amount_ttc,
|
||||||
|
status: po.status,
|
||||||
|
description: po.description,
|
||||||
|
notes: po.notes,
|
||||||
|
createdBy: po.created_by,
|
||||||
|
createdAt: po.created_at,
|
||||||
|
updatedAt: po.updated_at,
|
||||||
|
lines: lines.map((l: any) => ({
|
||||||
|
id: l.id,
|
||||||
|
description: l.description,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: l.unit_price,
|
||||||
|
amountHT: l.amount_ht,
|
||||||
|
lineOrder: l.line_order,
|
||||||
|
})),
|
||||||
|
linkedInvoices: linkedInvoices.map((i: any) => ({
|
||||||
|
id: i.id,
|
||||||
|
invoiceNumber: i.invoice_number,
|
||||||
|
amountTTC: i.amount_ttc,
|
||||||
|
status: i.status,
|
||||||
|
matchingStatus: i.matching_status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur get BC:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/purchase-orders
|
||||||
|
router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { orderNumber, supplierId, supplierName, orderDate, expectedDeliveryDate,
|
||||||
|
amountHT, amountTVA, amountTTC, description, notes, lines } = req.body;
|
||||||
|
|
||||||
|
if (!orderNumber || !orderDate) {
|
||||||
|
return res.status(400).json({ error: 'Numéro de commande et date requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [result]: any = await pool.execute(
|
||||||
|
`INSERT INTO purchase_orders (order_number, supplier_id, supplier_name, order_date, expected_delivery_date, amount_ht, amount_tva, amount_ttc, description, notes, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[orderNumber, supplierId || null, supplierName || null, orderDate, expectedDeliveryDate || null,
|
||||||
|
amountHT || null, amountTVA || null, amountTTC || null, description || null, notes || null, req.user!.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const poId = result.insertId;
|
||||||
|
|
||||||
|
// Insert lines
|
||||||
|
if (lines && lines.length > 0) {
|
||||||
|
for (let idx = 0; idx < lines.length; idx++) {
|
||||||
|
const line = lines[idx];
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO purchase_order_lines (purchase_order_id, description, quantity, unit_price, amount_ht, line_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[poId, line.description || null, line.quantity || null, line.unitPrice || null, line.amountHT || null, idx]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAction('purchase_order', poId, 'creation', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.status(201).json({ id: poId, orderNumber });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') {
|
||||||
|
return res.status(409).json({ error: 'Ce numéro de commande existe déjà' });
|
||||||
|
}
|
||||||
|
console.error('Erreur création BC:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/purchase-orders/:id
|
||||||
|
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { orderNumber, supplierId, supplierName, orderDate, expectedDeliveryDate,
|
||||||
|
amountHT, amountTVA, amountTTC, status, description, notes, lines } = req.body;
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`UPDATE purchase_orders SET order_number = ?, supplier_id = ?, supplier_name = ?, order_date = ?, expected_delivery_date = ?,
|
||||||
|
amount_ht = ?, amount_tva = ?, amount_ttc = ?, status = ?, description = ?, notes = ? WHERE id = ?`,
|
||||||
|
[orderNumber, supplierId || null, supplierName || null, orderDate, expectedDeliveryDate || null,
|
||||||
|
amountHT || null, amountTVA || null, amountTTC || null, status || 'brouillon', description || null, notes || null, req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update lines if provided
|
||||||
|
if (lines) {
|
||||||
|
await pool.execute('DELETE FROM purchase_order_lines WHERE purchase_order_id = ?', [req.params.id]);
|
||||||
|
for (let idx = 0; idx < lines.length; idx++) {
|
||||||
|
const line = lines[idx];
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO purchase_order_lines (purchase_order_id, description, quantity, unit_price, amount_ht, line_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[req.params.id, line.description || null, line.quantity || null, line.unitPrice || null, line.amountHT || null, idx]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAction('purchase_order', parseInt(req.params.id), 'modification', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Bon de commande mis à jour' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur update BC:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/purchase-orders/:id
|
||||||
|
router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute('DELETE FROM purchase_order_lines WHERE purchase_order_id = ?', [req.params.id]);
|
||||||
|
await pool.execute('DELETE FROM purchase_orders WHERE id = ?', [req.params.id]);
|
||||||
|
|
||||||
|
await logAction('purchase_order', parseInt(req.params.id), 'suppression', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Bon de commande supprimé' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur suppression BC:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
151
backend/src/routes/suppliers.ts
Normal file
151
backend/src/routes/suppliers.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||||
|
import { logAction } from '../services/auditService';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/suppliers
|
||||||
|
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { search, active } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM suppliers WHERE 1=1';
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
query += ' AND is_active = ?';
|
||||||
|
params.push(active === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query += ' AND (name LIKE ? OR siret LIKE ? OR email LIKE ?)';
|
||||||
|
const s = `%${search}%`;
|
||||||
|
params.push(s, s, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY name ASC';
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(query, params);
|
||||||
|
|
||||||
|
const suppliers = rows.map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
siret: s.siret,
|
||||||
|
address: s.address,
|
||||||
|
city: s.city,
|
||||||
|
postalCode: s.postal_code,
|
||||||
|
country: s.country,
|
||||||
|
email: s.email,
|
||||||
|
phone: s.phone,
|
||||||
|
contactName: s.contact_name,
|
||||||
|
iban: s.iban,
|
||||||
|
notes: s.notes,
|
||||||
|
isActive: s.is_active,
|
||||||
|
createdAt: s.created_at,
|
||||||
|
updatedAt: s.updated_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(suppliers);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur liste fournisseurs:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/suppliers/:id
|
||||||
|
router.get('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows]: any = await pool.execute('SELECT * FROM suppliers WHERE id = ?', [req.params.id]);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return res.status(404).json({ error: 'Fournisseur non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = rows[0];
|
||||||
|
res.json({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
siret: s.siret,
|
||||||
|
address: s.address,
|
||||||
|
city: s.city,
|
||||||
|
postalCode: s.postal_code,
|
||||||
|
country: s.country,
|
||||||
|
email: s.email,
|
||||||
|
phone: s.phone,
|
||||||
|
contactName: s.contact_name,
|
||||||
|
iban: s.iban,
|
||||||
|
notes: s.notes,
|
||||||
|
isActive: s.is_active,
|
||||||
|
createdAt: s.created_at,
|
||||||
|
updatedAt: s.updated_at,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur get fournisseur:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/suppliers
|
||||||
|
router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, siret, address, city, postalCode, country, email, phone, contactName, iban, notes } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'Le nom du fournisseur est requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const [result]: any = await pool.execute(
|
||||||
|
`INSERT INTO suppliers (name, siret, address, city, postal_code, country, email, phone, contact_name, iban, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[name, siret || null, address || null, city || null, postalCode || null, country || 'France', email || null, phone || null, contactName || null, iban || null, notes || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAction('supplier', result.insertId, 'creation', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.status(201).json({ id: result.insertId, name });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur création fournisseur:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/suppliers/:id
|
||||||
|
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, siret, address, city, postalCode, country, email, phone, contactName, iban, notes, isActive } = req.body;
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute(
|
||||||
|
`UPDATE suppliers SET name = ?, siret = ?, address = ?, city = ?, postal_code = ?, country = ?, email = ?, phone = ?, contact_name = ?, iban = ?, notes = ?, is_active = ? WHERE id = ?`,
|
||||||
|
[name, siret || null, address || null, city || null, postalCode || null, country || 'France', email || null, phone || null, contactName || null, iban || null, notes || null, isActive !== false, req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAction('supplier', parseInt(req.params.id), 'modification', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Fournisseur mis à jour' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur update fournisseur:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/suppliers/:id
|
||||||
|
router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute('UPDATE suppliers SET is_active = FALSE WHERE id = ?', [req.params.id]);
|
||||||
|
|
||||||
|
await logAction('supplier', parseInt(req.params.id), 'desactivation', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Fournisseur désactivé' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erreur delete fournisseur:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
30
backend/src/services/auditService.ts
Normal file
30
backend/src/services/auditService.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
|
||||||
|
export async function logAction(
|
||||||
|
entityType: string,
|
||||||
|
entityId: number,
|
||||||
|
action: string,
|
||||||
|
userId?: number,
|
||||||
|
userName?: string,
|
||||||
|
details?: any
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO audit_log (entity_type, entity_id, action, user_id, user_name, details) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[entityType, entityId, action, userId || null, userName || null, details ? JSON.stringify(details) : null]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'enregistrement de l\'audit:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLog(entityType: string, entityId: number) {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT * FROM audit_log WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC`,
|
||||||
|
[entityType, entityId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
224
backend/src/services/ocrService.ts
Normal file
224
backend/src/services/ocrService.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import OpenAI from 'openai';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { getPool } from '../config/database';
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ExtractedInvoiceData {
|
||||||
|
invoiceNumber?: string;
|
||||||
|
supplierName?: string;
|
||||||
|
supplierSiret?: string;
|
||||||
|
supplierAddress?: string;
|
||||||
|
invoiceDate?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
amountHT?: number;
|
||||||
|
amountTVA?: number;
|
||||||
|
amountTTC?: number;
|
||||||
|
tvaRate?: number;
|
||||||
|
currency?: string;
|
||||||
|
lines?: Array<{
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unitPrice?: number;
|
||||||
|
amountHT?: number;
|
||||||
|
tvaRate?: number;
|
||||||
|
amountTVA?: number;
|
||||||
|
amountTTC?: number;
|
||||||
|
}>;
|
||||||
|
fullText?: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractInvoiceData(filePath: string, mimeType: string, invoiceId: number): Promise<ExtractedInvoiceData> {
|
||||||
|
try {
|
||||||
|
console.log(`🔍 OCR en cours pour la facture #${invoiceId}...`);
|
||||||
|
|
||||||
|
// Read file and convert to base64
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
const base64 = fileBuffer.toString('base64');
|
||||||
|
|
||||||
|
// Determine media type
|
||||||
|
let mediaType = 'image/jpeg';
|
||||||
|
if (mimeType === 'application/pdf') {
|
||||||
|
mediaType = 'application/pdf';
|
||||||
|
} else if (mimeType) {
|
||||||
|
mediaType = mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = `data:${mediaType};base64,${base64}`;
|
||||||
|
|
||||||
|
const prompt = `Tu es un expert en extraction de données de factures. Analyse cette facture et extrais les informations suivantes au format JSON strict.
|
||||||
|
|
||||||
|
IMPORTANT: Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ou après.
|
||||||
|
|
||||||
|
{
|
||||||
|
"invoiceNumber": "numéro de facture",
|
||||||
|
"supplierName": "nom du fournisseur",
|
||||||
|
"supplierSiret": "numéro SIRET du fournisseur",
|
||||||
|
"supplierAddress": "adresse complète du fournisseur",
|
||||||
|
"invoiceDate": "date de facture au format YYYY-MM-DD",
|
||||||
|
"dueDate": "date d'échéance au format YYYY-MM-DD",
|
||||||
|
"amountHT": montant_HT_nombre,
|
||||||
|
"amountTVA": montant_TVA_nombre,
|
||||||
|
"amountTTC": montant_TTC_nombre,
|
||||||
|
"tvaRate": taux_TVA_nombre,
|
||||||
|
"currency": "EUR",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"description": "description de la ligne",
|
||||||
|
"quantity": quantité_nombre,
|
||||||
|
"unitPrice": prix_unitaire_nombre,
|
||||||
|
"amountHT": montant_HT_ligne_nombre,
|
||||||
|
"tvaRate": taux_TVA_nombre,
|
||||||
|
"amountTVA": montant_TVA_ligne_nombre,
|
||||||
|
"amountTTC": montant_TTC_ligne_nombre
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fullText": "texte complet extrait de la facture",
|
||||||
|
"confidence": score_de_confiance_0_à_100
|
||||||
|
}
|
||||||
|
|
||||||
|
Si une information n'est pas trouvée, utilise null. Les montants doivent être des nombres (pas de chaînes). Les dates doivent être au format YYYY-MM-DD.`;
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-4.1-mini',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: dataUrl,
|
||||||
|
detail: 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 4096,
|
||||||
|
temperature: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content || '{}';
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
let extracted: ExtractedInvoiceData;
|
||||||
|
try {
|
||||||
|
// Try to extract JSON from the response
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
extracted = JSON.parse(jsonMatch[0]);
|
||||||
|
} else {
|
||||||
|
extracted = JSON.parse(content);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Erreur parsing OCR JSON:', parseError);
|
||||||
|
extracted = { fullText: content, confidence: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the invoice in the database
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Try to find or create supplier
|
||||||
|
let supplierId: number | null = null;
|
||||||
|
if (extracted.supplierName) {
|
||||||
|
const [existingSuppliers]: any = await pool.execute(
|
||||||
|
'SELECT id FROM suppliers WHERE name LIKE ? OR siret = ? LIMIT 1',
|
||||||
|
[`%${extracted.supplierName}%`, extracted.supplierSiret || '']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingSuppliers.length > 0) {
|
||||||
|
supplierId = existingSuppliers[0].id;
|
||||||
|
} else {
|
||||||
|
// Create new supplier
|
||||||
|
const [result]: any = await pool.execute(
|
||||||
|
'INSERT INTO suppliers (name, siret, address) VALUES (?, ?, ?)',
|
||||||
|
[extracted.supplierName, extracted.supplierSiret || null, extracted.supplierAddress || null]
|
||||||
|
);
|
||||||
|
supplierId = result.insertId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`UPDATE invoices SET
|
||||||
|
invoice_number = COALESCE(?, invoice_number),
|
||||||
|
supplier_id = COALESCE(?, supplier_id),
|
||||||
|
supplier_name = COALESCE(?, supplier_name),
|
||||||
|
supplier_siret = COALESCE(?, supplier_siret),
|
||||||
|
supplier_address = COALESCE(?, supplier_address),
|
||||||
|
invoice_date = COALESCE(?, invoice_date),
|
||||||
|
due_date = COALESCE(?, due_date),
|
||||||
|
amount_ht = COALESCE(?, amount_ht),
|
||||||
|
amount_tva = COALESCE(?, amount_tva),
|
||||||
|
amount_ttc = COALESCE(?, amount_ttc),
|
||||||
|
tva_rate = COALESCE(?, tva_rate),
|
||||||
|
currency = COALESCE(?, currency),
|
||||||
|
ocr_raw_data = ?,
|
||||||
|
ocr_confidence = ?,
|
||||||
|
full_text = ?,
|
||||||
|
status = 'en_verification'
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
extracted.invoiceNumber || null,
|
||||||
|
supplierId,
|
||||||
|
extracted.supplierName || null,
|
||||||
|
extracted.supplierSiret || null,
|
||||||
|
extracted.supplierAddress || null,
|
||||||
|
extracted.invoiceDate || null,
|
||||||
|
extracted.dueDate || null,
|
||||||
|
extracted.amountHT || null,
|
||||||
|
extracted.amountTVA || null,
|
||||||
|
extracted.amountTTC || null,
|
||||||
|
extracted.tvaRate || null,
|
||||||
|
extracted.currency || null,
|
||||||
|
JSON.stringify(extracted),
|
||||||
|
extracted.confidence || null,
|
||||||
|
extracted.fullText || null,
|
||||||
|
invoiceId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert invoice lines
|
||||||
|
if (extracted.lines && extracted.lines.length > 0) {
|
||||||
|
for (let idx = 0; idx < extracted.lines.length; idx++) {
|
||||||
|
const line = extracted.lines[idx];
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO invoice_lines (invoice_id, description, quantity, unit_price, amount_ht, tva_rate, amount_tva, amount_ttc, line_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[invoiceId, line.description || null, line.quantity || null, line.unitPrice || null,
|
||||||
|
line.amountHT || null, line.tvaRate || null, line.amountTVA || null, line.amountTTC || null, idx]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the OCR action
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO audit_log (entity_type, entity_id, action, details) VALUES (?, ?, ?, ?)`,
|
||||||
|
['invoice', invoiceId, 'ocr_extraction', JSON.stringify({ confidence: extracted.confidence, linesCount: extracted.lines?.length || 0 })]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ OCR terminé pour la facture #${invoiceId} (confiance: ${extracted.confidence}%)`);
|
||||||
|
|
||||||
|
return extracted;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Erreur OCR pour la facture #${invoiceId}:`, error);
|
||||||
|
|
||||||
|
// Update invoice with error status
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO audit_log (entity_type, entity_id, action, details) VALUES (?, ?, ?, ?)`,
|
||||||
|
['invoice', invoiceId, 'ocr_erreur', JSON.stringify({ error: error.message })]
|
||||||
|
);
|
||||||
|
} catch (logError) {
|
||||||
|
console.error('Erreur log OCR:', logError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
1
backend/uploads/.gitkeep
Normal file
1
backend/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
87
docker-compose.yml
Normal file
87
docker-compose.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ============================================================
|
||||||
|
# MySQL Database
|
||||||
|
# ============================================================
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: santinova-facturation-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-santinova_db_pass}
|
||||||
|
MYSQL_DATABASE: facturation_santinova
|
||||||
|
MYSQL_CHARACTER_SET_SERVER: utf8mb4
|
||||||
|
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-3306}:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- santinova-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-santinova_db_pass}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Application (Backend + Frontend)
|
||||||
|
# ============================================================
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: santinova-facturation-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3001
|
||||||
|
DATABASE_URL: mysql://root:${DB_ROOT_PASSWORD:-santinova_db_pass}@db:3306/facturation_santinova
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-santinova-jwt-secret-change-in-production}
|
||||||
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8080}:3001"
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
networks:
|
||||||
|
- santinova-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Database Migration & Seed (run once)
|
||||||
|
# ============================================================
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.migrate
|
||||||
|
container_name: santinova-facturation-migrate
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: mysql://root:${DB_ROOT_PASSWORD:-santinova_db_pass}@db:3306/facturation_santinova
|
||||||
|
networks:
|
||||||
|
- santinova-network
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
driver: local
|
||||||
|
uploads_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
santinova-network:
|
||||||
|
driver: bridge
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Facturation SANTINOVA</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3035
frontend/package-lock.json
generated
Normal file
3035
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "facturation-santinova-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^2.1.5",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"chart.js": "^4.4.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.45",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
80
frontend/src/App.tsx
Normal file
80
frontend/src/App.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import InvoiceList from './pages/InvoiceList';
|
||||||
|
import InvoiceDetail from './pages/InvoiceDetail';
|
||||||
|
import InvoiceUpload from './pages/InvoiceUpload';
|
||||||
|
import SupplierList from './pages/SupplierList';
|
||||||
|
import PurchaseOrderList from './pages/PurchaseOrderList';
|
||||||
|
import PurchaseOrderDetail from './pages/PurchaseOrderDetail';
|
||||||
|
import Exports from './pages/Exports';
|
||||||
|
import UserList from './pages/UserList';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="animate-spin h-10 w-10 text-santinova-600 mx-auto mb-4" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-gray-500">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (user?.role !== 'admin') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="invoices" element={<InvoiceList />} />
|
||||||
|
<Route path="invoices/upload" element={<InvoiceUpload />} />
|
||||||
|
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||||
|
<Route path="suppliers" element={<SupplierList />} />
|
||||||
|
<Route path="purchase-orders" element={<PurchaseOrderList />} />
|
||||||
|
<Route path="purchase-orders/:id" element={<PurchaseOrderDetail />} />
|
||||||
|
<Route path="exports" element={<Exports />} />
|
||||||
|
<Route path="users" element={<AdminRoute><UserList /></AdminRoute>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
frontend/src/components/Layout.tsx
Normal file
166
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useLocation, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Tableau de bord', href: '/', icon: HomeIcon },
|
||||||
|
{ name: 'Factures', href: '/invoices', icon: DocumentIcon },
|
||||||
|
{ name: 'Fournisseurs', href: '/suppliers', icon: BuildingIcon },
|
||||||
|
{ name: 'Bons de commande', href: '/purchase-orders', icon: ClipboardIcon },
|
||||||
|
{ name: 'Exports', href: '/exports', icon: DownloadIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNav = [
|
||||||
|
{ name: 'Utilisateurs', href: '/users', icon: UsersIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
function HomeIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuildingIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClipboardIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const allNav = user?.role === 'admin' ? [...navigation, ...adminNav] : navigation;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Mobile sidebar overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 lg:hidden" onClick={() => setSidebarOpen(false)}>
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={`fixed inset-y-0 left-0 z-50 w-64 bg-santinova-900 transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-5 border-b border-santinova-800">
|
||||||
|
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-santinova-900 font-bold text-sm">S</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white font-bold text-lg leading-tight">SANTINOVA</h1>
|
||||||
|
<p className="text-santinova-300 text-xs">Facturation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
|
{allNav.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href ||
|
||||||
|
(item.href !== '/' && location.pathname.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-santinova-700 text-white'
|
||||||
|
: 'text-santinova-200 hover:bg-santinova-800 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
<div className="px-4 py-4 border-t border-santinova-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-santinova-600 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-sm font-medium truncate">{user?.firstName} {user?.lastName}</p>
|
||||||
|
<p className="text-santinova-300 text-xs truncate">{user?.role}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleLogout} className="text-santinova-300 hover:text-white" title="Déconnexion">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="bg-white border-b border-gray-200 px-4 py-3 lg:px-6 flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 p-4 lg:p-6 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/context/AuthContext.tsx
Normal file
77
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { User } from '../types';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedUser = localStorage.getItem('user');
|
||||||
|
if (token && savedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
authAPI.me()
|
||||||
|
.then((res) => {
|
||||||
|
setUser(res.data);
|
||||||
|
localStorage.setItem('user', JSON.stringify(res.data));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const res = await authAPI.login(email, password);
|
||||||
|
const { token: newToken, user: newUser } = res.data;
|
||||||
|
setToken(newToken);
|
||||||
|
setUser(newUser);
|
||||||
|
localStorage.setItem('token', newToken);
|
||||||
|
localStorage.setItem('user', JSON.stringify(newUser));
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, token, login, logout, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
33
frontend/src/index.css
Normal file
33
frontend/src/index.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-santinova-600 hover:bg-santinova-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-white hover:bg-gray-50 text-gray-700 font-medium py-2 px-4 rounded-lg border border-gray-300 transition-colors duration-200 inline-flex items-center gap-2;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 inline-flex items-center gap-2;
|
||||||
|
}
|
||||||
|
.btn-success {
|
||||||
|
@apply bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 inline-flex items-center gap-2;
|
||||||
|
}
|
||||||
|
.input-field {
|
||||||
|
@apply block w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-400 focus:border-santinova-500 focus:ring-1 focus:ring-santinova-500 focus:outline-none text-sm;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
231
frontend/src/pages/Dashboard.tsx
Normal file
231
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement } from 'chart.js';
|
||||||
|
import { Doughnut, Bar, Line } from 'react-chartjs-2';
|
||||||
|
import { dashboardAPI } from '../services/api';
|
||||||
|
import { DashboardData } from '../types';
|
||||||
|
import { formatCurrency, formatDate, statusLabels, statusColors } from '../utils/helpers';
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement);
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const res = await dashboardAPI.get();
|
||||||
|
setData(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur chargement dashboard:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<svg className="animate-spin h-8 w-8 text-santinova-600" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return <p className="text-gray-500">Erreur de chargement</p>;
|
||||||
|
|
||||||
|
const statusChartData = {
|
||||||
|
labels: data.statusCounts.map(s => statusLabels[s.status] || s.status),
|
||||||
|
datasets: [{
|
||||||
|
data: data.statusCounts.map(s => s.count),
|
||||||
|
backgroundColor: ['#3b82f6', '#f59e0b', '#6366f1', '#22c55e', '#10b981', '#ef4444', '#9ca3af'],
|
||||||
|
borderWidth: 0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthlyChartData = {
|
||||||
|
labels: data.monthlyEvolution.map(m => {
|
||||||
|
const [year, month] = m.month.split('-');
|
||||||
|
return `${month}/${year.slice(2)}`;
|
||||||
|
}),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Montant TTC',
|
||||||
|
data: data.monthlyEvolution.map(m => m.total),
|
||||||
|
borderColor: '#2563eb',
|
||||||
|
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const supplierChartData = {
|
||||||
|
labels: data.topSuppliers.map(s => s.supplier_name?.length > 20 ? s.supplier_name.slice(0, 20) + '...' : s.supplier_name),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Montant total',
|
||||||
|
data: data.topSuppliers.map(s => s.total),
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tableau de bord</h1>
|
||||||
|
<Link to="/invoices/upload" className="btn-primary">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nouvelle facture
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Total factures actives</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1">{data.activeTotals.count}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{formatCurrency(data.activeTotals.total)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-santinova-100 rounded-xl flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-santinova-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">En retard</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600 mt-1">{data.overdue.count}</p>
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formatCurrency(data.overdue.total)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Échéance 7 jours</p>
|
||||||
|
<p className="text-2xl font-bold text-amber-600 mt-1">{data.dueSoon.count}</p>
|
||||||
|
<p className="text-sm text-amber-500 mt-1">{formatCurrency(data.dueSoon.total)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-amber-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Échéance 30 jours</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1">{data.dueMonth.count}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{formatCurrency(data.dueMonth.total)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending actions */}
|
||||||
|
{data.pendingForUser.length > 0 && (
|
||||||
|
<div className="card bg-santinova-50 border-santinova-200">
|
||||||
|
<h3 className="font-semibold text-santinova-900 mb-3">Actions en attente</h3>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{data.pendingForUser.map((p, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-8 bg-santinova-600 text-white rounded-full text-sm font-bold">
|
||||||
|
{p.count}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-santinova-800">{p.action}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Répartition par statut</h3>
|
||||||
|
<div className="h-64 flex items-center justify-center">
|
||||||
|
<Doughnut data={statusChartData} options={{ responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } } }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card lg:col-span-2">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Évolution mensuelle</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<Line data={monthlyChartData} options={{ responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: (v) => `${Number(v).toLocaleString('fr-FR')} €` } } } }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Top fournisseurs</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<Bar data={supplierChartData} options={{ responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { ticks: { callback: (v) => `${Number(v).toLocaleString('fr-FR')} €` } } } }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent invoices */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-900">Dernières factures</h3>
|
||||||
|
<Link to="/invoices" className="text-sm text-santinova-600 hover:text-santinova-700">Voir tout</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.recentInvoices.map((inv) => (
|
||||||
|
<Link
|
||||||
|
key={inv.id}
|
||||||
|
to={`/invoices/${inv.id}`}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{inv.invoiceNumber || `#${inv.id}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{inv.supplierName || 'Fournisseur inconnu'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{formatCurrency(inv.amountTTC)}</p>
|
||||||
|
<span className={`badge ${statusColors[inv.status] || 'bg-gray-100 text-gray-800'}`}>
|
||||||
|
{statusLabels[inv.status] || inv.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{data.recentInvoices.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">Aucune facture</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
frontend/src/pages/Exports.tsx
Normal file
178
frontend/src/pages/Exports.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportAPI } from '../services/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function Exports() {
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
status: '',
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState('');
|
||||||
|
|
||||||
|
const handleExportCSV = async () => {
|
||||||
|
setLoading('csv');
|
||||||
|
try {
|
||||||
|
const res = await exportAPI.csv(filters);
|
||||||
|
const blob = new Blob([res.data], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `export_factures_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
toast.success('Export CSV téléchargé');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur d\'export');
|
||||||
|
} finally {
|
||||||
|
setLoading('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportExcel = async () => {
|
||||||
|
setLoading('excel');
|
||||||
|
try {
|
||||||
|
const res = await exportAPI.excel(filters);
|
||||||
|
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `export_factures_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
toast.success('Export Excel téléchargé');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur d\'export');
|
||||||
|
} finally {
|
||||||
|
setLoading('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCEGI = async () => {
|
||||||
|
setLoading('cegi');
|
||||||
|
try {
|
||||||
|
const res = await exportAPI.cegi(filters);
|
||||||
|
toast.success(`Export CEGI préparé: ${res.data.count} écritures`);
|
||||||
|
// Download as JSON for now
|
||||||
|
const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `export_cegi_${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur d\'export');
|
||||||
|
} finally {
|
||||||
|
setLoading('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Exports comptables</h1>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Filtres d'export</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date début</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.startDate}
|
||||||
|
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date fin</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.endDate}
|
||||||
|
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Statut</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option value="approuvee">Approuvées</option>
|
||||||
|
<option value="payee">Payées</option>
|
||||||
|
<option value="validee">Validées</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export options */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* CSV */}
|
||||||
|
<div className="card text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Export CSV</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Format CSV compatible avec la plupart des logiciels comptables et tableurs.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={loading === 'csv'}
|
||||||
|
className="btn-primary w-full justify-center"
|
||||||
|
>
|
||||||
|
{loading === 'csv' ? 'Export en cours...' : 'Télécharger CSV'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Excel */}
|
||||||
|
<div className="card text-center">
|
||||||
|
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 13.125c0-.621.504-1.125 1.125-1.125" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Export Excel</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Format XLSX avec mise en forme pour analyse dans Microsoft Excel ou LibreOffice.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
disabled={loading === 'excel'}
|
||||||
|
className="btn-primary w-full justify-center"
|
||||||
|
>
|
||||||
|
{loading === 'excel' ? 'Export en cours...' : 'Télécharger Excel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CEGI */}
|
||||||
|
<div className="card text-center border-2 border-dashed border-gray-300">
|
||||||
|
<div className="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">CEGI Compta First</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Export préparatoire pour l'intégration future avec CEGI Compta First.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleExportCEGI}
|
||||||
|
disabled={loading === 'cegi'}
|
||||||
|
className="btn-secondary w-full justify-center"
|
||||||
|
>
|
||||||
|
{loading === 'cegi' ? 'Export en cours...' : 'Préparer export CEGI'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">Intégration en cours de développement</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
475
frontend/src/pages/InvoiceDetail.tsx
Normal file
475
frontend/src/pages/InvoiceDetail.tsx
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { invoiceAPI, purchaseOrderAPI } from '../services/api';
|
||||||
|
import { Invoice, PurchaseOrder } from '../types';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { formatCurrency, formatDate, formatDateTime, statusLabels, statusColors, sourceLabels, matchingLabels, matchingColors } from '../utils/helpers';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function InvoiceDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editData, setEditData] = useState<any>({});
|
||||||
|
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
||||||
|
const [selectedPO, setSelectedPO] = useState<number | ''>('');
|
||||||
|
const [statusNote, setStatusNote] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) loadInvoice();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadInvoice = async () => {
|
||||||
|
try {
|
||||||
|
const res = await invoiceAPI.get(parseInt(id!));
|
||||||
|
setInvoice(res.data);
|
||||||
|
setEditData({
|
||||||
|
invoiceNumber: res.data.invoiceNumber || '',
|
||||||
|
supplierName: res.data.supplierName || '',
|
||||||
|
supplierSiret: res.data.supplierSiret || '',
|
||||||
|
supplierAddress: res.data.supplierAddress || '',
|
||||||
|
invoiceDate: res.data.invoiceDate ? res.data.invoiceDate.slice(0, 10) : '',
|
||||||
|
dueDate: res.data.dueDate ? res.data.dueDate.slice(0, 10) : '',
|
||||||
|
amountHT: res.data.amountHT || '',
|
||||||
|
amountTVA: res.data.amountTVA || '',
|
||||||
|
amountTTC: res.data.amountTTC || '',
|
||||||
|
tvaRate: res.data.tvaRate || '',
|
||||||
|
notes: res.data.notes || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load POs for matching
|
||||||
|
const poRes = await purchaseOrderAPI.list({ limit: 100 });
|
||||||
|
setPurchaseOrders(poRes.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur de chargement');
|
||||||
|
navigate('/invoices');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await invoiceAPI.update(parseInt(id!), editData);
|
||||||
|
toast.success('Facture mise à jour');
|
||||||
|
setEditing(false);
|
||||||
|
loadInvoice();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur de mise à jour');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: string) => {
|
||||||
|
try {
|
||||||
|
await invoiceAPI.changeStatus(parseInt(id!), newStatus, statusNote);
|
||||||
|
toast.success(`Statut mis à jour: ${statusLabels[newStatus]}`);
|
||||||
|
setStatusNote('');
|
||||||
|
loadInvoice();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur de changement de statut');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMatch = async () => {
|
||||||
|
if (!selectedPO) return;
|
||||||
|
try {
|
||||||
|
const res = await invoiceAPI.match(parseInt(id!), selectedPO as number);
|
||||||
|
toast.success(`Rapprochement effectué: ${res.data.matchingStatus === 'rapproche' ? 'OK' : 'Écart détecté'}`);
|
||||||
|
loadInvoice();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur de rapprochement');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Supprimer cette facture ?')) return;
|
||||||
|
try {
|
||||||
|
await invoiceAPI.delete(parseInt(id!));
|
||||||
|
toast.success('Facture supprimée');
|
||||||
|
navigate('/invoices');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur de suppression');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableActions = () => {
|
||||||
|
if (!invoice || !user) return [];
|
||||||
|
const actions: Array<{ status: string; label: string; color: string }> = [];
|
||||||
|
const role = user.role;
|
||||||
|
const s = invoice.status;
|
||||||
|
|
||||||
|
if (s === 'recue' && ['operateur', 'validateur', 'approbateur', 'admin'].includes(role)) {
|
||||||
|
actions.push({ status: 'en_verification', label: 'Passer en vérification', color: 'btn-primary' });
|
||||||
|
}
|
||||||
|
if (s === 'en_verification' && ['validateur', 'approbateur', 'admin'].includes(role)) {
|
||||||
|
actions.push({ status: 'validee', label: 'Valider', color: 'btn-success' });
|
||||||
|
}
|
||||||
|
if (s === 'validee' && ['approbateur', 'admin'].includes(role)) {
|
||||||
|
actions.push({ status: 'approuvee', label: 'Approuver', color: 'btn-success' });
|
||||||
|
}
|
||||||
|
if (s === 'approuvee' && role === 'admin') {
|
||||||
|
actions.push({ status: 'payee', label: 'Marquer comme payée', color: 'btn-success' });
|
||||||
|
}
|
||||||
|
if (s === 'payee' && role === 'admin') {
|
||||||
|
actions.push({ status: 'archivee', label: 'Archiver', color: 'btn-secondary' });
|
||||||
|
}
|
||||||
|
if (!['payee', 'archivee', 'rejetee'].includes(s) && ['validateur', 'approbateur', 'admin'].includes(role)) {
|
||||||
|
actions.push({ status: 'rejetee', label: 'Rejeter', color: 'btn-danger' });
|
||||||
|
}
|
||||||
|
if (s === 'rejetee' && ['operateur', 'validateur', 'approbateur', 'admin'].includes(role)) {
|
||||||
|
actions.push({ status: 'recue', label: 'Remettre en réception', color: 'btn-secondary' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<svg className="animate-spin h-8 w-8 text-santinova-600" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
const actions = getAvailableActions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => navigate('/invoices')} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Facture {invoice.invoiceNumber || `#${invoice.id}`}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500">{invoice.supplierName || 'Fournisseur inconnu'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`badge text-sm px-3 py-1 ${statusColors[invoice.status]}`}>
|
||||||
|
{statusLabels[invoice.status]}
|
||||||
|
</span>
|
||||||
|
{!editing ? (
|
||||||
|
<button onClick={() => setEditing(true)} className="btn-secondary text-sm">Modifier</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} className="btn-primary text-sm">Enregistrer</button>
|
||||||
|
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Annuler</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<button onClick={handleDelete} className="btn-danger text-sm">Supprimer</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow actions */}
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="card bg-gray-50">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Actions de workflow</h3>
|
||||||
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Note (optionnelle)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={statusNote}
|
||||||
|
onChange={(e) => setStatusNote(e.target.value)}
|
||||||
|
placeholder="Ajouter une note..."
|
||||||
|
className="input-field text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{actions.map(action => (
|
||||||
|
<button
|
||||||
|
key={action.status}
|
||||||
|
onClick={() => handleStatusChange(action.status)}
|
||||||
|
className={`${action.color} text-sm`}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main info */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Informations générales</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">N° Facture</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="text" value={editData.invoiceNumber} onChange={(e) => setEditData({ ...editData, invoiceNumber: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">{invoice.invoiceNumber || '-'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Fournisseur</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="text" value={editData.supplierName} onChange={(e) => setEditData({ ...editData, supplierName: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">{invoice.supplierName || '-'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">SIRET</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="text" value={editData.supplierSiret} onChange={(e) => setEditData({ ...editData, supplierSiret: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{invoice.supplierSiret || '-'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Adresse fournisseur</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="text" value={editData.supplierAddress} onChange={(e) => setEditData({ ...editData, supplierAddress: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{invoice.supplierAddress || '-'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Date de facture</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="date" value={editData.invoiceDate} onChange={(e) => setEditData({ ...editData, invoiceDate: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{formatDate(invoice.invoiceDate)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Date d'échéance</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="date" value={editData.dueDate} onChange={(e) => setEditData({ ...editData, dueDate: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{formatDate(invoice.dueDate)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amounts */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Montants</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Montant HT</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="number" step="0.01" value={editData.amountHT} onChange={(e) => setEditData({ ...editData, amountHT: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-semibold">{formatCurrency(invoice.amountHT)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">TVA</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="number" step="0.01" value={editData.amountTVA} onChange={(e) => setEditData({ ...editData, amountTVA: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-semibold">{formatCurrency(invoice.amountTVA)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Montant TTC</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="number" step="0.01" value={editData.amountTTC} onChange={(e) => setEditData({ ...editData, amountTTC: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-bold text-santinova-600">{formatCurrency(invoice.amountTTC)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Taux TVA</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="number" step="0.01" value={editData.tvaRate} onChange={(e) => setEditData({ ...editData, tvaRate: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{invoice.tvaRate ? `${invoice.tvaRate}%` : '-'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice lines */}
|
||||||
|
{invoice.lines && invoice.lines.length > 0 && (
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="font-semibold text-gray-900">Lignes de facture</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Description</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">Qté</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">P.U.</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">HT</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">TVA</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">TTC</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{invoice.lines.map((line, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="px-6 py-3 text-sm">{line.description || '-'}</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-right">{line.quantity || '-'}</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-right">{line.unitPrice ? formatCurrency(line.unitPrice) : '-'}</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-right">{line.amountHT ? formatCurrency(line.amountHT) : '-'}</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-right">{line.amountTVA ? formatCurrency(line.amountTVA) : '-'}</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-right font-medium">{line.amountTTC ? formatCurrency(line.amountTTC) : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Notes</h3>
|
||||||
|
{editing ? (
|
||||||
|
<textarea
|
||||||
|
value={editData.notes}
|
||||||
|
onChange={(e) => setEditData({ ...editData, notes: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600">{invoice.notes || 'Aucune note'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* File preview */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Document original</h3>
|
||||||
|
{invoice.filePath ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">{invoice.originalFileName}</p>
|
||||||
|
<a
|
||||||
|
href={`/api/invoices/${invoice.id}/file`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-secondary text-sm w-full justify-center"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
Voir le document
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">Aucun document</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Métadonnées</h3>
|
||||||
|
<dl className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-gray-500">Source</dt>
|
||||||
|
<dd>{sourceLabels[invoice.source]}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-gray-500">Réception</dt>
|
||||||
|
<dd>{formatDate(invoice.receptionDate)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-gray-500">Confiance OCR</dt>
|
||||||
|
<dd>{invoice.ocrConfidence ? `${invoice.ocrConfidence}%` : '-'}</dd>
|
||||||
|
</div>
|
||||||
|
{invoice.validatedAt && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-gray-500">Validée le</dt>
|
||||||
|
<dd>{formatDateTime(invoice.validatedAt)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.approvedAt && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-gray-500">Approuvée le</dt>
|
||||||
|
<dd>{formatDateTime(invoice.approvedAt)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.paidAt && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-gray-500">Payée le</dt>
|
||||||
|
<dd>{formatDateTime(invoice.paidAt)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matching */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Rapprochement BC</h3>
|
||||||
|
{invoice.purchaseOrderId ? (
|
||||||
|
<div>
|
||||||
|
<span className={`badge ${matchingColors[invoice.matchingStatus || 'non_rapproche']}`}>
|
||||||
|
{matchingLabels[invoice.matchingStatus || 'non_rapproche']}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">{invoice.matchingNotes}</p>
|
||||||
|
<Link to={`/purchase-orders/${invoice.purchaseOrderId}`} className="text-sm text-santinova-600 hover:underline mt-1 block">
|
||||||
|
Voir le bon de commande
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">Non rapproché</p>
|
||||||
|
<select
|
||||||
|
value={selectedPO}
|
||||||
|
onChange={(e) => setSelectedPO(e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
className="input-field text-sm mb-2"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un BC...</option>
|
||||||
|
{purchaseOrders.map(po => (
|
||||||
|
<option key={po.id} value={po.id}>
|
||||||
|
{po.orderNumber} - {po.supplierName} ({formatCurrency(po.amountTTC)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={handleMatch} disabled={!selectedPO} className="btn-primary text-sm w-full justify-center">
|
||||||
|
Rapprocher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit log */}
|
||||||
|
{invoice.auditLog && invoice.auditLog.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Historique</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{invoice.auditLog.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex gap-3">
|
||||||
|
<div className="w-2 h-2 bg-santinova-400 rounded-full mt-1.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-900">{entry.action.replace(/_/g, ' ')}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{entry.userName && `${entry.userName} - `}{formatDateTime(entry.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
frontend/src/pages/InvoiceList.tsx
Normal file
185
frontend/src/pages/InvoiceList.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { invoiceAPI } from '../services/api';
|
||||||
|
import { Invoice, PaginatedResponse } from '../types';
|
||||||
|
import { formatCurrency, formatDate, statusLabels, statusColors, sourceLabels, isOverdue, isDueSoon } from '../utils/helpers';
|
||||||
|
|
||||||
|
export default function InvoiceList() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({ total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||||
|
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInvoices();
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const loadInvoices = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page: searchParams.get('page') || 1,
|
||||||
|
limit: 20,
|
||||||
|
};
|
||||||
|
if (searchParams.get('status')) params.status = searchParams.get('status');
|
||||||
|
if (searchParams.get('search')) params.search = searchParams.get('search');
|
||||||
|
|
||||||
|
const res = await invoiceAPI.list(params);
|
||||||
|
setInvoices(res.data.data);
|
||||||
|
setPagination(res.data.pagination);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur chargement factures:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
|
params.set('page', '1');
|
||||||
|
setSearchParams(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusFilter = (status: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (status) {
|
||||||
|
params.set('status', status);
|
||||||
|
} else {
|
||||||
|
params.delete('status');
|
||||||
|
}
|
||||||
|
params.set('page', '1');
|
||||||
|
setStatusFilter(status);
|
||||||
|
setSearchParams(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set('page', page.toString());
|
||||||
|
setSearchParams(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses = ['', 'recue', 'en_verification', 'validee', 'approuvee', 'payee', 'rejetee'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Factures</h1>
|
||||||
|
<Link to="/invoices/upload" className="btn-primary">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Importer une facture
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Rechercher (n° facture, fournisseur...)"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => handleStatusFilter(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
<option value="">Tous les statuts</option>
|
||||||
|
{statuses.filter(s => s).map(s => (
|
||||||
|
<option key={s} value={s}>{statusLabels[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary">Rechercher</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">N° Facture</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Fournisseur</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Date</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Échéance</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Montant TTC</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||||
|
) : invoices.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Aucune facture trouvée</td></tr>
|
||||||
|
) : invoices.map((inv) => (
|
||||||
|
<tr key={inv.id} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Link to={`/invoices/${inv.id}`} className="text-sm font-medium text-santinova-600 hover:text-santinova-700">
|
||||||
|
{inv.invoiceNumber || `#${inv.id}`}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="text-sm text-gray-900">{inv.supplierName || '-'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(inv.invoiceDate)}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`text-sm ${isOverdue(inv.dueDate, inv.status) ? 'text-red-600 font-medium' : isDueSoon(inv.dueDate, inv.status) ? 'text-amber-600' : 'text-gray-500'}`}>
|
||||||
|
{formatDate(inv.dueDate)}
|
||||||
|
{isOverdue(inv.dueDate, inv.status) && ' ⚠'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-sm font-medium text-gray-900">{formatCurrency(inv.amountTTC)}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`badge ${statusColors[inv.status] || 'bg-gray-100 text-gray-800'}`}>
|
||||||
|
{statusLabels[inv.status] || inv.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{sourceLabels[inv.source] || inv.source}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 bg-gray-50">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{pagination.total} facture{pagination.total > 1 ? 's' : ''} - Page {pagination.page}/{pagination.totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => goToPage(pagination.page - 1)}
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
className="btn-secondary text-sm py-1 px-3 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => goToPage(pagination.page + 1)}
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
className="btn-secondary text-sm py-1 px-3 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/src/pages/InvoiceUpload.tsx
Normal file
203
frontend/src/pages/InvoiceUpload.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { invoiceAPI } from '../services/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function InvoiceUpload() {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [source, setSource] = useState('upload');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<Record<string, 'pending' | 'uploading' | 'done' | 'error'>>({});
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files).filter(f =>
|
||||||
|
f.type === 'application/pdf' || f.type.startsWith('image/')
|
||||||
|
);
|
||||||
|
setFiles(prev => [...prev, ...droppedFiles]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const selectedFiles = Array.from(e.target.files);
|
||||||
|
setFiles(prev => [...prev, ...selectedFiles]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
toast.error('Veuillez sélectionner au moins un fichier');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const newProgress: Record<string, 'pending' | 'uploading' | 'done' | 'error'> = {};
|
||||||
|
files.forEach(f => { newProgress[f.name] = 'pending'; });
|
||||||
|
setProgress(newProgress);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
setProgress(prev => ({ ...prev, [file.name]: 'uploading' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('source', source);
|
||||||
|
|
||||||
|
await invoiceAPI.upload(formData);
|
||||||
|
setProgress(prev => ({ ...prev, [file.name]: 'done' }));
|
||||||
|
successCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
setProgress(prev => ({ ...prev, [file.name]: 'error' }));
|
||||||
|
toast.error(`Erreur pour ${file.name}: ${error.response?.data?.error || 'Erreur inconnue'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(false);
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(`${successCount} facture${successCount > 1 ? 's' : ''} importée${successCount > 1 ? 's' : ''} avec succès`);
|
||||||
|
setTimeout(() => navigate('/invoices'), 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} o`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Importer des factures</h1>
|
||||||
|
|
||||||
|
{/* Source selection */}
|
||||||
|
<div className="card">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Source du document</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ value: 'upload', label: 'Upload manuel', icon: '📄' },
|
||||||
|
{ value: 'scan', label: 'Scan papier', icon: '📷' },
|
||||||
|
{ value: 'email', label: 'Email', icon: '📧' },
|
||||||
|
{ value: 'portail', label: 'Portail fournisseur', icon: '🌐' },
|
||||||
|
].map(s => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
onClick={() => setSource(s.value)}
|
||||||
|
className={`p-3 rounded-lg border-2 text-center transition-colors ${
|
||||||
|
source === s.value
|
||||||
|
? 'border-santinova-500 bg-santinova-50 text-santinova-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl block mb-1">{s.icon}</span>
|
||||||
|
<span className="text-sm font-medium">{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="card border-2 border-dashed border-gray-300 hover:border-santinova-400 cursor-pointer transition-colors text-center py-12"
|
||||||
|
>
|
||||||
|
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-gray-600 font-medium">Glissez-déposez vos fichiers ici</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">ou cliquez pour sélectionner</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">PDF, JPEG, PNG, TIFF - Max 20 Mo par fichier</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.tiff,.tif,.webp,.bmp"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Fichiers sélectionnés ({files.length})</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-8 h-8 bg-santinova-100 rounded flex items-center justify-center text-santinova-600 text-xs font-medium">
|
||||||
|
{file.name.split('.').pop()?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{progress[file.name] === 'uploading' && (
|
||||||
|
<svg className="animate-spin h-5 w-5 text-santinova-600" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{progress[file.name] === 'done' && (
|
||||||
|
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{progress[file.name] === 'error' && (
|
||||||
|
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!uploading && (
|
||||||
|
<button onClick={() => removeFile(index)} className="text-gray-400 hover:text-red-500">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="btn-primary flex-1 justify-center"
|
||||||
|
>
|
||||||
|
{uploading ? 'Import en cours...' : `Importer ${files.length} fichier${files.length > 1 ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
{!uploading && (
|
||||||
|
<button onClick={() => setFiles([])} className="btn-secondary">
|
||||||
|
Tout effacer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info box */}
|
||||||
|
<div className="card bg-blue-50 border-blue-200">
|
||||||
|
<h3 className="font-semibold text-blue-900 mb-2">Extraction automatique par IA</h3>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Après l'import, l'IA analysera automatiquement vos documents pour extraire les informations clés :
|
||||||
|
fournisseur, numéro de facture, dates, montants et lignes de détail.
|
||||||
|
Vous pourrez ensuite vérifier et corriger les données extraites.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
frontend/src/pages/Login.tsx
Normal file
94
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
toast.success('Connexion réussie');
|
||||||
|
navigate('/');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Identifiants incorrects');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-santinova-900 via-santinova-800 to-santinova-700 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-white rounded-2xl shadow-lg mb-4">
|
||||||
|
<span className="text-santinova-900 font-bold text-2xl">S</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">SANTINOVA</h1>
|
||||||
|
<p className="text-santinova-200 mt-1">Gestion de Facturation</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-6">Connexion</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Adresse email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-primary w-full justify-center py-2.5"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-santinova-300 text-sm mt-6">
|
||||||
|
Facturation SANTINOVA v1.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
frontend/src/pages/PurchaseOrderDetail.tsx
Normal file
242
frontend/src/pages/PurchaseOrderDetail.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { purchaseOrderAPI } from '../services/api';
|
||||||
|
import { PurchaseOrder } from '../types';
|
||||||
|
import { formatCurrency, formatDate, poStatusLabels, poStatusColors, statusLabels, matchingLabels } from '../utils/helpers';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function PurchaseOrderDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [po, setPO] = useState<PurchaseOrder | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editData, setEditData] = useState<any>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) loadPO();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadPO = async () => {
|
||||||
|
try {
|
||||||
|
const res = await purchaseOrderAPI.get(parseInt(id!));
|
||||||
|
setPO(res.data);
|
||||||
|
setEditData({
|
||||||
|
orderNumber: res.data.orderNumber,
|
||||||
|
supplierName: res.data.supplierName || '',
|
||||||
|
orderDate: res.data.orderDate ? res.data.orderDate.slice(0, 10) : '',
|
||||||
|
expectedDeliveryDate: res.data.expectedDeliveryDate ? res.data.expectedDeliveryDate.slice(0, 10) : '',
|
||||||
|
amountHT: res.data.amountHT || '',
|
||||||
|
amountTVA: res.data.amountTVA || '',
|
||||||
|
amountTTC: res.data.amountTTC || '',
|
||||||
|
status: res.data.status,
|
||||||
|
description: res.data.description || '',
|
||||||
|
notes: res.data.notes || '',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur de chargement');
|
||||||
|
navigate('/purchase-orders');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await purchaseOrderAPI.update(parseInt(id!), editData);
|
||||||
|
toast.success('Bon de commande mis à jour');
|
||||||
|
setEditing(false);
|
||||||
|
loadPO();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Supprimer ce bon de commande ?')) return;
|
||||||
|
try {
|
||||||
|
await purchaseOrderAPI.delete(parseInt(id!));
|
||||||
|
toast.success('Bon de commande supprimé');
|
||||||
|
navigate('/purchase-orders');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<svg className="animate-spin h-8 w-8 text-santinova-600" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!po) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => navigate('/purchase-orders')} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">BC {po.orderNumber}</h1>
|
||||||
|
<p className="text-sm text-gray-500">{po.supplierName || 'Fournisseur inconnu'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`badge text-sm px-3 py-1 ${poStatusColors[po.status]}`}>
|
||||||
|
{poStatusLabels[po.status]}
|
||||||
|
</span>
|
||||||
|
{!editing ? (
|
||||||
|
<button onClick={() => setEditing(true)} className="btn-secondary text-sm">Modifier</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} className="btn-primary text-sm">Enregistrer</button>
|
||||||
|
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Annuler</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button onClick={handleDelete} className="btn-danger text-sm">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Informations</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">N° Commande</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="text" value={editData.orderNumber} onChange={(e) => setEditData({ ...editData, orderNumber: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">{po.orderNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Fournisseur</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="text" value={editData.supplierName} onChange={(e) => setEditData({ ...editData, supplierName: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">{po.supplierName || '-'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Date commande</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="date" value={editData.orderDate} onChange={(e) => setEditData({ ...editData, orderDate: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{formatDate(po.orderDate)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Livraison prévue</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="date" value={editData.expectedDeliveryDate} onChange={(e) => setEditData({ ...editData, expectedDeliveryDate: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{formatDate(po.expectedDeliveryDate)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Statut</label>
|
||||||
|
<select value={editData.status} onChange={(e) => setEditData({ ...editData, status: e.target.value })} className="input-field">
|
||||||
|
{Object.entries(poStatusLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Montants</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">HT</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="number" step="0.01" value={editData.amountHT} onChange={(e) => setEditData({ ...editData, amountHT: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-semibold">{formatCurrency(po.amountHT)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">TVA</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="number" step="0.01" value={editData.amountTVA} onChange={(e) => setEditData({ ...editData, amountTVA: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-semibold">{formatCurrency(po.amountTVA)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">TTC</label>
|
||||||
|
{editing ? (
|
||||||
|
<input type="number" step="0.01" value={editData.amountTTC} onChange={(e) => setEditData({ ...editData, amountTTC: e.target.value })} className="input-field" />
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-bold text-santinova-600">{formatCurrency(po.amountTTC)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Description</h3>
|
||||||
|
{editing ? (
|
||||||
|
<textarea value={editData.description} onChange={(e) => setEditData({ ...editData, description: e.target.value })} className="input-field" rows={3} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600">{po.description || 'Aucune description'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Linked invoices */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Factures liées</h3>
|
||||||
|
{po.linkedInvoices && po.linkedInvoices.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{po.linkedInvoices.map((inv: any) => (
|
||||||
|
<Link
|
||||||
|
key={inv.id}
|
||||||
|
to={`/invoices/${inv.id}`}
|
||||||
|
className="block p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{inv.invoiceNumber || `#${inv.id}`}</p>
|
||||||
|
<div className="flex justify-between mt-1">
|
||||||
|
<span className="text-xs text-gray-500">{formatCurrency(inv.amountTTC)}</span>
|
||||||
|
<span className="text-xs text-gray-500">{statusLabels[inv.status]}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">Aucune facture liée</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lines */}
|
||||||
|
{po.lines && po.lines.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Lignes de commande</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{po.lines.map((line, idx) => (
|
||||||
|
<div key={idx} className="p-2 bg-gray-50 rounded text-sm">
|
||||||
|
<p className="font-medium">{line.description || '-'}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Qté: {line.quantity || '-'} | PU: {line.unitPrice ? formatCurrency(line.unitPrice) : '-'} | HT: {line.amountHT ? formatCurrency(line.amountHT) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
frontend/src/pages/PurchaseOrderList.tsx
Normal file
188
frontend/src/pages/PurchaseOrderList.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { purchaseOrderAPI, supplierAPI } from '../services/api';
|
||||||
|
import { PurchaseOrder, Supplier } from '../types';
|
||||||
|
import { formatCurrency, formatDate, poStatusLabels, poStatusColors } from '../utils/helpers';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function PurchaseOrderList() {
|
||||||
|
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({ total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
orderNumber: '', supplierId: '', supplierName: '', orderDate: new Date().toISOString().slice(0, 10),
|
||||||
|
expectedDeliveryDate: '', amountHT: '', amountTVA: '', amountTTC: '', description: '', notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOrders();
|
||||||
|
loadSuppliers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadOrders = async () => {
|
||||||
|
try {
|
||||||
|
const res = await purchaseOrderAPI.list({ search: search || undefined });
|
||||||
|
setOrders(res.data.data);
|
||||||
|
setPagination(res.data.pagination);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await supplierAPI.list({ active: 'true' });
|
||||||
|
setSuppliers(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
loadOrders();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!form.orderNumber || !form.orderDate) {
|
||||||
|
toast.error('Numéro et date requis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const selectedSupplier = suppliers.find(s => s.id === parseInt(form.supplierId));
|
||||||
|
await purchaseOrderAPI.create({
|
||||||
|
...form,
|
||||||
|
supplierId: form.supplierId ? parseInt(form.supplierId) : null,
|
||||||
|
supplierName: selectedSupplier?.name || form.supplierName,
|
||||||
|
});
|
||||||
|
toast.success('Bon de commande créé');
|
||||||
|
setShowModal(false);
|
||||||
|
loadOrders();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Bons de commande</h1>
|
||||||
|
<button onClick={() => { setForm({ orderNumber: '', supplierId: '', supplierName: '', orderDate: new Date().toISOString().slice(0, 10), expectedDeliveryDate: '', amountHT: '', amountTVA: '', amountTTC: '', description: '', notes: '' }); setShowModal(true); }} className="btn-primary">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nouveau BC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-4">
|
||||||
|
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Rechercher..." className="input-field flex-1" />
|
||||||
|
<button type="submit" className="btn-primary">Rechercher</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">N° Commande</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Fournisseur</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Date</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Montant TTC</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">Aucun bon de commande</td></tr>
|
||||||
|
) : orders.map((po) => (
|
||||||
|
<tr key={po.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Link to={`/purchase-orders/${po.id}`} className="text-sm font-medium text-santinova-600 hover:text-santinova-700">
|
||||||
|
{po.orderNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-900">{po.supplierName || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(po.orderDate)}</td>
|
||||||
|
<td className="px-6 py-4 text-right text-sm font-medium text-gray-900">{formatCurrency(po.amountTTC)}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`badge ${poStatusColors[po.status] || 'bg-gray-100 text-gray-800'}`}>
|
||||||
|
{poStatusLabels[po.status] || po.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<Link to={`/purchase-orders/${po.id}`} className="text-santinova-600 hover:text-santinova-700 text-sm">Détails</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold">Nouveau bon de commande</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">N° Commande *</label>
|
||||||
|
<input type="text" value={form.orderNumber} onChange={(e) => setForm({ ...form, orderNumber: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Fournisseur</label>
|
||||||
|
<select value={form.supplierId} onChange={(e) => setForm({ ...form, supplierId: e.target.value })} className="input-field">
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date commande *</label>
|
||||||
|
<input type="date" value={form.orderDate} onChange={(e) => setForm({ ...form, orderDate: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date livraison prévue</label>
|
||||||
|
<input type="date" value={form.expectedDeliveryDate} onChange={(e) => setForm({ ...form, expectedDeliveryDate: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Montant HT</label>
|
||||||
|
<input type="number" step="0.01" value={form.amountHT} onChange={(e) => setForm({ ...form, amountHT: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">TVA</label>
|
||||||
|
<input type="number" step="0.01" value={form.amountTVA} onChange={(e) => setForm({ ...form, amountTVA: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Montant TTC</label>
|
||||||
|
<input type="number" step="0.01" value={form.amountTTC} onChange={(e) => setForm({ ...form, amountTTC: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="input-field" rows={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||||
|
<button onClick={() => setShowModal(false)} className="btn-secondary">Annuler</button>
|
||||||
|
<button onClick={handleCreate} className="btn-primary">Créer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
frontend/src/pages/SupplierList.tsx
Normal file
219
frontend/src/pages/SupplierList.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { supplierAPI } from '../services/api';
|
||||||
|
import { Supplier } from '../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function SupplierList() {
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '', siret: '', address: '', city: '', postalCode: '', country: 'France',
|
||||||
|
email: '', phone: '', contactName: '', iban: '', notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSuppliers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await supplierAPI.list({ search: search || undefined });
|
||||||
|
setSuppliers(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
loadSuppliers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingSupplier(null);
|
||||||
|
setForm({ name: '', siret: '', address: '', city: '', postalCode: '', country: 'France', email: '', phone: '', contactName: '', iban: '', notes: '' });
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (supplier: Supplier) => {
|
||||||
|
setEditingSupplier(supplier);
|
||||||
|
setForm({
|
||||||
|
name: supplier.name || '',
|
||||||
|
siret: supplier.siret || '',
|
||||||
|
address: supplier.address || '',
|
||||||
|
city: supplier.city || '',
|
||||||
|
postalCode: supplier.postalCode || '',
|
||||||
|
country: supplier.country || 'France',
|
||||||
|
email: supplier.email || '',
|
||||||
|
phone: supplier.phone || '',
|
||||||
|
contactName: supplier.contactName || '',
|
||||||
|
iban: supplier.iban || '',
|
||||||
|
notes: supplier.notes || '',
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name) {
|
||||||
|
toast.error('Le nom est requis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingSupplier) {
|
||||||
|
await supplierAPI.update(editingSupplier.id, form);
|
||||||
|
toast.success('Fournisseur mis à jour');
|
||||||
|
} else {
|
||||||
|
await supplierAPI.create(form);
|
||||||
|
toast.success('Fournisseur créé');
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
loadSuppliers();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('Désactiver ce fournisseur ?')) return;
|
||||||
|
try {
|
||||||
|
await supplierAPI.delete(id);
|
||||||
|
toast.success('Fournisseur désactivé');
|
||||||
|
loadSuppliers();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Fournisseurs</h1>
|
||||||
|
<button onClick={openCreate} className="btn-primary">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nouveau fournisseur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Rechercher un fournisseur..."
|
||||||
|
className="input-field flex-1"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary">Rechercher</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Nom</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">SIRET</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Ville</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Email</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Téléphone</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||||
|
) : suppliers.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Aucun fournisseur</td></tr>
|
||||||
|
) : suppliers.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-gray-900">{s.name}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{s.siret || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{s.city || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{s.email || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{s.phone || '-'}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`badge ${s.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||||
|
{s.isActive ? 'Actif' : 'Inactif'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button onClick={() => openEdit(s)} className="text-santinova-600 hover:text-santinova-700 text-sm mr-3">Modifier</button>
|
||||||
|
<button onClick={() => handleDelete(s.id)} className="text-red-600 hover:text-red-700 text-sm">Désactiver</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold">{editingSupplier ? 'Modifier le fournisseur' : 'Nouveau fournisseur'}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
|
||||||
|
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">SIRET</label>
|
||||||
|
<input type="text" value={form.siret} onChange={(e) => setForm({ ...form, siret: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||||
|
<input type="text" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ville</label>
|
||||||
|
<input type="text" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Code postal</label>
|
||||||
|
<input type="text" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Téléphone</label>
|
||||||
|
<input type="text" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Contact</label>
|
||||||
|
<input type="text" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
|
||||||
|
<input type="text" value={form.iban} onChange={(e) => setForm({ ...form, iban: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} className="input-field" rows={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||||
|
<button onClick={() => setShowModal(false)} className="btn-secondary">Annuler</button>
|
||||||
|
<button onClick={handleSave} className="btn-primary">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
frontend/src/pages/UserList.tsx
Normal file
194
frontend/src/pages/UserList.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
import { User } from '../types';
|
||||||
|
import { roleLabels } from '../utils/helpers';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function UserList() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: '', password: '', firstName: '', lastName: '', role: 'operateur',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authAPI.getUsers();
|
||||||
|
setUsers(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'operateur' });
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setForm({
|
||||||
|
email: user.email,
|
||||||
|
password: '',
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.email || !form.firstName || !form.lastName) {
|
||||||
|
toast.error('Tous les champs sont requis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingUser && !form.password) {
|
||||||
|
toast.error('Le mot de passe est requis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingUser) {
|
||||||
|
await authAPI.updateUser(editingUser.id, {
|
||||||
|
email: form.email,
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
role: form.role,
|
||||||
|
isActive: true,
|
||||||
|
password: form.password || undefined,
|
||||||
|
});
|
||||||
|
toast.success('Utilisateur mis à jour');
|
||||||
|
} else {
|
||||||
|
await authAPI.createUser(form);
|
||||||
|
toast.success('Utilisateur créé');
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = async (id: number) => {
|
||||||
|
if (!confirm('Désactiver cet utilisateur ?')) return;
|
||||||
|
try {
|
||||||
|
await authAPI.deleteUser(id);
|
||||||
|
toast.success('Utilisateur désactivé');
|
||||||
|
loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Gestion des utilisateurs</h1>
|
||||||
|
<button onClick={openCreate} className="btn-primary">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nouvel utilisateur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Nom</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Email</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Rôle</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||||
|
) : users.map((u) => (
|
||||||
|
<tr key={u.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-santinova-100 rounded-full flex items-center justify-center text-santinova-600 text-sm font-medium">
|
||||||
|
{u.firstName?.[0]}{u.lastName?.[0]}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{u.firstName} {u.lastName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{u.email}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="badge bg-santinova-100 text-santinova-800">{roleLabels[u.role] || u.role}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`badge ${u.isActive !== false ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||||
|
{u.isActive !== false ? 'Actif' : 'Inactif'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button onClick={() => openEdit(u)} className="text-santinova-600 hover:text-santinova-700 text-sm mr-3">Modifier</button>
|
||||||
|
<button onClick={() => handleDeactivate(u.id)} className="text-red-600 hover:text-red-700 text-sm">Désactiver</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold">{editingUser ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur'}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Prénom *</label>
|
||||||
|
<input type="text" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
|
||||||
|
<input type="text" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||||
|
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Mot de passe {editingUser ? '(laisser vide pour ne pas changer)' : '*'}
|
||||||
|
</label>
|
||||||
|
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rôle</label>
|
||||||
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="input-field">
|
||||||
|
{Object.entries(roleLabels).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||||
|
<button onClick={() => setShowModal(false)} className="btn-secondary">Annuler</button>
|
||||||
|
<button onClick={handleSave} className="btn-primary">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/services/api.ts
Normal file
99
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - add auth token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor - handle 401
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
export const authAPI = {
|
||||||
|
login: (email: string, password: string) => api.post('/auth/login', { email, password }),
|
||||||
|
me: () => api.get('/auth/me'),
|
||||||
|
getUsers: () => api.get('/auth/users'),
|
||||||
|
createUser: (data: any) => api.post('/auth/users', data),
|
||||||
|
updateUser: (id: number, data: any) => api.put(`/auth/users/${id}`, data),
|
||||||
|
deleteUser: (id: number) => api.delete(`/auth/users/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
export const invoiceAPI = {
|
||||||
|
list: (params?: any) => api.get('/invoices', { params }),
|
||||||
|
get: (id: number) => api.get(`/invoices/${id}`),
|
||||||
|
upload: (formData: FormData) => api.post('/invoices/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}),
|
||||||
|
update: (id: number, data: any) => api.put(`/invoices/${id}`, data),
|
||||||
|
updateLines: (id: number, lines: any[]) => api.put(`/invoices/${id}/lines`, { lines }),
|
||||||
|
changeStatus: (id: number, status: string, notes?: string) => api.post(`/invoices/${id}/status`, { status, notes }),
|
||||||
|
match: (id: number, purchaseOrderId: number) => api.post(`/invoices/${id}/match`, { purchaseOrderId }),
|
||||||
|
delete: (id: number) => api.delete(`/invoices/${id}`),
|
||||||
|
stats: () => api.get('/invoices/stats'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suppliers
|
||||||
|
export const supplierAPI = {
|
||||||
|
list: (params?: any) => api.get('/suppliers', { params }),
|
||||||
|
get: (id: number) => api.get(`/suppliers/${id}`),
|
||||||
|
create: (data: any) => api.post('/suppliers', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/suppliers/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/suppliers/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Purchase Orders
|
||||||
|
export const purchaseOrderAPI = {
|
||||||
|
list: (params?: any) => api.get('/purchase-orders', { params }),
|
||||||
|
get: (id: number) => api.get(`/purchase-orders/${id}`),
|
||||||
|
create: (data: any) => api.post('/purchase-orders', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/purchase-orders/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/purchase-orders/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export const dashboardAPI = {
|
||||||
|
get: () => api.get('/dashboard'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
export const exportAPI = {
|
||||||
|
csv: (params?: any) => api.get('/exports/invoices/csv', { params, responseType: 'blob' }),
|
||||||
|
excel: (params?: any) => api.get('/exports/invoices/excel', { params, responseType: 'blob' }),
|
||||||
|
cegi: (params?: any) => api.get('/exports/cegi', { params }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
export const notificationAPI = {
|
||||||
|
list: (params?: any) => api.get('/notifications', { params }),
|
||||||
|
markRead: (id: number) => api.put(`/notifications/${id}/read`),
|
||||||
|
markAllRead: () => api.put('/notifications/read-all'),
|
||||||
|
count: () => api.get('/notifications/count'),
|
||||||
|
};
|
||||||
168
frontend/src/types/index.ts
Normal file
168
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: 'admin' | 'approbateur' | 'validateur' | 'operateur';
|
||||||
|
isActive?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceStatus = 'recue' | 'en_verification' | 'validee' | 'approuvee' | 'payee' | 'rejetee' | 'archivee';
|
||||||
|
export type InvoiceSource = 'upload' | 'email' | 'portail' | 'scan';
|
||||||
|
export type MatchingStatus = 'non_rapproche' | 'rapproche' | 'ecart_detecte';
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
supplierId?: number;
|
||||||
|
supplierName?: string;
|
||||||
|
supplierSiret?: string;
|
||||||
|
supplierAddress?: string;
|
||||||
|
invoiceDate?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
receptionDate: string;
|
||||||
|
amountHT?: string;
|
||||||
|
amountTVA?: string;
|
||||||
|
amountTTC?: string;
|
||||||
|
tvaRate?: string;
|
||||||
|
currency?: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
source: InvoiceSource;
|
||||||
|
originalFileName?: string;
|
||||||
|
filePath?: string;
|
||||||
|
fileType?: string;
|
||||||
|
ocrRawData?: any;
|
||||||
|
ocrConfidence?: string;
|
||||||
|
purchaseOrderId?: number;
|
||||||
|
matchingStatus?: MatchingStatus;
|
||||||
|
matchingNotes?: string;
|
||||||
|
notes?: string;
|
||||||
|
fullText?: string;
|
||||||
|
createdBy?: number;
|
||||||
|
validatedBy?: number;
|
||||||
|
approvedBy?: number;
|
||||||
|
validatedAt?: string;
|
||||||
|
approvedAt?: string;
|
||||||
|
paidAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lines?: InvoiceLine[];
|
||||||
|
auditLog?: AuditEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceLine {
|
||||||
|
id?: number;
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unitPrice?: number;
|
||||||
|
amountHT?: number;
|
||||||
|
tvaRate?: number;
|
||||||
|
amountTVA?: number;
|
||||||
|
amountTTC?: number;
|
||||||
|
lineOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Supplier {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
siret?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
contactName?: string;
|
||||||
|
iban?: string;
|
||||||
|
notes?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type POStatus = 'brouillon' | 'envoyee' | 'recue' | 'facturee' | 'annulee';
|
||||||
|
|
||||||
|
export interface PurchaseOrder {
|
||||||
|
id: number;
|
||||||
|
orderNumber: string;
|
||||||
|
supplierId?: number;
|
||||||
|
supplierName?: string;
|
||||||
|
orderDate: string;
|
||||||
|
expectedDeliveryDate?: string;
|
||||||
|
amountHT?: string;
|
||||||
|
amountTVA?: string;
|
||||||
|
amountTTC?: string;
|
||||||
|
status: POStatus;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdBy?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lines?: POLine[];
|
||||||
|
linkedInvoices?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface POLine {
|
||||||
|
id?: number;
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unitPrice?: number;
|
||||||
|
amountHT?: number;
|
||||||
|
lineOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
details?: any;
|
||||||
|
userName?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
isRead: boolean;
|
||||||
|
link?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
statusCounts: Array<{ status: string; count: number; total: number }>;
|
||||||
|
activeTotals: { count: number; total: number };
|
||||||
|
overdue: { count: number; total: number };
|
||||||
|
dueSoon: { count: number; total: number };
|
||||||
|
dueMonth: { count: number; total: number };
|
||||||
|
recentInvoices: Array<{
|
||||||
|
id: number;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
supplierName?: string;
|
||||||
|
amountTTC?: string;
|
||||||
|
status: string;
|
||||||
|
dueDate?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
topSuppliers: Array<{ supplier_name: string; invoice_count: number; total: number }>;
|
||||||
|
monthlyEvolution: Array<{ month: string; count: number; total: number }>;
|
||||||
|
sourceCounts: Array<{ source: string; count: number }>;
|
||||||
|
pendingForUser: Array<{ action: string; count: number }>;
|
||||||
|
}
|
||||||
102
frontend/src/utils/helpers.ts
Normal file
102
frontend/src/utils/helpers.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export function formatCurrency(amount: string | number | undefined | null): string {
|
||||||
|
if (amount === undefined || amount === null || amount === '') return '0,00 €';
|
||||||
|
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||||
|
if (isNaN(num)) return '0,00 €';
|
||||||
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | undefined | null): string {
|
||||||
|
if (!date) return '-';
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(date));
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: string | undefined | null): string {
|
||||||
|
if (!date) return '-';
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}).format(new Date(date));
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statusLabels: Record<string, string> = {
|
||||||
|
recue: 'Reçue',
|
||||||
|
en_verification: 'En vérification',
|
||||||
|
validee: 'Validée',
|
||||||
|
approuvee: 'Approuvée',
|
||||||
|
payee: 'Payée',
|
||||||
|
rejetee: 'Rejetée',
|
||||||
|
archivee: 'Archivée',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusColors: Record<string, string> = {
|
||||||
|
recue: 'bg-blue-100 text-blue-800',
|
||||||
|
en_verification: 'bg-yellow-100 text-yellow-800',
|
||||||
|
validee: 'bg-indigo-100 text-indigo-800',
|
||||||
|
approuvee: 'bg-green-100 text-green-800',
|
||||||
|
payee: 'bg-emerald-100 text-emerald-800',
|
||||||
|
rejetee: 'bg-red-100 text-red-800',
|
||||||
|
archivee: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sourceLabels: Record<string, string> = {
|
||||||
|
upload: 'Upload',
|
||||||
|
email: 'Email',
|
||||||
|
portail: 'Portail',
|
||||||
|
scan: 'Scan',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchingLabels: Record<string, string> = {
|
||||||
|
non_rapproche: 'Non rapproché',
|
||||||
|
rapproche: 'Rapproché',
|
||||||
|
ecart_detecte: 'Écart détecté',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchingColors: Record<string, string> = {
|
||||||
|
non_rapproche: 'bg-gray-100 text-gray-800',
|
||||||
|
rapproche: 'bg-green-100 text-green-800',
|
||||||
|
ecart_detecte: 'bg-orange-100 text-orange-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const poStatusLabels: Record<string, string> = {
|
||||||
|
brouillon: 'Brouillon',
|
||||||
|
envoyee: 'Envoyée',
|
||||||
|
recue: 'Reçue',
|
||||||
|
facturee: 'Facturée',
|
||||||
|
annulee: 'Annulée',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const poStatusColors: Record<string, string> = {
|
||||||
|
brouillon: 'bg-gray-100 text-gray-800',
|
||||||
|
envoyee: 'bg-blue-100 text-blue-800',
|
||||||
|
recue: 'bg-green-100 text-green-800',
|
||||||
|
facturee: 'bg-purple-100 text-purple-800',
|
||||||
|
annulee: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roleLabels: Record<string, string> = {
|
||||||
|
admin: 'Administrateur',
|
||||||
|
approbateur: 'Approbateur',
|
||||||
|
validateur: 'Validateur',
|
||||||
|
operateur: 'Opérateur',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isOverdue(dueDate: string | undefined | null, status: string): boolean {
|
||||||
|
if (!dueDate || ['payee', 'archivee', 'rejetee'].includes(status)) return false;
|
||||||
|
return new Date(dueDate) < new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDueSoon(dueDate: string | undefined | null, status: string, days: number = 7): boolean {
|
||||||
|
if (!dueDate || ['payee', 'archivee', 'rejetee'].includes(status)) return false;
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
const now = new Date();
|
||||||
|
const future = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
return due >= now && due <= future;
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
26
frontend/tailwind.config.js
Normal file
26
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
santinova: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#2563eb',
|
||||||
|
600: '#1d4ed8',
|
||||||
|
700: '#1e40af',
|
||||||
|
800: '#1e3a8a',
|
||||||
|
900: '#172554',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
23
frontend/vite.config.ts
Normal file
23
frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user