feat: vue detail facture + restauration tracking git complet
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# NotesFrais — variables d'environnement (docker-compose)
|
||||
# Copier ce fichier en .env puis remplir les valeurs CHANGE_ME
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Domaine public (sans https://) ───────────────────────────────
|
||||
# Utilisé par Coolify/Traefik pour le routage HTTPS.
|
||||
DOMAIN=frais.domench.fr
|
||||
|
||||
# ── Base de données PostgreSQL ───────────────────────────────────
|
||||
# Mot de passe du compte notesfrais dans PostgreSQL.
|
||||
# Générer : openssl rand -hex 32
|
||||
DB_PASSWORD=CHANGE_ME
|
||||
|
||||
# ── JWT (authentification) ───────────────────────────────────────
|
||||
# Secret pour signer les tokens d'accès (HS256).
|
||||
# Générer : openssl rand -hex 64
|
||||
JWT_SECRET=CHANGE_ME
|
||||
|
||||
# ── Chiffrement AES-256-GCM (secrets stockés en base) ───────────
|
||||
# Clé de 32 octets hex pour chiffrer les mots de passe SMTP
|
||||
# et les identifiants Microsoft Graph enregistrés en BDD.
|
||||
# Générer : openssl rand -hex 32
|
||||
APP_SECRET=CHANGE_ME
|
||||
|
||||
# ── Initialisation des comptes (script init-users uniquement) ────
|
||||
# Ces variables ne sont lues qu'une seule fois lors du premier
|
||||
# lancement de npm run init-users (ou docker exec … node dist/scripts/init-users.js).
|
||||
# Elles peuvent être supprimées ensuite.
|
||||
GREG_EMAIL=greg@domench.fr
|
||||
GREG_PASSWORD=CHANGE_ME
|
||||
GAEL_EMAIL=gael@domench.fr
|
||||
GAEL_PASSWORD=CHANGE_ME
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
dist/
|
||||
*/dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,323 @@
|
||||
# NotesFrais
|
||||
|
||||
PWA de gestion des notes de frais — capture photo, OCR, génération PDF, envoi par e-mail et export SharePoint.
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | React 18 + Vite + TailwindCSS + TanStack Query v5 |
|
||||
| Backend | Node.js 20 + Express + TypeScript |
|
||||
| Base de données | PostgreSQL 16 |
|
||||
| Stockage fichiers | Volume Docker (`/app/uploads`) |
|
||||
| Authentification | JWT (access 15 min + refresh 30 j) |
|
||||
| PDF | pdf-lib |
|
||||
| E-mail | Nodemailer (SMTP par société) |
|
||||
| SharePoint | Microsoft Graph API (client_credentials) |
|
||||
| PWA / offline | vite-plugin-pwa + Workbox + IndexedDB queue |
|
||||
| Déploiement | Docker multi-stage + Coolify + Traefik (HTTPS) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Pré-requis
|
||||
|
||||
- Docker ≥ 24 et Docker Compose v2
|
||||
- Un domaine pointant sur le VPS (ex. `frais.domench.fr`)
|
||||
- Coolify installé sur le VPS (gère le routage HTTPS via Traefik)
|
||||
- Un **App Registration Azure / Entra ID** pour Microsoft Graph (voir §5)
|
||||
|
||||
---
|
||||
|
||||
## 2. Installation
|
||||
|
||||
### 2.1 Cloner le dépôt
|
||||
|
||||
```bash
|
||||
git clone <url-du-repo> notesfrais
|
||||
cd notesfrais
|
||||
```
|
||||
|
||||
### 2.2 Créer le fichier `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Éditer `.env` et remplir toutes les valeurs `CHANGE_ME` :
|
||||
|
||||
| Variable | Description | Commande de génération |
|
||||
|----------|-------------|------------------------|
|
||||
| `DOMAIN` | Domaine public (sans `https://`) | — |
|
||||
| `DB_PASSWORD` | Mot de passe PostgreSQL | `openssl rand -hex 32` |
|
||||
| `JWT_SECRET` | Secret de signature JWT | `openssl rand -hex 64` |
|
||||
| `APP_SECRET` | Clé AES-256 pour chiffrement BDD | `openssl rand -hex 32` |
|
||||
| `GREG_EMAIL` | E-mail du compte Greg (init uniquement) | — |
|
||||
| `GREG_PASSWORD` | Mot de passe du compte Greg | — |
|
||||
| `GAEL_EMAIL` | E-mail du compte Gaël (init uniquement) | — |
|
||||
| `GAEL_PASSWORD` | Mot de passe du compte Gaël | — |
|
||||
|
||||
### 2.3 Premier démarrage
|
||||
|
||||
```bash
|
||||
# Build et démarrage des conteneurs
|
||||
docker compose up -d --build
|
||||
|
||||
# Vérifier que tout est sain
|
||||
docker compose ps
|
||||
docker compose logs backend --tail=50
|
||||
```
|
||||
|
||||
Le script `docker-entrypoint.sh` attend automatiquement que PostgreSQL soit prêt, puis exécute la migration.
|
||||
|
||||
### 2.4 Créer les utilisateurs initiaux
|
||||
|
||||
Cette commande n'est à exécuter **qu'une seule fois**, juste après le premier démarrage :
|
||||
|
||||
```bash
|
||||
docker compose exec backend node dist/scripts/initUsers.js
|
||||
```
|
||||
|
||||
Une fois les comptes créés, vous pouvez supprimer les variables `GREG_*` et `GAEL_*` du `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Déploiement avec Coolify
|
||||
|
||||
1. Dans Coolify, créer un nouveau service de type **Docker Compose**.
|
||||
2. Pointer sur le dépôt Git (branche `main`).
|
||||
3. Dans **Variables d'environnement**, renseigner les mêmes clés que dans `.env`.
|
||||
4. Dans **Domaines**, configurer `frais.domench.fr` → port `80` (Traefik gère le TLS).
|
||||
5. Lancer le build ; Coolify s'occupe du certificat Let's Encrypt.
|
||||
|
||||
> **Astuce** : le `docker-compose.yml` expose uniquement le port 80 du frontend.
|
||||
> Traefik route le trafic HTTPS → HTTP interne. Le backend n'est pas exposé directement.
|
||||
|
||||
---
|
||||
|
||||
## 4. Paramétrage in-app
|
||||
|
||||
Une fois connecté, aller dans **Réglages** :
|
||||
|
||||
### 4.1 Sociétés
|
||||
|
||||
Ajouter chaque société avec son nom et son adresse e-mail de contact.
|
||||
|
||||
### 4.2 Configuration e-mail (par société)
|
||||
|
||||
Renseigner les paramètres SMTP pour chaque société (hôte, port, TLS, utilisateur, mot de passe).
|
||||
Le mot de passe SMTP est chiffré en AES-256-GCM avant d'être stocké en base.
|
||||
|
||||
### 4.3 Microsoft Graph / SharePoint
|
||||
|
||||
Renseigner une seule fois pour **toutes les sociétés** :
|
||||
|
||||
| Champ | Description |
|
||||
|-------|-------------|
|
||||
| Tenant ID | ID du tenant Azure (voir §5) |
|
||||
| Client ID | ID de l'App Registration |
|
||||
| Client Secret | Secret de l'App Registration |
|
||||
| Site ID | ID du site SharePoint (voir §5.4) |
|
||||
| Item ID | ID du fichier Excel (voir §5.5) |
|
||||
| Nom de feuille | Nom de l'onglet Excel (ex. `App`) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration Microsoft Graph (Azure / Entra ID)
|
||||
|
||||
### 5.1 Créer l'App Registration
|
||||
|
||||
1. Aller sur [portal.azure.com](https://portal.azure.com) → **Microsoft Entra ID** → **Inscriptions d'applications** → **Nouvelle inscription**.
|
||||
2. Nom : `NotesFrais` — Type de compte : **Locataire unique**.
|
||||
3. URI de redirection : aucune (flux client_credentials).
|
||||
4. **Créer**.
|
||||
|
||||
### 5.2 Créer un secret client
|
||||
|
||||
1. Dans l'app → **Certificats et secrets** → **Nouveau secret client**.
|
||||
2. Durée recommandée : 24 mois.
|
||||
3. **Copier la valeur immédiatement** (elle n'est visible qu'une fois).
|
||||
|
||||
### 5.3 Accorder les permissions API
|
||||
|
||||
1. **Autorisations API** → **Ajouter une autorisation** → **Microsoft Graph** → **Autorisations d'application**.
|
||||
2. Ajouter :
|
||||
- `Files.ReadWrite.All`
|
||||
- `Sites.ReadWrite.All`
|
||||
3. Cliquer **Accorder le consentement administrateur** (bouton vert) — indispensable pour les permissions d'application.
|
||||
|
||||
### 5.4 Obtenir le Site ID SharePoint
|
||||
|
||||
Dans [Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer) (connecté avec un compte admin) :
|
||||
|
||||
```
|
||||
GET https://graph.microsoft.com/v1.0/sites/<tenant>.sharepoint.com:/sites/<nom-du-site>
|
||||
```
|
||||
|
||||
Remplacer `<tenant>` par le nom du tenant (ex. `1dotech`) et `<nom-du-site>` par le chemin du site.
|
||||
Récupérer la valeur du champ `id` dans la réponse (format : `host,guid1,guid2`).
|
||||
|
||||
### 5.5 Obtenir l'Item ID du fichier Excel
|
||||
|
||||
```
|
||||
GET https://graph.microsoft.com/v1.0/sites/<site-id>/drive/root/children
|
||||
```
|
||||
|
||||
Trouver le fichier Excel dans la liste et copier son champ `id`.
|
||||
|
||||
> **Alternative** : naviguer dans l'arborescence avec
|
||||
> `GET /sites/<site-id>/drive/root:/<chemin/vers/fichier.xlsx>`
|
||||
|
||||
---
|
||||
|
||||
## 6. Mise à jour
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Le `docker-entrypoint.sh` ré-applique la migration à chaque démarrage (toutes les instructions SQL utilisent `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` — elles sont idempotentes).
|
||||
|
||||
---
|
||||
|
||||
## 7. Sauvegarde et restauration
|
||||
|
||||
### Sauvegarde de la base de données
|
||||
|
||||
```bash
|
||||
# Dump compressé horodaté
|
||||
docker compose exec db pg_dump -U notesfrais notesfrais \
|
||||
| gzip > "backup_notesfrais_$(date +%Y%m%d_%H%M%S).sql.gz"
|
||||
```
|
||||
|
||||
### Sauvegarde des fichiers uploadés
|
||||
|
||||
```bash
|
||||
# Copie locale du volume uploads
|
||||
docker cp $(docker compose ps -q backend):/app/uploads ./uploads_backup
|
||||
```
|
||||
|
||||
### Restauration de la base de données
|
||||
|
||||
```bash
|
||||
# Arrêter le backend le temps de la restauration
|
||||
docker compose stop backend
|
||||
|
||||
# Restaurer
|
||||
gunzip -c backup_notesfrais_YYYYMMDD_HHMMSS.sql.gz \
|
||||
| docker compose exec -T db psql -U notesfrais notesfrais
|
||||
|
||||
# Redémarrer
|
||||
docker compose start backend
|
||||
```
|
||||
|
||||
### Restauration des fichiers
|
||||
|
||||
```bash
|
||||
docker cp ./uploads_backup/. $(docker compose ps -q backend):/app/uploads/
|
||||
```
|
||||
|
||||
### Script de sauvegarde automatique (cron)
|
||||
|
||||
Créer `/etc/cron.daily/notesfrais-backup` :
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
BACKUP_DIR=/var/backups/notesfrais
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Garder 30 jours
|
||||
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete
|
||||
|
||||
# Dump BDD
|
||||
cd /chemin/vers/notesfrais
|
||||
docker compose exec -T db pg_dump -U notesfrais notesfrais \
|
||||
| gzip > "$BACKUP_DIR/db_$(date +%Y%m%d_%H%M%S).sql.gz"
|
||||
```
|
||||
|
||||
```bash
|
||||
chmod +x /etc/cron.daily/notesfrais-backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Logs et débogage
|
||||
|
||||
```bash
|
||||
# Tous les services
|
||||
docker compose logs -f
|
||||
|
||||
# Backend uniquement
|
||||
docker compose logs -f backend
|
||||
|
||||
# Vérifier l'état des conteneurs
|
||||
docker compose ps
|
||||
|
||||
# Accéder au shell backend
|
||||
docker compose exec backend sh
|
||||
|
||||
# Console PostgreSQL
|
||||
docker compose exec db psql -U notesfrais notesfrais
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Variables d'environnement — référence complète
|
||||
|
||||
### Fichier `.env` (racine, pour docker-compose)
|
||||
|
||||
| Variable | Obligatoire | Défaut | Description |
|
||||
|----------|-------------|--------|-------------|
|
||||
| `DOMAIN` | Oui | `frais.domench.fr` | Domaine public |
|
||||
| `DB_PASSWORD` | Oui | — | Mot de passe PostgreSQL |
|
||||
| `JWT_SECRET` | Oui | — | Secret JWT (≥ 64 octets hex) |
|
||||
| `APP_SECRET` | Oui | — | Clé AES-256 (32 octets hex) |
|
||||
| `GREG_EMAIL` | Init seult. | — | E-mail compte Greg |
|
||||
| `GREG_PASSWORD` | Init seult. | — | Mot de passe compte Greg |
|
||||
| `GAEL_EMAIL` | Init seult. | — | E-mail compte Gaël |
|
||||
| `GAEL_PASSWORD` | Init seult. | — | Mot de passe compte Gaël |
|
||||
|
||||
### Variables injectées par docker-compose (non à configurer dans `.env`)
|
||||
|
||||
| Variable | Valeur fixée dans docker-compose.yml |
|
||||
|----------|--------------------------------------|
|
||||
| `NODE_ENV` | `production` |
|
||||
| `PORT` | `3001` |
|
||||
| `DATABASE_URL` | construit depuis `DB_PASSWORD` |
|
||||
| `FRONTEND_URL` | construit depuis `DOMAIN` |
|
||||
| `UPLOADS_DIR` | `/app/uploads` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Structure du projet
|
||||
|
||||
```
|
||||
notesfrais/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Point d'entrée Express
|
||||
│ │ ├── config.ts # Variables d'env
|
||||
│ │ ├── db.ts # Pool PostgreSQL
|
||||
│ │ ├── middleware/ # Auth JWT, error handler
|
||||
│ │ ├── routes/ # auth, invoices, companies, settings, guests
|
||||
│ │ ├── services/ # pdf, email, sharepoint
|
||||
│ │ ├── migrations/ # 001_init.sql
|
||||
│ │ └── scripts/ # migrate.ts, initUsers.ts
|
||||
│ ├── Dockerfile
|
||||
│ ├── docker-entrypoint.sh
|
||||
│ └── package.json
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # Login, NewInvoice, MyInvoices, Settings…
|
||||
│ │ ├── components/ # Layout, modaux…
|
||||
│ │ ├── hooks/ # useOfflineQueue, useOnline…
|
||||
│ │ ├── utils/ # offlineQueue (IndexedDB), api…
|
||||
│ │ └── types/ # index.ts
|
||||
│ ├── Dockerfile
|
||||
│ ├── nginx.conf
|
||||
│ └── package.json
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
uploads
|
||||
*.log
|
||||
@@ -0,0 +1,26 @@
|
||||
# ─── Application ────────────────────────────────────────────
|
||||
PORT=3001
|
||||
NODE_ENV=production
|
||||
FRONTEND_URL=https://frais.domench.fr
|
||||
|
||||
# ─── Base de données PostgreSQL ─────────────────────────────
|
||||
DATABASE_URL=postgresql://notesfrais:CHANGE_ME@postgres:5432/notesfrais
|
||||
|
||||
# ─── JWT ────────────────────────────────────────────────────
|
||||
# Générer avec : openssl rand -hex 64
|
||||
JWT_SECRET=CHANGE_ME_very_long_random_secret_here
|
||||
JWT_EXPIRES_IN=15m
|
||||
REFRESH_TOKEN_EXPIRES_DAYS=30
|
||||
|
||||
# ─── Chiffrement (mots de passe SMTP + secrets Graph en BDD) ─
|
||||
# Générer avec : openssl rand -hex 32
|
||||
APP_SECRET=CHANGE_ME_another_long_random_secret
|
||||
|
||||
# ─── Répertoire de stockage des fichiers ────────────────────
|
||||
UPLOADS_DIR=/data/uploads
|
||||
|
||||
# ─── Création initiale des utilisateurs (init-users uniquement)
|
||||
GREG_EMAIL=greg@example.com
|
||||
GREG_PASSWORD=CHANGE_ME
|
||||
GAEL_EMAIL=gael@example.com
|
||||
GAEL_PASSWORD=CHANGE_ME
|
||||
@@ -0,0 +1,39 @@
|
||||
# ── Stage 1 : Build TypeScript ───────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --include=dev
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
# Compile tout (app + scripts) → dist/
|
||||
RUN npm run build
|
||||
|
||||
# Copier les fichiers non-TypeScript (SQL, assets) dans dist/
|
||||
RUN cp -r src/migrations dist/migrations
|
||||
|
||||
# ── Stage 2 : Image de production ────────────────────────────
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Dépendances de production uniquement
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Code compilé (app + scripts)
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Dossiers de stockage (montés via volume en prod)
|
||||
RUN mkdir -p /app/uploads/images /app/uploads/pdfs
|
||||
|
||||
# Script de démarrage
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec node dist/index.js
|
||||
Generated
+1526
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "notesfrais-backend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "tsx src/scripts/migrate.ts",
|
||||
"init-users": "tsx src/scripts/initUsers.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.13",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.11.5",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8",
|
||||
"csv-parse": "^5.5.6",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/pg": "^8.11.5",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"tsx": "^4.7.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
function require_env(key: string): string {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Variable d'environnement manquante : ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
|
||||
// Base de données
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://notesfrais:notesfrais@localhost:5432/notesfrais',
|
||||
|
||||
// JWT
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-prod',
|
||||
jwtExpiresIn: (process.env.JWT_EXPIRES_IN || '15m') as string,
|
||||
refreshTokenExpiresDays: parseInt(process.env.REFRESH_TOKEN_EXPIRES_DAYS || '30'),
|
||||
|
||||
// Chiffrement AES-256 (mots de passe SMTP, secrets Graph)
|
||||
appSecret: process.env.APP_SECRET || 'dev-app-secret-change-in-prod',
|
||||
|
||||
// Stockage fichiers
|
||||
uploadsDir: process.env.UPLOADS_DIR || './uploads',
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Chiffrement AES-256-GCM symétrique.
|
||||
* Utilisé pour stocker les mots de passe SMTP et secrets Microsoft Graph en BDD.
|
||||
* La clé est dérivée de APP_SECRET (variable d'environnement).
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import { config } from './config';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
return crypto.createHash('sha256').update(config.appSecret).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiffre une chaîne de texte.
|
||||
* Retourne une chaîne base64 : iv (16) + tag (16) + ciphertext.
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Déchiffre une chaîne encodée en base64 produite par encrypt().
|
||||
*/
|
||||
export function decrypt(encoded: string): string {
|
||||
const data = Buffer.from(encoded, 'base64');
|
||||
const iv = data.subarray(0, IV_LENGTH);
|
||||
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const enc = data.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(enc).toString('utf8') + decipher.final('utf8');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Pool } from 'pg';
|
||||
import { config } from './config';
|
||||
|
||||
export const db = new Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
// Pas de SSL forcé — connexion interne Docker (réseau privé)
|
||||
max: 10,
|
||||
connectionTimeoutMillis: 10000, // Erreur si connexion impossible après 10s
|
||||
idleTimeoutMillis: 60000, // Libérer connexions inactives après 60s
|
||||
});
|
||||
|
||||
db.on('error', (err) => {
|
||||
console.error('Erreur pool PostgreSQL :', err.message);
|
||||
});
|
||||
|
||||
export async function testConnection(): Promise<void> {
|
||||
const client = await db.connect();
|
||||
client.release();
|
||||
console.log('✅ PostgreSQL connecté');
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { config } from './config';
|
||||
import { db, testConnection } from './db';
|
||||
|
||||
// Routes
|
||||
import authRoutes from './routes/auth';
|
||||
import invoicesRoutes from './routes/invoices';
|
||||
import companiesRoutes from './routes/companies';
|
||||
import categoriesRoutes from './routes/categories';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import contactsRoutes from './routes/contacts';
|
||||
import { requireAuth } from './middleware/auth';
|
||||
|
||||
const app = express();
|
||||
|
||||
// ─── Sécurité ────────────────────────────────────────────────
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: config.frontendUrl,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// ─── Routes API ──────────────────────────────────────────────
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/invoices', invoicesRoutes);
|
||||
app.use('/api/companies', companiesRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/contacts', contactsRoutes);
|
||||
|
||||
// ─── Images uploads (authentifié) ───────────────────────────────
|
||||
app.get('/api/uploads/images/:filename', requireAuth, (req, res) => {
|
||||
const filename = path.basename(req.params.filename); // sécurité: évite path traversal
|
||||
const filePath = path.join(config.uploadsDir, 'images', filename);
|
||||
res.sendFile(filePath, (err) => {
|
||||
if (err) res.status(404).json({ error: 'Image non trouvée' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Health check (accessible via /health ET /api/health) ──────
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0' }));
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0', uptime: process.uptime() }));
|
||||
|
||||
// ─── Gestionnaire d'erreurs global ────────────────────────────
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('[Error]', err.stack);
|
||||
res.status(500).json({
|
||||
error: err.message,
|
||||
type: err.constructor.name,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Initialisation DB + migration (arrière-plan) ─────────────
|
||||
async function runMigration(): Promise<void> {
|
||||
const sqlPath = path.join(__dirname, 'migrations/001_init.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
await db.query(sql);
|
||||
console.log('✅ Migration terminée');
|
||||
}
|
||||
|
||||
async function runInitUsers(): Promise<void> {
|
||||
const users = [
|
||||
{ name: 'Greg', email: process.env.GREG_EMAIL || 'greg@example.com', password: process.env.GREG_PASSWORD || 'changeme' },
|
||||
{ name: 'Gaël', email: process.env.GAEL_EMAIL || 'gael@example.com', password: process.env.GAEL_PASSWORD || 'changeme' },
|
||||
];
|
||||
for (const user of users) {
|
||||
const hash = await bcrypt.hash(user.password, 12);
|
||||
await db.query(
|
||||
`INSERT INTO users (name, email, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`,
|
||||
[user.name, user.email, hash]
|
||||
);
|
||||
console.log(` ✅ Utilisateur prêt : ${user.name} <${user.email}>`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDb(maxAttempts = 30, delayMs = 2000): Promise<void> {
|
||||
for (let i = 1; i <= maxAttempts; i++) {
|
||||
try {
|
||||
await testConnection();
|
||||
return;
|
||||
} catch {
|
||||
console.log(` PostgreSQL non prêt (${i}/${maxAttempts}), attente...`);
|
||||
await new Promise(r => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
throw new Error(`PostgreSQL inaccessible après ${maxAttempts} tentatives`);
|
||||
}
|
||||
|
||||
// ─── Démarrage ────────────────────────────────────────────────
|
||||
async function start() {
|
||||
// Créer les répertoires uploads
|
||||
try {
|
||||
fs.mkdirSync(path.join(config.uploadsDir, 'images'), { recursive: true });
|
||||
fs.mkdirSync(path.join(config.uploadsDir, 'pdfs'), { recursive: true });
|
||||
} catch (err: any) {
|
||||
console.warn('Avertissement: impossible de créer les répertoires uploads:', err.message);
|
||||
}
|
||||
|
||||
// ── Bind du port IMMÉDIATEMENT ─────────────────────────────
|
||||
app.listen(config.port, () => {
|
||||
console.log(`🚀 NotesFrais backend démarré sur le port ${config.port}`);
|
||||
console.log(` Environnement : ${config.nodeEnv}`);
|
||||
console.log(` Frontend autorisé : ${config.frontendUrl}`);
|
||||
console.log(` DATABASE_URL: ${process.env.DATABASE_URL
|
||||
? process.env.DATABASE_URL.replace(/:([^@]+)@/, ':***@')
|
||||
: '[non défini — utilise défaut localhost]'}`);
|
||||
});
|
||||
|
||||
// ── Initialisation DB en arrière-plan ─────────────────────
|
||||
(async () => {
|
||||
try {
|
||||
await waitForDb();
|
||||
await runMigration();
|
||||
await runInitUsers();
|
||||
console.log('✅ Base de données prête');
|
||||
} catch (err: any) {
|
||||
console.error('⚠️ Initialisation DB échouée (non bloquant):', err.message);
|
||||
// Le serveur continue — les routes retourneront des 500 si la DB est inaccessible
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('Impossible de démarrer le serveur :', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: { id: number; name: string; email: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware JWT — vérifie le Bearer token dans Authorization.
|
||||
* Injecte req.user si valide, sinon 401.
|
||||
*/
|
||||
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Token manquant' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwtSecret) as {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token invalide ou expiré' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodSchema, ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
* Middleware de validation Zod.
|
||||
* Parse req.body avec le schéma fourni.
|
||||
* Retourne 400 avec le détail des erreurs si invalide.
|
||||
*/
|
||||
export function validate(schema: ZodSchema) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
req.body = schema.parse(req.body);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'Données invalides',
|
||||
details: err.errors.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
-- =============================================================
|
||||
-- NotesFrais — Migration 001 : Initialisation de la base
|
||||
-- =============================================================
|
||||
|
||||
-- Extension pour gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- =============================================================
|
||||
-- UTILISATEURS
|
||||
-- Deux utilisateurs fixes : Greg et Gaël
|
||||
-- Chaque utilisateur possède sa propre config SMTP
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
-- Config SMTP (expéditeur propre à chaque user)
|
||||
smtp_host VARCHAR(255),
|
||||
smtp_port INTEGER DEFAULT 587,
|
||||
smtp_secure BOOLEAN DEFAULT FALSE, -- TRUE = port 465 / SSL
|
||||
smtp_user VARCHAR(255),
|
||||
smtp_pass_enc TEXT, -- chiffré AES-256 côté app
|
||||
smtp_from_name VARCHAR(100),
|
||||
smtp_from_email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- PARAMÈTRES GLOBAUX DE L'APPLICATION
|
||||
-- Stocke la config Microsoft Graph + localisation du fichier Excel
|
||||
-- Clés attendues : graph_tenant_id, graph_client_id,
|
||||
-- graph_client_secret_enc,
|
||||
-- sharepoint_site_id, sharepoint_item_id,
|
||||
-- sharepoint_sheet_name
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- SOCIÉTÉS
|
||||
-- Entités vers lesquelles envoyer les factures à rembourser
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL, -- destinataire de l'email de remboursement
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- CATÉGORIES DE DÉPENSES
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- FACTURES (table principale)
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE RESTRICT,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
|
||||
supplier VARCHAR(255), -- fournisseur (OCR ou saisie manuelle)
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
invoice_date DATE NOT NULL, -- date figurant sur le justificatif
|
||||
comment TEXT, -- commentaire libre
|
||||
-- Fichiers
|
||||
images JSONB DEFAULT '[]', -- [{ "path": "...", "order": 0 }]
|
||||
pdf_path TEXT, -- chemin relatif du PDF généré sur le VPS
|
||||
pdf_filename TEXT, -- ex: 2026-04-29_Restaurant_SocieteA_42.50€.pdf
|
||||
-- Options d'envoi
|
||||
add_to_tracking BOOLEAN DEFAULT TRUE, -- case "Ajouter au fichier de suivi"
|
||||
tracking_added BOOLEAN DEFAULT FALSE,-- envoi SharePoint réalisé
|
||||
email_sent BOOLEAN DEFAULT FALSE,
|
||||
sent_at TIMESTAMPTZ,
|
||||
-- Statut remboursement
|
||||
status VARCHAR(20) DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'reimbursed')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- INVITÉS (associés à une facture)
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS guests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
company VARCHAR(255),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- REFRESH TOKENS (authentification JWT)
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- CONTACTS (répertoire d'invités réutilisables)
|
||||
-- Utilisés comme suggestions dans le formulaire de facture
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
company VARCHAR(255),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- INDEX
|
||||
-- =============================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_company_id ON invoices(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_category_id ON invoices(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_date ON invoices(invoice_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_sent_at ON invoices(sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_invoice_id ON guests(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||
|
||||
-- =============================================================
|
||||
-- TRIGGER : mise à jour automatique de updated_at
|
||||
-- =============================================================
|
||||
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER set_updated_at_users
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||
|
||||
CREATE OR REPLACE TRIGGER set_updated_at_companies
|
||||
BEFORE UPDATE ON companies
|
||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||
|
||||
CREATE OR REPLACE TRIGGER set_updated_at_invoices
|
||||
BEFORE UPDATE ON invoices
|
||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||
|
||||
-- =============================================================
|
||||
-- DONNÉES INITIALES
|
||||
-- =============================================================
|
||||
|
||||
-- Catégories pré-configurées
|
||||
INSERT INTO categories (name, sort_order) VALUES
|
||||
('Restaurant', 1),
|
||||
('Transport', 2),
|
||||
('Hôtel', 3),
|
||||
('Matériel', 4)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Note : les utilisateurs Greg et Gaël sont créés via le script
|
||||
-- d'initialisation (voir README — init-users.sh) afin de ne pas
|
||||
-- stocker de mots de passe en clair dans les migrations.
|
||||
@@ -0,0 +1,134 @@
|
||||
import express, { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { config } from '../config';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
|
||||
function generateAccessToken(user: { id: number; name: string; email: string }): string {
|
||||
return jwt.sign(
|
||||
{ id: user.id, name: user.name, email: user.email },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'] }
|
||||
);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Schémas de validation ───────────────────────────────────
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email invalide'),
|
||||
password: z.string().min(1, 'Mot de passe requis'),
|
||||
});
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Body: { email, password }
|
||||
* Retourne: { accessToken, refreshToken, user }
|
||||
*/
|
||||
router.post('/login', validate(loginSchema), async (req: Request, res: Response, next: express.NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
res.status(401).json({ error: 'Email ou mot de passe incorrect' });
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(user);
|
||||
const refreshToken = crypto.randomBytes(40).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + config.refreshTokenExpiresDays);
|
||||
|
||||
await db.query(
|
||||
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
||||
[user.id, hashToken(refreshToken), expiresAt]
|
||||
);
|
||||
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Body: { refreshToken }
|
||||
* Retourne: { accessToken }
|
||||
*/
|
||||
router.post('/refresh', async (req: Request, res: Response): Promise<void> => {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) {
|
||||
res.status(400).json({ error: 'Refresh token manquant' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT rt.user_id, u.name, u.email
|
||||
FROM refresh_tokens rt
|
||||
JOIN users u ON u.id = rt.user_id
|
||||
WHERE rt.token_hash = $1 AND rt.expires_at > NOW()`,
|
||||
[hashToken(refreshToken)]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
res.status(401).json({ error: 'Session expirée, veuillez vous reconnecter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken({ id: row.user_id, name: row.name, email: row.email });
|
||||
res.json({ accessToken });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Révoque le refresh token.
|
||||
*/
|
||||
router.post('/logout', requireAuth, async (req: AuthRequest, res: Response, next: express.NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (refreshToken) {
|
||||
await db.query('DELETE FROM refresh_tokens WHERE token_hash = $1', [hashToken(refreshToken)]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Retourne l'utilisateur connecté (sans données sensibles).
|
||||
*/
|
||||
router.get('/me', requireAuth, async (req: AuthRequest, res: Response, next: express.NextFunction): Promise<void> => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT id, name, email,
|
||||
smtp_host, smtp_port, smtp_secure, smtp_user,
|
||||
smtp_from_name, smtp_from_email
|
||||
FROM users WHERE id = $1`,
|
||||
[req.user!.id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) =>
|
||||
(req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
|
||||
const categorySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
sort_order: z.number().int().optional().default(0),
|
||||
});
|
||||
|
||||
/** GET /api/categories */
|
||||
router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM categories WHERE is_active=TRUE ORDER BY sort_order, name'
|
||||
);
|
||||
res.json(result.rows);
|
||||
}));
|
||||
|
||||
/** POST /api/categories */
|
||||
router.post('/', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, sort_order } = req.body;
|
||||
const result = await db.query(
|
||||
'INSERT INTO categories (name, sort_order) VALUES ($1, $2) RETURNING *',
|
||||
[name, sort_order]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** PUT /api/categories/:id */
|
||||
router.put('/:id', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, sort_order } = req.body;
|
||||
const result = await db.query(
|
||||
'UPDATE categories SET name=$1, sort_order=$2 WHERE id=$3 AND is_active=TRUE RETURNING *',
|
||||
[name, sort_order, req.params.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Catégorie introuvable' }); return; }
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** DELETE /api/categories/:id — Soft-delete */
|
||||
router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
await db.query('UPDATE categories SET is_active=FALSE WHERE id=$1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) =>
|
||||
(req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
/** GET /api/companies — Liste toutes les sociétés actives */
|
||||
router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM companies WHERE is_active = TRUE ORDER BY name'
|
||||
);
|
||||
res.json(result.rows);
|
||||
}));
|
||||
|
||||
/** POST /api/companies — Crée une société */
|
||||
router.post('/', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, email } = req.body;
|
||||
const result = await db.query(
|
||||
`INSERT INTO companies (name, email) VALUES ($1, $2) RETURNING *`,
|
||||
[name, email]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** PUT /api/companies/:id — Met à jour une société */
|
||||
router.put('/:id', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, email } = req.body;
|
||||
const result = await db.query(
|
||||
`UPDATE companies SET name=$1, email=$2, updated_at=NOW()
|
||||
WHERE id=$3 AND is_active=TRUE RETURNING *`,
|
||||
[name, email, req.params.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Société introuvable' }); return; }
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** DELETE /api/companies/:id — Soft-delete (is_active = false) */
|
||||
router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
await db.query(
|
||||
'UPDATE companies SET is_active=FALSE, updated_at=NOW() WHERE id=$1',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import multer from 'multer';
|
||||
import { db } from '../db';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { parse as csvParse } from 'csv-parse/sync';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Multer en mémoire (CSV/XLSX, max 5 Mo)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ok = [
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/octet-stream',
|
||||
].includes(file.mimetype) || file.originalname.match(/\.(csv|xls|xlsx)$/i);
|
||||
cb(null, !!ok);
|
||||
},
|
||||
});
|
||||
|
||||
function wrap(fn: RequestHandler): RequestHandler {
|
||||
return (req, res, next) => (fn(req, res, next) as any).catch(next);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalise une ligne brute en { name, company } */
|
||||
function normalizeRow(row: Record<string, any>): { name: string; company: string | null } | null {
|
||||
// Cherche les colonnes "nom"/"name" et "société"/"company" indépendamment de la casse
|
||||
const keys = Object.keys(row);
|
||||
|
||||
const nameKey = keys.find((k) =>
|
||||
/^(nom|name|prénom|prenom|contact)$/i.test(k.trim())
|
||||
);
|
||||
const companyKey = keys.find((k) =>
|
||||
/^(soci[eé]t[eé]|company|entreprise|organisation|organization|employeur|employer)$/i.test(k.trim())
|
||||
);
|
||||
|
||||
// Fallback : première colonne = nom, deuxième = société
|
||||
const name = (nameKey ? row[nameKey] : row[keys[0]] ?? '').toString().trim();
|
||||
const company = companyKey
|
||||
? (row[companyKey] ?? '').toString().trim() || null
|
||||
: keys[1]
|
||||
? (row[keys[1]] ?? '').toString().trim() || null
|
||||
: null;
|
||||
|
||||
if (!name) return null;
|
||||
return { name, company };
|
||||
}
|
||||
|
||||
/** Parse un buffer CSV → tableau de lignes normalisées */
|
||||
function parseCSV(buf: Buffer): Array<{ name: string; company: string | null }> {
|
||||
const rows = csvParse(buf, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
}) as Record<string, any>[];
|
||||
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
|
||||
}
|
||||
|
||||
/** Parse un buffer XLSX → tableau de lignes normalisées */
|
||||
function parseXLSX(buf: Buffer): Array<{ name: string; company: string | null }> {
|
||||
const wb = XLSX.read(buf, { type: 'buffer' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: '' });
|
||||
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
|
||||
}
|
||||
|
||||
// ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/contacts — liste tous les contacts triés */
|
||||
router.get('/', wrap(async (_req, res) => {
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, name, company, sort_order FROM contacts ORDER BY name ASC'
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
/** POST /api/contacts — ajoute un contact manuel */
|
||||
router.post('/', wrap(async (req, res) => {
|
||||
const { name, company } = req.body as { name?: string; company?: string };
|
||||
if (!name?.trim()) {
|
||||
return res.status(400).json({ error: 'Le nom est requis.' });
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO contacts (name, company) VALUES ($1, $2) RETURNING id, name, company, sort_order',
|
||||
[name.trim(), company?.trim() || null]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
}));
|
||||
|
||||
/** POST /api/contacts/import — import CSV ou XLSX */
|
||||
router.post('/import', upload.single('file'), wrap(async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Aucun fichier fourni.' });
|
||||
}
|
||||
|
||||
const isXLSX =
|
||||
req.file.originalname.match(/\.(xls|xlsx)$/i) ||
|
||||
req.file.mimetype.includes('spreadsheet') ||
|
||||
req.file.mimetype.includes('ms-excel');
|
||||
|
||||
let contacts: Array<{ name: string; company: string | null }>;
|
||||
try {
|
||||
contacts = isXLSX ? parseXLSX(req.file.buffer) : parseCSV(req.file.buffer);
|
||||
} catch (err: any) {
|
||||
return res.status(422).json({ error: `Impossible de lire le fichier : ${err.message}` });
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
return res.status(422).json({ error: 'Aucun contact trouvé dans le fichier. Vérifiez les colonnes (Nom, Société).' });
|
||||
}
|
||||
|
||||
// Upsert : si (name, company) existe déjà, on ne duplique pas
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const c of contacts) {
|
||||
const exists = await db.query(
|
||||
'SELECT id FROM contacts WHERE LOWER(name) = LOWER($1) AND (company IS NULL AND $2::TEXT IS NULL OR LOWER(company) = LOWER($2))',
|
||||
[c.name, c.company]
|
||||
);
|
||||
if (exists.rowCount && exists.rowCount > 0) {
|
||||
skipped++;
|
||||
} else {
|
||||
await db.query(
|
||||
'INSERT INTO contacts (name, company) VALUES ($1, $2)',
|
||||
[c.name, c.company]
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ inserted, skipped, total: contacts.length });
|
||||
}));
|
||||
|
||||
/** DELETE /api/contacts/:id */
|
||||
router.delete('/:id', wrap(async (req, res) => {
|
||||
const { rowCount } = await db.query('DELETE FROM contacts WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Contact introuvable.' });
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,447 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { config } from '../config';
|
||||
import { generateInvoicePdf } from '../services/pdf';
|
||||
import { sendInvoiceEmail } from '../services/email';
|
||||
import { addRowToExcel } from '../services/sharepoint';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Wrapper pour éviter les unhandledRejection qui crashent Node 20
|
||||
function wrap(fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
}
|
||||
|
||||
// ─── Upload images ────────────────────────────────────────────
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
const dir = path.join(config.uploadsDir, 'images');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 Mo
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
cb(null, allowed.includes(file.mimetype));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/invoices/upload-image
|
||||
* Upload d'une photo de facture (avant soumission du formulaire).
|
||||
* Retourne: { filename }
|
||||
*/
|
||||
router.post('/upload-image', upload.single('image'), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Aucun fichier reçu ou format non supporté (jpg/png/webp)' });
|
||||
return;
|
||||
}
|
||||
res.json({ filename: req.file.filename });
|
||||
}));
|
||||
|
||||
// ─── Schémas de validation ────────────────────────────────────
|
||||
|
||||
const createSchema = z.object({
|
||||
company_id: z.number().int().positive(),
|
||||
category_id: z.number().int().positive(),
|
||||
supplier: z.string().optional(),
|
||||
amount: z.number().positive('Le montant doit être positif'),
|
||||
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format AAAA-MM-JJ attendu'),
|
||||
comment: z.string().optional(),
|
||||
images: z.array(z.object({
|
||||
path: z.string().min(1),
|
||||
order: z.number().int(),
|
||||
})).min(1, 'Au moins une image requise'),
|
||||
add_to_tracking: z.boolean().default(true),
|
||||
guests: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
company: z.string().optional().nullable(),
|
||||
sort_order: z.number().int().optional().default(0),
|
||||
})).default([]),
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
async function getInvoiceById(id: string) {
|
||||
const result = await db.query(
|
||||
`SELECT i.*,
|
||||
co.name AS company_name,
|
||||
co.email AS company_email,
|
||||
cat.name AS category_name,
|
||||
u.name AS user_name
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
JOIN users u ON u.id = i.user_id
|
||||
WHERE i.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!result.rows[0]) return null;
|
||||
|
||||
const invoice = result.rows[0];
|
||||
const guests = await db.query(
|
||||
'SELECT name, company, sort_order FROM guests WHERE invoice_id=$1 ORDER BY sort_order',
|
||||
[id]
|
||||
);
|
||||
invoice.guests = guests.rows;
|
||||
return invoice;
|
||||
}
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/invoices/summary
|
||||
* Récapitulatif des montants par société × statut (En attente / Remboursé)
|
||||
* ⚠ Doit être AVANT /:id pour ne pas être capturé
|
||||
*/
|
||||
router.get('/summary', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
`SELECT co.name AS company_name, i.status,
|
||||
SUM(i.amount) AS total,
|
||||
COUNT(*)::int AS count
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
WHERE i.user_id = $1
|
||||
GROUP BY co.name, i.status
|
||||
ORDER BY co.name, i.status`,
|
||||
[req.user!.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices/export/csv
|
||||
* Export CSV du listing filtré (mêmes filtres que GET /)
|
||||
*/
|
||||
router.get('/export/csv', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { company_ids, category_ids, status, date_from, date_to, search } = req.query;
|
||||
|
||||
const conditions: string[] = ['i.user_id = $1'];
|
||||
const params: unknown[] = [req.user!.id];
|
||||
let p = 2;
|
||||
|
||||
if (company_ids) { conditions.push(`i.company_id = ANY($${p++}::int[])`); params.push(String(company_ids).split(',').map(Number)); }
|
||||
if (category_ids) { conditions.push(`i.category_id = ANY($${p++}::int[])`); params.push(String(category_ids).split(',').map(Number)); }
|
||||
if (status) { conditions.push(`i.status = $${p++}`); params.push(status); }
|
||||
if (date_from) { conditions.push(`i.invoice_date >= $${p++}`); params.push(date_from); }
|
||||
if (date_to) { conditions.push(`i.invoice_date <= $${p++}`); params.push(date_to); }
|
||||
if (search) { conditions.push(`(i.supplier ILIKE $${p} OR i.comment ILIKE $${p})`); params.push(`%${search}%`); p++; }
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT i.invoice_date, co.name AS company, cat.name AS category,
|
||||
i.supplier, i.amount, i.comment, i.status,
|
||||
i.sent_at, i.pdf_filename
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY COALESCE(i.sent_at, i.created_at) DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const headers = ['Date','Société','Catégorie','Fournisseur','Montant (€)','Commentaire','Statut','Date envoi','Fichier PDF'];
|
||||
const csvLines = [headers.join(';')];
|
||||
|
||||
for (const row of result.rows) {
|
||||
const invoiceDate = row.invoice_date
|
||||
? new Date(row.invoice_date).toLocaleDateString('fr-FR')
|
||||
: '';
|
||||
const sentDate = row.sent_at
|
||||
? new Date(row.sent_at).toLocaleDateString('fr-FR')
|
||||
: '';
|
||||
|
||||
csvLines.push([
|
||||
invoiceDate,
|
||||
row.company,
|
||||
row.category,
|
||||
row.supplier || '',
|
||||
Number(row.amount).toFixed(2).replace('.', ','),
|
||||
`"${(row.comment || '').replace(/"/g, '""')}"`,
|
||||
row.status === 'pending' ? 'En attente' : 'Remboursé',
|
||||
sentDate,
|
||||
row.pdf_filename || '',
|
||||
].join(';'));
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="factures-${new Date().toISOString().split('T')[0]}.csv"`);
|
||||
res.send('' + csvLines.join('\n')); // BOM pour Excel
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices
|
||||
* Liste paginée avec filtres combinables
|
||||
*/
|
||||
router.get('/', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { company_ids, category_ids, status, date_from, date_to, search,
|
||||
sort_by, sort_dir, page, limit } = req.query;
|
||||
|
||||
const conditions: string[] = ['i.user_id = $1'];
|
||||
const params: unknown[] = [req.user!.id];
|
||||
let p = 2;
|
||||
|
||||
if (company_ids) { conditions.push(`i.company_id = ANY($${p++}::int[])`); params.push(String(company_ids).split(',').map(Number)); }
|
||||
if (category_ids) { conditions.push(`i.category_id = ANY($${p++}::int[])`); params.push(String(category_ids).split(',').map(Number)); }
|
||||
if (status) { conditions.push(`i.status = $${p++}`); params.push(status); }
|
||||
if (date_from) { conditions.push(`i.invoice_date >= $${p++}`); params.push(date_from); }
|
||||
if (date_to) { conditions.push(`i.invoice_date <= $${p++}`); params.push(date_to); }
|
||||
if (search) { conditions.push(`(i.supplier ILIKE $${p} OR i.comment ILIKE $${p})`); params.push(`%${search}%`); p++; }
|
||||
|
||||
// Tri sécurisé
|
||||
const sortMap: Record<string, string> = {
|
||||
invoice_date: 'i.invoice_date',
|
||||
amount: 'i.amount',
|
||||
supplier: 'i.supplier',
|
||||
company_name: 'co.name',
|
||||
category_name: 'cat.name',
|
||||
status: 'i.status',
|
||||
sent_at: 'i.sent_at',
|
||||
};
|
||||
const orderCol = sortMap[String(sort_by)] ?? 'i.sent_at';
|
||||
const orderDir = sort_dir === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const pageNum = Math.max(1, parseInt(String(page || '1')));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(String(limit || '50'))));
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
const [dataRes, countRes] = await Promise.all([
|
||||
db.query(
|
||||
`SELECT i.id, i.supplier, i.amount, i.invoice_date, i.comment,
|
||||
i.status, i.email_sent, i.tracking_added, i.sent_at,
|
||||
i.add_to_tracking, i.pdf_filename, i.created_at,
|
||||
co.name AS company_name,
|
||||
cat.name AS category_name
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
WHERE ${where}
|
||||
ORDER BY ${orderCol} ${orderDir} NULLS LAST
|
||||
LIMIT $${p} OFFSET $${p + 1}`,
|
||||
[...params, limitNum, offset]
|
||||
),
|
||||
db.query(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
WHERE ${where}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
data: dataRes.rows,
|
||||
total: countRes.rows[0].total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/invoices
|
||||
* Crée une facture (avec invités)
|
||||
*/
|
||||
router.post('/', validate(createSchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { company_id, category_id, supplier, amount, invoice_date,
|
||||
comment, images, add_to_tracking, guests } = req.body;
|
||||
|
||||
const client = await db.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const invResult = await client.query(
|
||||
`INSERT INTO invoices
|
||||
(user_id, company_id, category_id, supplier, amount,
|
||||
invoice_date, comment, images, add_to_tracking)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING *`,
|
||||
[req.user!.id, company_id, category_id, supplier ?? null, amount,
|
||||
invoice_date, comment ?? null, JSON.stringify(images), add_to_tracking]
|
||||
);
|
||||
const invoice = invResult.rows[0];
|
||||
|
||||
for (const g of guests) {
|
||||
await client.query(
|
||||
'INSERT INTO guests (invoice_id, name, company, sort_order) VALUES ($1,$2,$3,$4)',
|
||||
[invoice.id, g.name, g.company ?? null, g.sort_order ?? 0]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json(await getInvoiceById(invoice.id));
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/invoices/:id/send
|
||||
* 1. Génère le PDF (images + page invités si applicable)
|
||||
* 2. Envoie l'email à la société
|
||||
* 3. Ajoute au fichier Excel SharePoint (si add_to_tracking = true)
|
||||
* 4. Met à jour la facture en BDD
|
||||
*/
|
||||
router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const invoice = await getInvoiceById(req.params.id);
|
||||
if (!invoice) { res.status(404).json({ error: 'Facture introuvable' }); return; }
|
||||
if (invoice.user_id !== req.user!.id) { res.status(403).json({ error: 'Accès refusé' }); return; }
|
||||
|
||||
const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]);
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user.smtp_host || !user.smtp_pass_enc) {
|
||||
res.status(400).json({ error: 'SMTP non configuré. Allez dans Paramètres → Email.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Nommage du PDF ────────────────────────────────────────
|
||||
const dateObj = new Date(invoice.invoice_date);
|
||||
const dateStr = dateObj.toISOString().split('T')[0]; // AAAA-MM-JJ
|
||||
const amountStr = Number(invoice.amount).toFixed(2).replace('.', ',');
|
||||
const safeCat = invoice.category_name.replace(/[^a-zA-Z0-9À-ɏ]/g, '-');
|
||||
const safeCo = invoice.company_name.replace(/[^a-zA-Z0-9À-ɏ]/g, '-');
|
||||
const pdfFilename = `${dateStr}_${safeCat}_${safeCo}_${amountStr}€.pdf`;
|
||||
|
||||
const pdfDir = path.join(config.uploadsDir, 'pdfs');
|
||||
const pdfPath = path.join(pdfDir, `${invoice.id}.pdf`);
|
||||
|
||||
// ── Génération PDF ────────────────────────────────────────
|
||||
const imagePaths = (invoice.images as Array<{ path: string; order: number }>)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((img) => path.join(config.uploadsDir, 'images', img.path));
|
||||
|
||||
await generateInvoicePdf(imagePaths, invoice.guests, pdfPath, user.name);
|
||||
|
||||
// ── Envoi email ───────────────────────────────────────────
|
||||
await sendInvoiceEmail(user, invoice.company_email, pdfPath, pdfFilename);
|
||||
|
||||
// ── SharePoint (non bloquant) ─────────────────────────────
|
||||
let trackingAdded = false;
|
||||
let trackingError: string | null = null;
|
||||
if (invoice.add_to_tracking) {
|
||||
try {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
await addRowToExcel({
|
||||
category: invoice.category_name,
|
||||
companyName: invoice.company_name,
|
||||
comment: invoice.comment || '',
|
||||
guests: invoice.guests,
|
||||
date: `${day}/${month}/${year}`,
|
||||
amount: Number(invoice.amount),
|
||||
userName: user.name,
|
||||
});
|
||||
trackingAdded = true;
|
||||
} catch (err: any) {
|
||||
// Non bloquant : l'email est déjà envoyé, mais on remonte l'erreur
|
||||
// pour que le frontend puisse afficher un avertissement.
|
||||
console.warn('[SharePoint] Erreur non bloquante :', err.message);
|
||||
trackingError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mise à jour BDD ───────────────────────────────────────
|
||||
await db.query(
|
||||
`UPDATE invoices
|
||||
SET pdf_path=$1, pdf_filename=$2, email_sent=TRUE,
|
||||
tracking_added=$3, sent_at=NOW(), updated_at=NOW()
|
||||
WHERE id=$4`,
|
||||
[path.join('pdfs', `${invoice.id}.pdf`), pdfFilename, trackingAdded, invoice.id]
|
||||
);
|
||||
|
||||
const updated = await getInvoiceById(invoice.id);
|
||||
res.json({ ...updated, tracking_error: trackingError });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices/:id
|
||||
*/
|
||||
router.get('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const invoice = await getInvoiceById(req.params.id);
|
||||
if (!invoice || invoice.user_id !== req.user!.id) {
|
||||
res.status(404).json({ error: 'Facture introuvable' });
|
||||
return;
|
||||
}
|
||||
res.json(invoice);
|
||||
}));
|
||||
|
||||
/**
|
||||
* PATCH /api/invoices/:id/status
|
||||
* Toggle du statut (pending ↔ reimbursed)
|
||||
*/
|
||||
router.patch('/:id/status', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { status } = req.body;
|
||||
if (!['pending', 'reimbursed'].includes(status)) {
|
||||
res.status(400).json({ error: 'Statut invalide (pending ou reimbursed)' });
|
||||
return;
|
||||
}
|
||||
const result = await db.query(
|
||||
'UPDATE invoices SET status=$1, updated_at=NOW() WHERE id=$2 AND user_id=$3 RETURNING id, status',
|
||||
[status, req.params.id, req.user!.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Facture introuvable' }); return; }
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices/:id/pdf
|
||||
* Téléchargement du PDF généré
|
||||
*/
|
||||
router.get('/:id/pdf', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'SELECT pdf_path, pdf_filename FROM invoices WHERE id=$1 AND user_id=$2',
|
||||
[req.params.id, req.user!.id]
|
||||
);
|
||||
const invoice = result.rows[0];
|
||||
if (!invoice?.pdf_path) { res.status(404).json({ error: 'PDF non disponible' }); return; }
|
||||
|
||||
const fullPath = path.join(config.uploadsDir, invoice.pdf_path);
|
||||
res.download(fullPath, invoice.pdf_filename);
|
||||
}));
|
||||
|
||||
/**
|
||||
* DELETE /api/invoices/:id
|
||||
* Supprime la facture + nettoyage des fichiers
|
||||
*/
|
||||
router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'DELETE FROM invoices WHERE id=$1 AND user_id=$2 RETURNING id, pdf_path, images',
|
||||
[req.params.id, req.user!.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Facture introuvable' }); return; }
|
||||
|
||||
const deleted = result.rows[0];
|
||||
|
||||
// Nettoyage PDF
|
||||
if (deleted.pdf_path) {
|
||||
await fs.unlink(path.join(config.uploadsDir, deleted.pdf_path)).catch(() => {});
|
||||
}
|
||||
// Nettoyage images sources
|
||||
const images = (deleted.images as Array<{ path: string }>) || [];
|
||||
for (const img of images) {
|
||||
await fs.unlink(path.join(config.uploadsDir, 'images', img.path)).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { encrypt } from '../crypto';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) =>
|
||||
(req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
|
||||
// ─── Schémas ─────────────────────────────────────────────────
|
||||
|
||||
const smtpSchema = z.object({
|
||||
smtp_host: z.string().min(1, 'Hôte SMTP requis'),
|
||||
smtp_port: z.number().int().min(1).max(65535),
|
||||
smtp_secure: z.boolean(),
|
||||
smtp_user: z.string().min(1),
|
||||
smtp_pass: z.string().optional(), // absent = conserver l'existant
|
||||
smtp_from_name: z.string().min(1),
|
||||
smtp_from_email: z.string().email(),
|
||||
});
|
||||
|
||||
const appSettingsSchema = z.object({
|
||||
graph_tenant_id: z.string().optional(),
|
||||
graph_client_id: z.string().optional(),
|
||||
graph_client_secret: z.string().optional(), // absent = conserver l'existant
|
||||
sharepoint_site_id: z.string().optional(),
|
||||
sharepoint_item_id: z.string().optional(),
|
||||
sharepoint_sheet_name: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Routes SMTP ─────────────────────────────────────────────
|
||||
|
||||
/** GET /api/settings/smtp — Config SMTP de l'utilisateur (sans mot de passe) */
|
||||
router.get('/smtp', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user,
|
||||
smtp_from_name, smtp_from_email,
|
||||
(smtp_pass_enc IS NOT NULL) AS has_password
|
||||
FROM users WHERE id=$1`,
|
||||
[req.user!.id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** PUT /api/settings/smtp — Sauvegarde la config SMTP */
|
||||
router.put('/smtp', validate(smtpSchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from_name, smtp_from_email } = req.body;
|
||||
|
||||
if (smtp_pass) {
|
||||
await db.query(
|
||||
`UPDATE users
|
||||
SET smtp_host=$1, smtp_port=$2, smtp_secure=$3, smtp_user=$4,
|
||||
smtp_pass_enc=$5, smtp_from_name=$6, smtp_from_email=$7, updated_at=NOW()
|
||||
WHERE id=$8`,
|
||||
[smtp_host, smtp_port, smtp_secure, smtp_user, encrypt(smtp_pass), smtp_from_name, smtp_from_email, req.user!.id]
|
||||
);
|
||||
} else {
|
||||
await db.query(
|
||||
`UPDATE users
|
||||
SET smtp_host=$1, smtp_port=$2, smtp_secure=$3, smtp_user=$4,
|
||||
smtp_from_name=$5, smtp_from_email=$6, updated_at=NOW()
|
||||
WHERE id=$7`,
|
||||
[smtp_host, smtp_port, smtp_secure, smtp_user, smtp_from_name, smtp_from_email, req.user!.id]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
/** POST /api/settings/smtp/test — Envoie un email de test */
|
||||
router.post('/smtp/test', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]);
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user.smtp_host || !user.smtp_pass_enc) {
|
||||
res.status(400).json({ error: 'SMTP non configuré' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { sendTestEmail } = await import('../services/email');
|
||||
await sendTestEmail(user);
|
||||
res.json({ success: true, message: `Email de test envoyé à ${user.smtp_from_email}` });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: `Échec SMTP : ${err.message}` });
|
||||
}
|
||||
}));
|
||||
|
||||
// ─── Routes paramètres application (Microsoft Graph) ─────────
|
||||
|
||||
/** GET /api/settings/app — Config Graph + SharePoint (sans secret) */
|
||||
router.get('/app', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN (
|
||||
'graph_tenant_id','graph_client_id',
|
||||
'sharepoint_site_id','sharepoint_item_id','sharepoint_sheet_name'
|
||||
)`
|
||||
);
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of result.rows) settings[row.key] = row.value;
|
||||
// Indique juste si le secret est présent sans l'exposer
|
||||
const secretResult = await db.query(
|
||||
"SELECT value FROM app_settings WHERE key='graph_client_secret_enc'"
|
||||
);
|
||||
settings.has_secret = secretResult.rows[0] ? 'true' : 'false';
|
||||
res.json(settings);
|
||||
}));
|
||||
|
||||
/** PUT /api/settings/app — Sauvegarde la config Microsoft Graph + SharePoint */
|
||||
router.put('/app', validate(appSettingsSchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const {
|
||||
graph_tenant_id, graph_client_id, graph_client_secret,
|
||||
sharepoint_site_id, sharepoint_item_id, sharepoint_sheet_name,
|
||||
} = req.body;
|
||||
|
||||
const upsert = async (key: string, value: string | undefined) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
await db.query(
|
||||
`INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()`,
|
||||
[key, value]
|
||||
);
|
||||
};
|
||||
|
||||
await upsert('graph_tenant_id', graph_tenant_id);
|
||||
await upsert('graph_client_id', graph_client_id);
|
||||
await upsert('sharepoint_site_id', sharepoint_site_id);
|
||||
await upsert('sharepoint_item_id', sharepoint_item_id);
|
||||
await upsert('sharepoint_sheet_name', sharepoint_sheet_name);
|
||||
if (graph_client_secret) {
|
||||
await upsert('graph_client_secret_enc', encrypt(graph_client_secret));
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
/** POST /api/settings/sharepoint/test — Vérifie la connexion Graph + accès au fichier Excel */
|
||||
router.post('/sharepoint/test', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { testSharepointConnection } = await import('../services/sharepoint');
|
||||
await testSharepointConnection();
|
||||
res.json({ success: true, message: 'Connexion SharePoint OK — fichier Excel accessible.' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Script d'initialisation des utilisateurs Greg et Gaël.
|
||||
* À exécuter une seule fois après la migration.
|
||||
*
|
||||
* Variables d'environnement requises :
|
||||
* GREG_EMAIL, GREG_PASSWORD, GAEL_EMAIL, GAEL_PASSWORD
|
||||
*
|
||||
* Usage : npm run init-users
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '../db';
|
||||
|
||||
async function initUsers() {
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
const users = [
|
||||
{
|
||||
name: 'Greg',
|
||||
email: process.env.GREG_EMAIL || 'greg@example.com',
|
||||
password: process.env.GREG_PASSWORD || 'changeme',
|
||||
},
|
||||
{
|
||||
name: 'Gaël',
|
||||
email: process.env.GAEL_EMAIL || 'gael@example.com',
|
||||
password: process.env.GAEL_PASSWORD || 'changeme',
|
||||
},
|
||||
];
|
||||
|
||||
for (const user of users) {
|
||||
const hash = await bcrypt.hash(user.password, SALT_ROUNDS);
|
||||
const result = await db.query(
|
||||
`INSERT INTO users (name, email, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email) DO UPDATE SET name=$1, updated_at=NOW()
|
||||
RETURNING id, name, email`,
|
||||
[user.name, user.email, hash]
|
||||
);
|
||||
console.log(`✅ Utilisateur créé/mis à jour : ${result.rows[0].name} <${result.rows[0].email}>`);
|
||||
}
|
||||
|
||||
await db.end();
|
||||
}
|
||||
|
||||
initUsers().catch((err) => {
|
||||
console.error('❌ Initialisation échouée :', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Script de migration — crée toutes les tables et données initiales.
|
||||
* Usage : npm run migrate
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { db } from '../db';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function migrate() {
|
||||
const sqlPath = path.join(__dirname, '../migrations/001_init.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('🔄 Exécution de la migration 001_init.sql...');
|
||||
await db.query(sql);
|
||||
console.log('✅ Migration terminée avec succès');
|
||||
|
||||
await db.end();
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('❌ Migration échouée :', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { decrypt } from '../crypto';
|
||||
|
||||
export interface UserSmtpConfig {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_secure: boolean;
|
||||
smtp_user: string;
|
||||
smtp_pass_enc: string;
|
||||
smtp_from_name: string;
|
||||
smtp_from_email: string;
|
||||
}
|
||||
|
||||
function createTransporter(user: UserSmtpConfig) {
|
||||
return nodemailer.createTransport({
|
||||
host: user.smtp_host,
|
||||
port: user.smtp_port,
|
||||
secure: user.smtp_secure,
|
||||
auth: {
|
||||
user: user.smtp_user,
|
||||
pass: decrypt(user.smtp_pass_enc),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie la facture par email.
|
||||
* Expéditeur = compte SMTP de l'utilisateur connecté.
|
||||
* Destinataire = email de la société à rembourser.
|
||||
*/
|
||||
export async function sendInvoiceEmail(
|
||||
user: UserSmtpConfig,
|
||||
toEmail: string,
|
||||
pdfAbsPath: string,
|
||||
pdfFilename: string
|
||||
): Promise<void> {
|
||||
const transporter = createTransporter(user);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
|
||||
to: toEmail,
|
||||
subject: 'Facture à rembourser',
|
||||
text: '',
|
||||
attachments: [
|
||||
{
|
||||
filename: pdfFilename,
|
||||
path: pdfAbsPath,
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Email de test pour vérifier la config SMTP de l'utilisateur.
|
||||
*/
|
||||
export async function sendTestEmail(user: UserSmtpConfig): Promise<void> {
|
||||
const transporter = createTransporter(user);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
|
||||
to: user.smtp_from_email,
|
||||
subject: '[NotesFrais] Test de configuration SMTP',
|
||||
text: 'Votre configuration SMTP fonctionne correctement ✓',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
// @ts-ignore
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface PdfGuest {
|
||||
name: string;
|
||||
company?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le PDF de la facture.
|
||||
* - Une page par image (ticket/reçu)
|
||||
* - Si des invités sont présents, la liste est ajoutée EN BAS de la dernière
|
||||
* page d'image (même page, sous le ticket), sans page séparée.
|
||||
* - userName : prénom affiché dans l'en-tête "Invité par …"
|
||||
*/
|
||||
export async function generateInvoicePdf(
|
||||
imagePaths: string[],
|
||||
guests: PdfGuest[],
|
||||
outputPath: string,
|
||||
userName = 'Moi'
|
||||
): Promise<void> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
const font = guests.length > 0 ? await pdfDoc.embedFont(StandardFonts.Helvetica) : null;
|
||||
const fontBold = guests.length > 0 ? await pdfDoc.embedFont(StandardFonts.HelveticaBold) : null;
|
||||
|
||||
for (let idx = 0; idx < imagePaths.length; idx++) {
|
||||
const imgPath = imagePaths[idx];
|
||||
const imgBytes = await fs.readFile(imgPath);
|
||||
const ext = path.extname(imgPath).toLowerCase();
|
||||
const image = ext === '.png'
|
||||
? await pdfDoc.embedPng(imgBytes)
|
||||
: await pdfDoc.embedJpg(imgBytes);
|
||||
|
||||
// Normaliser à une largeur A4 (595pt) pour que la section invités
|
||||
// soit visible. Sans normalisation, image.width est en pixels
|
||||
// (ex. 3024px), ce qui donne une page de 42 pouces de large et
|
||||
// rend la section invités imperceptible en bas de page.
|
||||
const PAGE_W = 595;
|
||||
const scale = PAGE_W / image.width;
|
||||
const imgW = PAGE_W;
|
||||
const imgH = Math.round(image.height * scale);
|
||||
|
||||
const isLast = idx === imagePaths.length - 1;
|
||||
const addList = isLast && guests.length > 0;
|
||||
|
||||
// Hauteur supplémentaire pour la liste d'invités (sous le ticket)
|
||||
const PADDING = 24; // marge entre ticket et liste
|
||||
const LINE_H = 44; // hauteur par ligne d'invité (×2)
|
||||
const HEADER_H = 80; // titre + séparateur (×2)
|
||||
const listH = addList
|
||||
? HEADER_H + guests.length * LINE_H + PADDING * 2
|
||||
: 0;
|
||||
|
||||
const pageW = imgW;
|
||||
const pageH = imgH + listH;
|
||||
|
||||
const page = pdfDoc.addPage([pageW, pageH]);
|
||||
|
||||
// ── Image (placée en haut de la page) ──────────────────────
|
||||
page.drawImage(image, { x: 0, y: listH, width: imgW, height: imgH });
|
||||
|
||||
// ── Liste des invités (en bas, sous l'image) ───────────────
|
||||
if (addList && font && fontBold) {
|
||||
const M = 30; // marge gauche/droite
|
||||
let y = listH - PADDING;
|
||||
|
||||
// Ligne de séparation entre ticket et liste
|
||||
page.drawLine({
|
||||
start: { x: M, y: listH - 1 },
|
||||
end: { x: pageW - M, y: listH - 1 },
|
||||
thickness: 1,
|
||||
color: rgb(0.8, 0.8, 0.8),
|
||||
});
|
||||
|
||||
// Titre "Invité par <prénom>"
|
||||
page.drawText(`Invité par ${userName}`, {
|
||||
x: M, y,
|
||||
size: 26,
|
||||
font: fontBold,
|
||||
color: rgb(0.15, 0.15, 0.15),
|
||||
});
|
||||
y -= HEADER_H - PADDING - 10;
|
||||
|
||||
// Séparateur sous le titre
|
||||
page.drawLine({
|
||||
start: { x: M, y },
|
||||
end: { x: pageW - M, y },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.75, 0.75, 0.75),
|
||||
});
|
||||
y -= 22;
|
||||
|
||||
// Lignes invités
|
||||
for (const guest of guests) {
|
||||
if (y < 4) break;
|
||||
page.drawText(guest.name, {
|
||||
x: M, y,
|
||||
size: 22,
|
||||
font: fontBold,
|
||||
color: rgb(0.1, 0.1, 0.1),
|
||||
maxWidth: guest.company ? (pageW - M * 2) / 2 - 10 : pageW - M * 2,
|
||||
});
|
||||
if (guest.company) {
|
||||
page.drawText(guest.company, {
|
||||
x: M + (pageW - M * 2) / 2,
|
||||
y,
|
||||
size: 22,
|
||||
font,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
maxWidth: (pageW - M * 2) / 2,
|
||||
});
|
||||
}
|
||||
y -= LINE_H;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
await fs.writeFile(outputPath, pdfBytes);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { db } from '../db';
|
||||
import { decrypt } from '../crypto';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
interface GraphConfig {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
sharepointSiteId: string;
|
||||
sharepointItemId: string;
|
||||
sharepointSheet: string;
|
||||
}
|
||||
|
||||
export interface SharepointRowData {
|
||||
category: string;
|
||||
companyName: string;
|
||||
comment: string;
|
||||
guests: Array<{ name: string; company?: string | null }>;
|
||||
date: string; // format JJ/MM/AAAA
|
||||
amount: number;
|
||||
userName: string; // 'Greg' ou 'Gaël'
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
async function getGraphConfig(): Promise<GraphConfig> {
|
||||
const result = await db.query(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN (
|
||||
'graph_tenant_id', 'graph_client_id', 'graph_client_secret_enc',
|
||||
'sharepoint_site_id', 'sharepoint_item_id', 'sharepoint_sheet_name'
|
||||
)`
|
||||
);
|
||||
const cfg: Record<string, string> = {};
|
||||
for (const row of result.rows) cfg[row.key] = row.value;
|
||||
|
||||
if (!cfg.graph_tenant_id || !cfg.graph_client_id || !cfg.graph_client_secret_enc) {
|
||||
throw new Error('Microsoft Graph non configuré (tenant_id, client_id ou client_secret manquant)');
|
||||
}
|
||||
if (!cfg.sharepoint_site_id || !cfg.sharepoint_item_id) {
|
||||
throw new Error('Fichier Excel SharePoint non configuré (site_id ou item_id manquant dans Paramètres → Microsoft 365)');
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId: cfg.graph_tenant_id,
|
||||
clientId: cfg.graph_client_id,
|
||||
clientSecret: decrypt(cfg.graph_client_secret_enc),
|
||||
sharepointSiteId: cfg.sharepoint_site_id,
|
||||
sharepointItemId: cfg.sharepoint_item_id,
|
||||
sharepointSheet: cfg.sharepoint_sheet_name ?? 'Feuil1',
|
||||
};
|
||||
}
|
||||
|
||||
async function getAccessToken(cfg: GraphConfig): Promise<string> {
|
||||
const response = await fetch(
|
||||
`https://login.microsoftonline.com/${cfg.tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Obtention token Graph échouée (${response.status}) : ${body}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token: string };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
// ─── Test de connexion (sans écriture) ───────────────────────
|
||||
|
||||
/**
|
||||
* Vérifie que le token Graph s'obtient et que la feuille Excel est accessible.
|
||||
* Utilisé par POST /api/settings/sharepoint/test.
|
||||
*/
|
||||
export async function testSharepointConnection(): Promise<void> {
|
||||
const cfg = await getGraphConfig();
|
||||
const token = await getAccessToken(cfg);
|
||||
|
||||
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${cfg.sharepointSiteId}/drive/items/${cfg.sharepointItemId}/workbook/worksheets/${encodeURIComponent(cfg.sharepointSheet)}`;
|
||||
const resp = await fetch(`${baseUrl}/usedRange?$select=rowCount`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`Impossible d'accéder à la feuille "${cfg.sharepointSheet}" (${resp.status}) : ${body.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fonction principale ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ajoute une ligne dans le fichier Excel SharePoint commun selon le mapping :
|
||||
* A : Catégorie
|
||||
* B : Société facturée
|
||||
* C : Commentaire + invités
|
||||
* D : Date (JJ/MM/AAAA)
|
||||
* E : Montant si Greg
|
||||
* F : Montant si Gaël
|
||||
*/
|
||||
export async function addRowToExcel(row: SharepointRowData): Promise<void> {
|
||||
const cfg = await getGraphConfig();
|
||||
const token = await getAccessToken(cfg);
|
||||
|
||||
// Construction de la cellule C (commentaire + invités)
|
||||
let cellComment = row.comment || '';
|
||||
if (row.guests.length > 0) {
|
||||
const guestStr = row.guests
|
||||
.map((g) => (g.company ? `${g.name} — ${g.company}` : g.name))
|
||||
.join(' ; ');
|
||||
cellComment = cellComment
|
||||
? `${cellComment}. Invités : ${guestStr}`
|
||||
: `Invités : ${guestStr}`;
|
||||
}
|
||||
|
||||
// Colonnes E/F selon l'utilisateur
|
||||
const nameLower = row.userName.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
const isGreg = nameLower === 'greg';
|
||||
const isGael = nameLower === 'gael';
|
||||
|
||||
const values = [[
|
||||
row.category, // A
|
||||
row.companyName, // B
|
||||
cellComment, // C
|
||||
row.date, // D
|
||||
isGreg ? row.amount : null, // E — Greg
|
||||
isGael ? row.amount : null, // F — Gaël
|
||||
]];
|
||||
|
||||
// ── Trouver la première ligne vide ───────────────────────────
|
||||
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${cfg.sharepointSiteId}/drive/items/${cfg.sharepointItemId}/workbook/worksheets/${encodeURIComponent(cfg.sharepointSheet)}`;
|
||||
|
||||
const rangeResp = await fetch(`${baseUrl}/usedRange`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!rangeResp.ok) {
|
||||
const body = await rangeResp.text();
|
||||
throw new Error(`Graph usedRange échoué (${rangeResp.status}) : ${body}`);
|
||||
}
|
||||
|
||||
const rangeData = (await rangeResp.json()) as { rowCount: number };
|
||||
const nextRow = rangeData.rowCount + 1;
|
||||
|
||||
// ── Écriture de la nouvelle ligne ────────────────────────────
|
||||
const writeResp = await fetch(
|
||||
`${baseUrl}/range(address='A${nextRow}:F${nextRow}')`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ values }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!writeResp.ok) {
|
||||
const body = await writeResp.text();
|
||||
throw new Error(`Graph écriture ligne échouée (${writeResp.status}) : ${body}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
services:
|
||||
|
||||
# ── PostgreSQL ──────────────────────────────────────────────
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: notesfrais
|
||||
POSTGRES_USER: notesfrais
|
||||
POSTGRES_PASSWORD: 9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b
|
||||
volumes:
|
||||
- pgdata4:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U notesfrais -d notesfrais"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# ── Backend Express ─────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_URL: postgresql://notesfrais:9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b@db:5432/notesfrais
|
||||
JWT_SECRET: f1bec20689d176182a33b6904a236d9c207322fb5886e6599812bfbc236bd95bff4d1a05edb273a6ccdb6e06004e059241f7937ef91dee08b92fc25f0ea767c1
|
||||
APP_SECRET: bbec693632ddd25adeefaddfa64a3e8e1245a97f530cb492f1d3f496ae3a1936
|
||||
UPLOADS_DIR: /app/uploads
|
||||
FRONTEND_URL: https://frais.domench.fr
|
||||
GAEL_EMAIL: waltergael@1dotech.com
|
||||
GAEL_PASSWORD: Changeme123!
|
||||
GREG_EMAIL: waltergreg@1dotech.com
|
||||
GREG_PASSWORD: Changeme123!
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
internal:
|
||||
aliases:
|
||||
- notesfrais-backend
|
||||
|
||||
# ── Frontend nginx ──────────────────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
networks:
|
||||
- internal
|
||||
- coolify
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=coolify"
|
||||
- "traefik.http.routers.notesfrais.rule=Host(`frais.domench.fr`)"
|
||||
- "traefik.http.routers.notesfrais.entrypoints=https"
|
||||
- "traefik.http.routers.notesfrais.tls=true"
|
||||
- "traefik.http.routers.notesfrais.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.notesfrais.loadbalancer.server.port=80"
|
||||
- "traefik.http.routers.notesfrais-http.rule=Host(`frais.domench.fr`)"
|
||||
- "traefik.http.routers.notesfrais-http.entrypoints=http"
|
||||
- "traefik.http.routers.notesfrais-http.middlewares=notesfrais-https-redirect"
|
||||
- "traefik.http.middlewares.notesfrais-https-redirect.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.notesfrais-https-redirect.redirectscheme.permanent=true"
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
coolify:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
pgdata4:
|
||||
uploads:
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,25 @@
|
||||
# ── Stage 1 : Build Vite ─────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --include=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
# En prod le frontend est servi par nginx qui proxifie /api → backend
|
||||
# VITE_API_URL est laissé vide : le client utilise le chemin relatif /api
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2 : Serveur nginx ───────────────────────────────────
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
# SPA + proxy API
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Assets du build Vite
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="NotesFrais" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>NotesFrais</title>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+8
-1
@@ -39,4 +39,11 @@ server {
|
||||
client_max_body_size 20m;
|
||||
|
||||
# Timeout généreux pour la génération PDF
|
||||
pr
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# ── SPA fallback (React Router) ───────────────────────────
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "notesfrais-frontend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.32.1",
|
||||
"axios": "^1.6.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-image-crop": "^11.0.5",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"tesseract.js": "^5.0.5",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAuthStore } from './store/auth';
|
||||
import Login from './pages/Login';
|
||||
import Layout from './components/Layout';
|
||||
import NewInvoice from './pages/NewInvoice';
|
||||
import MyInvoices from './pages/MyInvoices';
|
||||
import InvoiceDetail from './pages/InvoiceDetail';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuthStore();
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
|
||||
{/* Page de connexion — accessible sans auth */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Pages protégées — layout avec nav du bas */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/new" replace />} />
|
||||
<Route path="new" element={<NewInvoice />} />
|
||||
<Route path="invoices" element={<MyInvoices />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
{/* Toasts globaux */}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
gutter={8}
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
success: { iconTheme: { primary: '#4f46e5', secondary: '#fff' } },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Client Axios centralisé.
|
||||
* - Ajoute automatiquement le Bearer token à chaque requête
|
||||
* - Rafraîchit le token silencieusement en cas de 401
|
||||
* - Redirige vers /login si le refresh échoue
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? '/api',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// ─── Request interceptor — injection du token ────────────────
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ─── Response interceptor — refresh automatique ──────────────
|
||||
let isRefreshing = false;
|
||||
type QueueItem = { resolve: (t: string) => void; reject: (e: unknown) => void };
|
||||
let failedQueue: QueueItem[] = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
for (const item of failedQueue) {
|
||||
if (error) item.reject(error);
|
||||
else item.resolve(token!);
|
||||
}
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error) => {
|
||||
const orig = error.config as typeof error.config & { _retry?: boolean };
|
||||
|
||||
if (error.response?.status === 401 && !orig._retry) {
|
||||
// Si déjà en train de rafraîchir, mettre en file d'attente
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
orig.headers.Authorization = `Bearer ${token}`;
|
||||
return api(orig);
|
||||
});
|
||||
}
|
||||
|
||||
orig._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const { refreshToken, setAccessToken, logout } = useAuthStore.getState();
|
||||
|
||||
if (!refreshToken) {
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Appel direct axios (pas l'instance api) pour éviter la boucle
|
||||
const resp = await axios.post('/api/auth/refresh', { refreshToken });
|
||||
const newToken: string = resp.data.accessToken;
|
||||
setAccessToken(newToken);
|
||||
processQueue(null, newToken);
|
||||
orig.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(orig);
|
||||
} catch (refreshErr) {
|
||||
processQueue(refreshErr, null);
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshErr);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Composant capture photo.
|
||||
* - Sur mobile : ouvre la caméra native (capture="environment")
|
||||
* - Sur desktop : ouvre la galerie de fichiers
|
||||
* Retourne un DataURL de l'image capturée.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
onCapture: (dataUrl: string, mimeType: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Camera({ onCapture, disabled }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const result = ev.target?.result as string;
|
||||
onCapture(result, file.type || 'image/jpeg');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Reset input pour permettre de re-sélectionner le même fichier
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="flex flex-col items-center justify-center gap-3 w-full py-10
|
||||
border-2 border-dashed border-indigo-200 rounded-2xl
|
||||
bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200
|
||||
text-indigo-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="font-semibold text-base">Photographier la facture</span>
|
||||
<span className="text-xs text-indigo-400">ou sélectionner une image</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Gestion de la liste d'invités pour une facture.
|
||||
* - Autocomplete : suggestions depuis le répertoire contacts au fur et à mesure de la frappe
|
||||
* - Panneau de sélection multiple : bouton "Choisir dans le répertoire" → liste cochable
|
||||
* - Saisie libre toujours possible (invité hors répertoire)
|
||||
*/
|
||||
import { useState, useImperativeHandle, forwardRef, useRef, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../api/client';
|
||||
import type { Guest, Contact } from '../types';
|
||||
|
||||
export interface GuestManagerHandle {
|
||||
/**
|
||||
* Si un invité est en cours de saisie (champ non validé), l'ajoute à la liste
|
||||
* et retourne la liste complète (incluant ce nouvel invité).
|
||||
* Retourne null si rien à flusher.
|
||||
*/
|
||||
flushPending: () => Guest[] | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
guests: Guest[];
|
||||
onChange: (guests: Guest[]) => void;
|
||||
}
|
||||
|
||||
const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager({ guests, onChange }, ref) {
|
||||
const [name, setName] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
const [showAC, setShowAC] = useState(false); // autocomplete dropdown visible
|
||||
const [showPanel, setShowPanel] = useState(false); // panneau de sélection multiple
|
||||
const [panelSearch, setPanelSearch] = useState('');
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const acRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Chargement du répertoire ─────────────────────────────────
|
||||
const { data: allContacts = [] } = useQuery<Contact[]>({
|
||||
queryKey: ['contacts'],
|
||||
queryFn: () => api.get('/contacts').then(r => r.data),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// ── Autocomplete ─────────────────────────────────────────────
|
||||
const acSuggestions: Contact[] = name.trim().length >= 1
|
||||
? allContacts.filter(c =>
|
||||
c.name.toLowerCase().includes(name.toLowerCase()) &&
|
||||
!guests.some(g => g.name.toLowerCase() === c.name.toLowerCase())
|
||||
).slice(0, 6)
|
||||
: [];
|
||||
|
||||
// Ferme le dropdown si clic dehors
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (acRef.current && !acRef.current.contains(e.target as Node)) {
|
||||
setShowAC(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
// ── Panel : contacts déjà dans la liste (pour l'état coché) ──
|
||||
const alreadyAdded = new Set(guests.map(g => g.name.toLowerCase()));
|
||||
const panelFiltered = panelSearch.trim()
|
||||
? allContacts.filter(c =>
|
||||
c.name.toLowerCase().includes(panelSearch.toLowerCase()) ||
|
||||
(c.company ?? '').toLowerCase().includes(panelSearch.toLowerCase())
|
||||
)
|
||||
: allContacts;
|
||||
|
||||
// ── Implémentation de flushPending (ref parent) ───────────────
|
||||
useImperativeHandle(ref, () => ({
|
||||
flushPending() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const updated: Guest[] = [
|
||||
...guests,
|
||||
{ name: trimmed, company: company.trim() || null, sort_order: guests.length },
|
||||
];
|
||||
onChange(updated);
|
||||
setName('');
|
||||
setCompany('');
|
||||
return updated;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────
|
||||
function addGuest(overrideName?: string, overrideCompany?: string | null) {
|
||||
const n = (overrideName ?? name).trim();
|
||||
if (!n) return;
|
||||
if (guests.some(g => g.name.toLowerCase() === n.toLowerCase())) {
|
||||
setName(''); setCompany('');
|
||||
return;
|
||||
}
|
||||
onChange([
|
||||
...guests,
|
||||
{ name: n, company: overrideCompany !== undefined ? overrideCompany : company.trim() || null, sort_order: guests.length },
|
||||
]);
|
||||
setName('');
|
||||
setCompany('');
|
||||
setShowAC(false);
|
||||
}
|
||||
|
||||
function removeGuest(index: number) {
|
||||
onChange(guests.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addGuest(); }
|
||||
if (e.key === 'Escape') setShowAC(false);
|
||||
}
|
||||
|
||||
function selectFromAC(c: Contact) {
|
||||
addGuest(c.name, c.company ?? null);
|
||||
nameInputRef.current?.focus();
|
||||
}
|
||||
|
||||
function toggleFromPanel(c: Contact) {
|
||||
const lower = c.name.toLowerCase();
|
||||
if (alreadyAdded.has(lower)) {
|
||||
// retirer
|
||||
onChange(guests.filter(g => g.name.toLowerCase() !== lower));
|
||||
} else {
|
||||
// ajouter
|
||||
onChange([
|
||||
...guests,
|
||||
{ name: c.name, company: c.company ?? null, sort_order: guests.length },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Invités déjà ajoutés */}
|
||||
{guests.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{guests.map((g, i) => (
|
||||
<li key={i} className="flex items-center gap-3 bg-indigo-50 rounded-xl px-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
|
||||
{g.company && <p className="text-xs text-gray-500 truncate">{g.company}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeGuest(i)}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Formulaire + autocomplete */}
|
||||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
|
||||
{/* Champ nom avec autocomplete */}
|
||||
<div className="relative" ref={acRef}>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => { setName(e.target.value); setShowAC(true); }}
|
||||
onFocus={() => setShowAC(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Nom de l'invité *"
|
||||
className="form-input text-sm py-2"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* Dropdown autocomplete */}
|
||||
{showAC && acSuggestions.length > 0 && (
|
||||
<div className="absolute z-40 left-0 right-0 top-full mt-1 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
|
||||
{acSuggestions.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); selectFromAC(c); }}
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 transition-colors flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-full bg-indigo-100 flex items-center justify-center text-xs font-bold text-indigo-600 shrink-0">
|
||||
{c.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
|
||||
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={e => setCompany(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Entreprise (optionnel)"
|
||||
className="form-input text-sm py-2"
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addGuest()}
|
||||
disabled={!name.trim()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-indigo-600
|
||||
disabled:text-gray-300 hover:text-indigo-700 transition-colors py-1">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Ajouter l'invité
|
||||
</button>
|
||||
|
||||
{allContacts.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowPanel(true); setPanelSearch(''); }}
|
||||
className="ml-auto flex items-center gap-1.5 text-xs font-medium text-violet-600 hover:text-violet-800 transition-colors py-1 px-2 bg-violet-50 rounded-lg">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
Répertoire ({allContacts.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panneau de sélection multiple (overlay) */}
|
||||
{showPanel && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40"
|
||||
onClick={e => { if (e.target === e.currentTarget) setShowPanel(false); }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm flex flex-col max-h-[80vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<p className="font-semibold text-gray-900">Choisir des invités</p>
|
||||
<button type="button" onClick={() => setShowPanel(false)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 rounded-lg">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recherche */}
|
||||
<div className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={panelSearch}
|
||||
onChange={e => setPanelSearch(e.target.value)}
|
||||
placeholder="Rechercher…"
|
||||
className="form-input text-sm py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{panelFiltered.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">Aucun contact trouvé</p>
|
||||
) : (
|
||||
panelFiltered.map(c => {
|
||||
const checked = alreadyAdded.has(c.name.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => toggleFromPanel(c)}
|
||||
className={`w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors mb-0.5
|
||||
${checked ? 'bg-indigo-50' : 'hover:bg-gray-50'}`}>
|
||||
{/* Checkbox visuelle */}
|
||||
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors
|
||||
${checked ? 'bg-indigo-600 border-indigo-600' : 'border-gray-300'}`}>
|
||||
{checked && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
|
||||
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPanel(false)}
|
||||
className="btn-primary w-full py-2.5">
|
||||
Valider ({guests.length} invité{guests.length !== 1 ? 's' : ''})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GuestManager;
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Recadrage d'image avec react-image-crop.
|
||||
* Permet à l'utilisateur de sélectionner la zone utile de la facture.
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import ReactCrop, { type Crop, type PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
onConfirm: (croppedDataUrl: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function cropImageToDataUrl(
|
||||
image: HTMLImageElement,
|
||||
crop: PixelCrop,
|
||||
mimeType = 'image/jpeg'
|
||||
): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
|
||||
canvas.width = crop.width * scaleX;
|
||||
canvas.height = crop.height * scaleY;
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(
|
||||
image,
|
||||
crop.x * scaleX, crop.y * scaleY,
|
||||
crop.width * scaleX, crop.height * scaleY,
|
||||
0, 0,
|
||||
canvas.width, canvas.height
|
||||
);
|
||||
|
||||
return canvas.toDataURL(mimeType, 0.92);
|
||||
}
|
||||
|
||||
export default function ImageCropper({ src, onConfirm, onCancel }: Props) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
const [completed, setCompleted] = useState<PixelCrop>();
|
||||
|
||||
const onImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const { naturalWidth: w, naturalHeight: h } = e.currentTarget;
|
||||
// Crop initial centré — pas de ratio imposé (factures de toutes formes)
|
||||
const initial = centerCrop(
|
||||
makeAspectCrop({ unit: '%', width: 90 }, w / h, w, h),
|
||||
w, h
|
||||
);
|
||||
setCrop(initial);
|
||||
}, []);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!completed || !imgRef.current) return;
|
||||
const dataUrl = cropImageToDataUrl(imgRef.current, completed);
|
||||
onConfirm(dataUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-black/60">
|
||||
<button onClick={onCancel} className="text-white/70 hover:text-white p-2">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-white font-semibold text-sm">Recadrer la facture</span>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!completed}
|
||||
className="bg-indigo-600 disabled:opacity-40 text-white text-sm font-semibold px-4 py-2 rounded-xl"
|
||||
>
|
||||
Valider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cropper */}
|
||||
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(c) => setCrop(c)}
|
||||
onComplete={(c) => setCompleted(c)}
|
||||
minWidth={50}
|
||||
minHeight={50}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt="Facture"
|
||||
className="max-h-[70vh] max-w-full object-contain"
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-white/50 text-xs pb-4">
|
||||
Faites glisser les poignées pour ajuster le recadrage
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
||||
import { useOfflineQueue } from '../hooks/useOfflineQueue';
|
||||
import api from '../api/client';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// ─── Icônes ─────────────────────────────────────────────────
|
||||
|
||||
function IconPlus({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-6 h-6 ${active ? 'text-indigo-600' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconList({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-6 h-6 ${active ? 'text-indigo-600' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-6 h-6 ${active ? 'text-indigo-600' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layout principal ────────────────────────────────────────
|
||||
|
||||
export default function Layout() {
|
||||
const { user, refreshToken, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const isOnline = useOnlineStatus();
|
||||
const { count: queueCount, processing, processQueue } = useOfflineQueue();
|
||||
|
||||
// Auto-traitement de la file dès la reconnexion
|
||||
const wasOnline = useRef(isOnline);
|
||||
useEffect(() => {
|
||||
if (!wasOnline.current && isOnline) {
|
||||
// Petite temporisation pour laisser le réseau se stabiliser
|
||||
const timer = setTimeout(() => processQueue(), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
wasOnline.current = isOnline;
|
||||
}, [isOnline, processQueue]);
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await api.post('/auth/logout', { refreshToken });
|
||||
} catch {
|
||||
// Ignore silencieusement
|
||||
}
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex flex-col items-center gap-0.5 py-2 px-4 min-w-[64px] transition-colors ${
|
||||
isActive ? 'text-indigo-600' : 'text-gray-400'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<header className="sticky top-0 z-40 bg-white border-b border-gray-100 shadow-sm">
|
||||
<div className="max-w-2xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<span className="font-bold text-gray-900 text-lg">NotesFrais</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Traitement file en cours */}
|
||||
{processing && (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full font-medium">
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
Envoi…
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Indicateur hors ligne */}
|
||||
{!isOnline && (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 inline-block" />
|
||||
Hors ligne
|
||||
{queueCount > 0 && (
|
||||
<span className="ml-0.5 bg-amber-500 text-white rounded-full px-1 text-[10px] font-bold">
|
||||
{queueCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Nom utilisateur + déconnexion */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 font-medium">{user?.name}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
title="Se déconnecter"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Contenu principal ── */}
|
||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 py-6 pb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* ── Barre de navigation du bas ── */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-40 bg-white border-t border-gray-100 shadow-[0_-1px_3px_rgba(0,0,0,0.06)]"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||
<div className="max-w-2xl mx-auto flex justify-around items-center">
|
||||
|
||||
<NavLink to="/new" className={navLinkClass}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<IconPlus active={isActive} />
|
||||
<span className={`text-xs font-medium ${isActive ? 'text-indigo-600' : 'text-gray-400'}`}>
|
||||
Nouvelle
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink to="/invoices" className={navLinkClass}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<IconList active={isActive} />
|
||||
{queueCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-0.5 rounded-full bg-red-500
|
||||
text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{queueCount > 9 ? '9+' : queueCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${isActive ? 'text-indigo-600' : 'text-gray-400'}`}>
|
||||
Mes factures
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink to="/settings" className={navLinkClass}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<IconSettings active={isActive} />
|
||||
<span className={`text-xs font-medium ${isActive ? 'text-indigo-600' : 'text-gray-400'}`}>
|
||||
Paramètres
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Hook OCR avec Tesseract.js (100% local, compatible offline).
|
||||
*
|
||||
* Stratégie de parsing :
|
||||
* 1. Montant — cherche le plus grand nombre décimal de la page
|
||||
* 2. Date — cherche une date dans les formats courants (JJ/MM/AAAA, AAAA-MM-JJ, etc.)
|
||||
* 3. Fournisseur — première ligne significative du texte
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
export interface OcrResult {
|
||||
rawText: string;
|
||||
amount: string; // ex: "42.50"
|
||||
date: string; // ex: "2026-04-15" (ISO)
|
||||
supplier: string;
|
||||
}
|
||||
|
||||
interface OcrState {
|
||||
loading: boolean;
|
||||
progress: number; // 0-100
|
||||
result: OcrResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ─── Regex helpers ────────────────────────────────────────────
|
||||
|
||||
function extractAmount(text: string): string {
|
||||
// Cherche les montants : 1 234,56 / 1234.56 / 42,50 / €12.50
|
||||
const matches = text.match(/\b\d{1,6}[.,]\d{2}\b/g) ?? [];
|
||||
if (!matches.length) return '';
|
||||
|
||||
// Retourne le plus grand (probablement le total)
|
||||
const values = matches.map((m) => parseFloat(m.replace(',', '.')));
|
||||
const max = Math.max(...values);
|
||||
return max > 0 ? max.toFixed(2) : '';
|
||||
}
|
||||
|
||||
function extractDate(text: string): string {
|
||||
// Formats : DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD, DD.MM.YYYY
|
||||
const patterns = [
|
||||
/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})\b/,
|
||||
/\b(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})\b/,
|
||||
];
|
||||
|
||||
for (const pat of patterns) {
|
||||
const m = text.match(pat);
|
||||
if (!m) continue;
|
||||
|
||||
let year: number, month: number, day: number;
|
||||
|
||||
if (parseInt(m[1]) > 31) {
|
||||
// Format YYYY-MM-DD
|
||||
[year, month, day] = [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
|
||||
} else {
|
||||
// Format DD/MM/YYYY
|
||||
[day, month, year] = [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
|
||||
}
|
||||
|
||||
if (year < 2000 || year > 2100) continue;
|
||||
if (month < 1 || month > 12) continue;
|
||||
if (day < 1 || day > 31) continue;
|
||||
|
||||
return `${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
return new Date().toISOString().split('T')[0]; // fallback: aujourd'hui
|
||||
}
|
||||
|
||||
function extractSupplier(text: string): string {
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 3 && !/^\d/.test(l) && !/^[€$£]/.test(l));
|
||||
|
||||
return lines[0]?.substring(0, 60) ?? '';
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────
|
||||
|
||||
export function useOCR() {
|
||||
const [state, setState] = useState<OcrState>({
|
||||
loading: false,
|
||||
progress: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Ref pour garder le worker Tesseract en mémoire (évite de le recharger)
|
||||
const workerRef = useRef<any>(null);
|
||||
|
||||
const run = useCallback(async (imageDataUrl: string) => {
|
||||
setState({ loading: true, progress: 0, result: null, error: null });
|
||||
|
||||
try {
|
||||
// Chargement lazy de Tesseract.js
|
||||
const Tesseract = await import('tesseract.js');
|
||||
|
||||
if (!workerRef.current) {
|
||||
workerRef.current = await Tesseract.createWorker('fra+eng', 1, {
|
||||
logger: (m: any) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
setState((s) => ({ ...s, progress: Math.round(m.progress * 100) }));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { data } = await workerRef.current.recognize(imageDataUrl);
|
||||
const rawText = data.text;
|
||||
|
||||
const result: OcrResult = {
|
||||
rawText,
|
||||
amount: extractAmount(rawText),
|
||||
date: extractDate(rawText),
|
||||
supplier: extractSupplier(rawText),
|
||||
};
|
||||
|
||||
setState({ loading: false, progress: 100, result, error: null });
|
||||
} catch (err: any) {
|
||||
setState({ loading: false, progress: 0, result: null, error: err.message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ loading: false, progress: 0, result: null, error: null });
|
||||
}, []);
|
||||
|
||||
return { ...state, run, reset };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Hook — File d'attente hors-ligne
|
||||
*
|
||||
* Expose :
|
||||
* count — nombre d'envois en attente (pour le badge nav)
|
||||
* processing — vrai pendant le traitement de la file
|
||||
* addToQueue — ajoute un invoice_id à la file + toast
|
||||
* processQueue — rejoue tous les envois en attente
|
||||
* refresh — resynchronise le count depuis IndexedDB
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api/client';
|
||||
import {
|
||||
queueGetAll,
|
||||
queueAdd,
|
||||
queueRemove,
|
||||
queueBumpRetries,
|
||||
QUEUE_EVENT,
|
||||
} from '../utils/offlineQueue';
|
||||
|
||||
export function useOfflineQueue() {
|
||||
const [count, setCount] = useState(0);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// ── Synchronise le count depuis IndexedDB ─────────────────
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const items = await queueGetAll();
|
||||
setCount(items.length);
|
||||
} catch {
|
||||
// IndexedDB indisponible (ex: mode privé sur certains navigateurs)
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Charge au montage
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Écoute les changements émis par d'autres instances du hook
|
||||
useEffect(() => {
|
||||
window.addEventListener(QUEUE_EVENT, refresh);
|
||||
return () => window.removeEventListener(QUEUE_EVENT, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
// ── Ajoute à la file ──────────────────────────────────────
|
||||
|
||||
const addToQueue = useCallback(async (invoiceId: string) => {
|
||||
try {
|
||||
await queueAdd(invoiceId);
|
||||
toast('Facture créée — envoi mis en attente', {
|
||||
icon: '📶',
|
||||
duration: 5000,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Impossible de mettre en file d\'attente');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Traite la file ────────────────────────────────────────
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processing) return;
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = await queueGetAll();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await api.post(`/invoices/${item.invoiceId}/send`);
|
||||
await queueRemove(item.invoiceId);
|
||||
successCount++;
|
||||
} catch {
|
||||
await queueBumpRetries(item.invoiceId);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
await refresh();
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount} facture${successCount > 1 ? 's' : ''} envoyée${successCount > 1 ? 's' : ''} depuis la file d'attente`
|
||||
);
|
||||
}
|
||||
if (failCount > 0) {
|
||||
toast.error(`${failCount} envoi${failCount > 1 ? 's' : ''} toujours en échec — nouvelle tentative à la prochaine connexion`);
|
||||
}
|
||||
}, [processing, refresh]);
|
||||
|
||||
return { count, processing, addToQueue, processQueue, refresh };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook qui retourne le statut de connexion réseau en temps réel.
|
||||
* Utilisé pour l'indicateur visuel hors ligne et la gestion de la queue offline.
|
||||
*/
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Import Inter depuis Google Fonts (fallback: system-ui) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans text-gray-900;
|
||||
}
|
||||
|
||||
/* Prevent scroll bounce on iOS but allow scrolling in containers */
|
||||
#root {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Bouton primaire réutilisable */
|
||||
.btn-primary {
|
||||
@apply flex items-center justify-center gap-2 w-full py-3 px-4
|
||||
bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white font-semibold rounded-xl transition-colors
|
||||
text-base select-none;
|
||||
}
|
||||
|
||||
/* Bouton secondaire */
|
||||
.btn-secondary {
|
||||
@apply flex items-center justify-center gap-2 w-full py-3 px-4
|
||||
bg-gray-100 hover:bg-gray-200 active:bg-gray-300
|
||||
text-gray-700 font-semibold rounded-xl transition-colors
|
||||
text-base select-none;
|
||||
}
|
||||
|
||||
/* Champ de formulaire */
|
||||
.form-input {
|
||||
@apply w-full px-4 py-3 rounded-xl border border-gray-200
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent
|
||||
text-gray-900 placeholder-gray-400 bg-white transition text-base;
|
||||
}
|
||||
|
||||
/* Label de formulaire */
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
@apply bg-white rounded-2xl border border-gray-100 shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loader spinner */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Page détail d'une facture.
|
||||
* Accessible via /invoices/:id
|
||||
* Affiche : images du ticket, invités, métadonnées, statut, PDF, email.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api/client';
|
||||
import type { Invoice, InvoiceStatus } from '../types';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function fmt(iso: string | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = iso.split('T')[0];
|
||||
const [y, m, day] = d.split('-');
|
||||
return `${day}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function fmtAmount(n: number): string {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n) + ' €';
|
||||
}
|
||||
|
||||
// ─── Composant image authentifiée ─────────────────────────────
|
||||
|
||||
function AuthImage({ filename, alt }: { filename: string; alt?: string }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
api
|
||||
.get(`/uploads/images/${encodeURIComponent(filename)}`, { responseType: 'blob' })
|
||||
.then((res) => {
|
||||
objectUrl = URL.createObjectURL(res.data);
|
||||
setSrc(objectUrl);
|
||||
})
|
||||
.catch(() => setSrc('error'));
|
||||
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [filename]);
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="w-full aspect-[3/4] bg-gray-100 rounded-xl flex items-center justify-center animate-pulse">
|
||||
<svg className="w-8 h-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (src === 'error') {
|
||||
return (
|
||||
<div className="w-full aspect-[3/4] bg-gray-100 rounded-xl flex flex-col items-center justify-center gap-2 text-gray-400">
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-xs">Image indisponible</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? 'Ticket'}
|
||||
className="w-full rounded-xl object-contain bg-gray-50 border border-gray-100"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [sending, setSending] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState(false);
|
||||
|
||||
// ── Fetch de la facture ──────────────────────────────────────
|
||||
const { data: invoice, isLoading, isError } = useQuery<Invoice>({
|
||||
queryKey: ['invoice', id],
|
||||
queryFn: () => api.get(`/invoices/${id}`).then((r) => r.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// ── Mutation statut ──────────────────────────────────────────
|
||||
const statusMut = useMutation({
|
||||
mutationFn: (status: InvoiceStatus) =>
|
||||
api.patch(`/invoices/${id}/status`, { status }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoice', id] });
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['invoices-summary'] });
|
||||
setTogglingId(false);
|
||||
toast.success('Statut mis à jour');
|
||||
},
|
||||
onError: () => {
|
||||
setTogglingId(false);
|
||||
toast.error('Erreur lors du changement de statut');
|
||||
},
|
||||
});
|
||||
|
||||
// ── Télécharger PDF ──────────────────────────────────────────
|
||||
async function handleDownloadPDF() {
|
||||
if (!invoice) return;
|
||||
try {
|
||||
const res = await api.get(`/invoices/${id}/pdf`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: url,
|
||||
download: invoice.pdf_filename ?? `facture-${id}.pdf`,
|
||||
});
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
} catch {
|
||||
toast.error('Erreur lors du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Renvoyer email ───────────────────────────────────────────
|
||||
async function handleResendEmail() {
|
||||
if (!invoice) return;
|
||||
setSending(true);
|
||||
const toastId = toast.loading('Envoi en cours…');
|
||||
try {
|
||||
await api.post(`/invoices/${id}/send`);
|
||||
qc.invalidateQueries({ queryKey: ['invoice', id] });
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
toast.success('Email envoyé !', { id: toastId });
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error ?? 'Erreur lors de l\'envoi', { id: toastId });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle statut ────────────────────────────────────────────
|
||||
function handleToggleStatus() {
|
||||
if (!invoice) return;
|
||||
const next: InvoiceStatus = invoice.status === 'pending' ? 'reimbursed' : 'pending';
|
||||
setTogglingId(true);
|
||||
statusMut.mutate(next);
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="py-20 flex flex-col items-center gap-3 text-gray-400">
|
||||
<svg className="w-6 h-6 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Chargement…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !invoice) {
|
||||
return (
|
||||
<div className="py-20 flex flex-col items-center gap-4 text-gray-400">
|
||||
<svg className="w-12 h-12 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm">Facture introuvable</p>
|
||||
<button onClick={() => navigate('/invoices')} className="btn-secondary text-sm">
|
||||
Retour à la liste
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isReimbursed = invoice.status === 'reimbursed';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-8">
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/invoices')}
|
||||
className="p-2 rounded-xl text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-base font-semibold text-gray-900 truncate">
|
||||
{invoice.supplier || invoice.company_name}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">{fmtAmount(invoice.amount)} · {fmt(invoice.invoice_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Image(s) du ticket ── */}
|
||||
{invoice.images && invoice.images.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{[...invoice.images]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((img, i) => (
|
||||
<AuthImage key={i} filename={img.path} alt={`Ticket ${i + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Métadonnées ── */}
|
||||
<div className="card space-y-3">
|
||||
|
||||
{/* Société + Catégorie */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Société</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{invoice.company_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Catégorie</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{invoice.category_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fournisseur + Date */}
|
||||
{(invoice.supplier || invoice.invoice_date) && (
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-gray-50 pt-3">
|
||||
{invoice.supplier && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Fournisseur</p>
|
||||
<p className="text-sm text-gray-700">{invoice.supplier}</p>
|
||||
</div>
|
||||
)}
|
||||
{invoice.invoice_date && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Date</p>
|
||||
<p className="text-sm text-gray-700">{fmt(invoice.invoice_date)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Montant */}
|
||||
<div className="border-t border-gray-50 pt-3 flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">Montant</p>
|
||||
<p className="text-lg font-bold text-gray-900">{fmtAmount(invoice.amount)}</p>
|
||||
</div>
|
||||
|
||||
{/* Commentaire */}
|
||||
{invoice.comment && (
|
||||
<div className="border-t border-gray-50 pt-3">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">Commentaire</p>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Statut ── */}
|
||||
<div className="card flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Statut</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
{isReimbursed ? 'Remboursé' : 'En attente de remboursement'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleStatus}
|
||||
disabled={togglingId}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold
|
||||
transition-all disabled:opacity-50
|
||||
${isReimbursed
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-amber-100 text-amber-700 hover:bg-amber-200'}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${isReimbursed ? 'bg-green-500' : 'bg-amber-500'}`} />
|
||||
{isReimbursed ? 'Remboursé' : 'En attente'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Invités ── */}
|
||||
{invoice.guests && invoice.guests.length > 0 && (
|
||||
<div className="card">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-3">
|
||||
Invités ({invoice.guests.length})
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{invoice.guests.map((g, i) => (
|
||||
<li key={i} className="flex items-center gap-3 bg-indigo-50 rounded-xl px-3 py-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-200 flex items-center justify-center
|
||||
text-xs font-bold text-indigo-700 flex-shrink-0">
|
||||
{g.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
|
||||
{g.company && <p className="text-xs text-gray-500 truncate">{g.company}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="space-y-2">
|
||||
|
||||
{/* Télécharger PDF */}
|
||||
{invoice.pdf_path && (
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
className="w-full flex items-center justify-center gap-2 bg-indigo-600 text-white
|
||||
font-semibold py-3 px-4 rounded-xl hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 13l2 2 2-2m-2 2V9" />
|
||||
</svg>
|
||||
Télécharger le PDF
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Renvoyer l'email */}
|
||||
<button
|
||||
onClick={handleResendEmail}
|
||||
disabled={sending}
|
||||
className="w-full flex items-center justify-center gap-2 bg-white border border-gray-200
|
||||
text-gray-700 font-semibold py-3 px-4 rounded-xl
|
||||
hover:border-indigo-300 hover:text-indigo-600 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{sending ? 'Envoi en cours…' : invoice.email_sent ? 'Renvoyer l\'email' : 'Envoyer l\'email'}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken);
|
||||
navigate('/new', { replace: true });
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error ?? 'Connexion impossible');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-white flex flex-col items-center justify-center px-4">
|
||||
|
||||
{/* Logo / titre */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-indigo-600 shadow-lg mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">NotesFrais</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Gestion de notes de frais</p>
|
||||
</div>
|
||||
|
||||
{/* Formulaire */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm bg-white rounded-2xl shadow-sm border border-gray-100 p-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="votre@email.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white font-semibold rounded-xl transition-colors flex items-center justify-center gap-2 text-base"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
Connexion…
|
||||
</>
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* Page "Mes factures" — Étape 9
|
||||
* Listing filtrable et triable, récapitulatif, export CSV.
|
||||
*/
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api/client';
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceFilters,
|
||||
InvoiceSummaryItem,
|
||||
InvoiceStatus,
|
||||
Company,
|
||||
Category,
|
||||
} from '../types';
|
||||
|
||||
// ─── API helpers ──────────────────────────────────────────────
|
||||
|
||||
function buildParams(filters: InvoiceFilters): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
filters.company_ids?.forEach((id) => p.append('company_ids[]', String(id)));
|
||||
filters.category_ids?.forEach((id) => p.append('category_ids[]', String(id)));
|
||||
if (filters.status) p.set('status', filters.status);
|
||||
if (filters.date_from) p.set('date_from', filters.date_from);
|
||||
if (filters.date_to) p.set('date_to', filters.date_to);
|
||||
if (filters.search) p.set('search', filters.search);
|
||||
if (filters.sort_by) p.set('sort_by', filters.sort_by);
|
||||
if (filters.sort_dir) p.set('sort_dir', filters.sort_dir);
|
||||
if (filters.page != null) p.set('page', String(filters.page));
|
||||
if (filters.limit != null) p.set('limit', String(filters.limit));
|
||||
return p;
|
||||
}
|
||||
|
||||
async function fetchInvoices(filters: InvoiceFilters) {
|
||||
const res = await api.get(`/invoices?${buildParams(filters)}`);
|
||||
return res.data as { data: Invoice[]; total: number; page: number; limit: number };
|
||||
}
|
||||
|
||||
async function fetchSummary() {
|
||||
const res = await api.get('/invoices/summary');
|
||||
return res.data as InvoiceSummaryItem[];
|
||||
}
|
||||
|
||||
async function fetchCompanies() {
|
||||
const res = await api.get('/companies');
|
||||
return res.data as Company[];
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
const res = await api.get('/categories');
|
||||
return res.data as Category[];
|
||||
}
|
||||
|
||||
// ─── Format helpers ───────────────────────────────────────────
|
||||
|
||||
function fmt(iso: string | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = iso.split('T')[0];
|
||||
const [y, m, day] = d.split('-');
|
||||
return `${day}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function fmtAmount(n: number): string {
|
||||
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n) + ' €';
|
||||
}
|
||||
|
||||
// ─── SortHeader ───────────────────────────────────────────────
|
||||
|
||||
interface SortHeaderProps {
|
||||
label: string;
|
||||
field: string;
|
||||
current: string;
|
||||
dir: 'asc' | 'desc';
|
||||
onSort: (field: string) => void;
|
||||
right?: boolean;
|
||||
}
|
||||
|
||||
function SortHeader({ label, field, current, dir, onSort, right }: SortHeaderProps) {
|
||||
const active = current === field;
|
||||
return (
|
||||
<th
|
||||
onClick={() => onSort(field)}
|
||||
className={`px-3 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide cursor-pointer select-none
|
||||
whitespace-nowrap hover:text-indigo-600 transition-colors ${right ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
<span className={`inline-flex items-center gap-1 ${right ? 'flex-row-reverse' : ''}`}>
|
||||
{label}
|
||||
{active ? (
|
||||
<svg className="w-3 h-3 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
{dir === 'asc'
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />}
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── StatusBadge ─────────────────────────────────────────────
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: InvoiceStatus;
|
||||
invoiceId: string;
|
||||
onToggle: (id: string, next: InvoiceStatus) => void;
|
||||
toggling: boolean;
|
||||
}
|
||||
|
||||
function StatusBadge({ status, invoiceId, onToggle, toggling }: StatusBadgeProps) {
|
||||
const next: InvoiceStatus = status === 'pending' ? 'reimbursed' : 'pending';
|
||||
const label = status === 'reimbursed' ? 'Remboursé' : 'En attente';
|
||||
const colors = status === 'reimbursed'
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-amber-100 text-amber-700 hover:bg-amber-200';
|
||||
const dotColor = status === 'reimbursed' ? 'bg-green-500' : 'bg-amber-500';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(invoiceId, next)}
|
||||
disabled={toggling}
|
||||
title={`Marquer comme "${next === 'reimbursed' ? 'Remboursé' : 'En attente'}"`}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold
|
||||
transition-all ${colors} ${toggling ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── DeleteModal ──────────────────────────────────────────────
|
||||
|
||||
interface DeleteModalProps {
|
||||
invoice: Invoice | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function DeleteModal({ invoice, onConfirm, onCancel, loading }: DeleteModalProps) {
|
||||
if (!invoice) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl p-6 w-full sm:max-w-sm mx-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900">Supprimer la facture</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{invoice.supplier || invoice.company_name} — {fmtAmount(invoice.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Cette action est irréversible. Le PDF et les images associés seront également supprimés.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onCancel} disabled={loading} className="flex-1 btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-red-600 text-white font-semibold py-2.5 px-4 rounded-xl
|
||||
hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Suppression…' : 'Supprimer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── MultiSelect ──────────────────────────────────────────────
|
||||
|
||||
interface MultiSelectOption { id: number; name: string }
|
||||
|
||||
interface MultiSelectProps {
|
||||
label: string;
|
||||
options: MultiSelectOption[];
|
||||
selected: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
function MultiSelect({ label, options, selected, onChange }: MultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function toggle(id: number) {
|
||||
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
|
||||
}
|
||||
|
||||
const display =
|
||||
selected.length === 0
|
||||
? label
|
||||
: selected.length === 1
|
||||
? (options.find((o) => o.id === selected[0])?.name ?? label)
|
||||
: `${selected.length} sélectionnés`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`form-input text-sm py-2 flex items-center justify-between gap-2 cursor-pointer
|
||||
${selected.length > 0 ? 'text-indigo-700 bg-indigo-50 border-indigo-300' : 'text-gray-500'}`}
|
||||
>
|
||||
<span className="truncate">{display}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute z-20 mt-1 w-full bg-white rounded-xl shadow-lg border border-gray-100 py-1 max-h-48 overflow-y-auto">
|
||||
{options.length === 0 && (
|
||||
<p className="px-3 py-2 text-sm text-gray-400">Aucune option</p>
|
||||
)}
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => toggle(opt.id)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-left hover:bg-indigo-50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors
|
||||
${selected.includes(opt.id) ? 'bg-indigo-600 border-indigo-600' : 'border-gray-300'}`}
|
||||
>
|
||||
{selected.includes(opt.id) && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-700">{opt.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function MyInvoices() {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Filter state ──
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [companyIds, setCompanyIds] = useState<number[]>([]);
|
||||
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | ''>('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [sortBy, setSortBy] = useState('sent_at');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
// ── Modal state ──
|
||||
const [deleteTarget, setDeleteTarget] = useState<Invoice | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
// ── Debounce search ──
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
function handleSearchChange(val: string) {
|
||||
setSearch(val);
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setDebouncedSearch(val);
|
||||
setPage(1);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// ── Filters object ──
|
||||
const filters: InvoiceFilters = {
|
||||
company_ids: companyIds.length ? companyIds : undefined,
|
||||
category_ids: categoryIds.length ? categoryIds : undefined,
|
||||
status: statusFilter || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir,
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
companyIds.length > 0 || categoryIds.length > 0 || !!statusFilter ||
|
||||
!!dateFrom || !!dateTo || !!debouncedSearch;
|
||||
|
||||
// ── Queries ──
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: ['invoices', filters],
|
||||
queryFn: () => fetchInvoices(filters),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const { data: summary = [] } = useQuery({
|
||||
queryKey: ['invoices-summary'],
|
||||
queryFn: fetchSummary,
|
||||
});
|
||||
|
||||
const { data: companies = [] } = useQuery({
|
||||
queryKey: ['companies'],
|
||||
queryFn: fetchCompanies,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: fetchCategories,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// ── Mutations ──
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/invoices/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['invoices-summary'] });
|
||||
setDeleteTarget(null);
|
||||
toast.success('Facture supprimée');
|
||||
},
|
||||
onError: () => toast.error('Erreur lors de la suppression'),
|
||||
});
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: InvoiceStatus }) =>
|
||||
api.patch(`/invoices/${id}/status`, { status }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['invoices-summary'] });
|
||||
setTogglingId(null);
|
||||
toast.success('Statut mis à jour');
|
||||
},
|
||||
onError: () => {
|
||||
setTogglingId(null);
|
||||
toast.error('Erreur lors du changement de statut');
|
||||
},
|
||||
});
|
||||
|
||||
// ── Handlers ──
|
||||
function handleSort(field: string) {
|
||||
if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else { setSortBy(field); setSortDir('desc'); }
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function handleToggleStatus(id: string, next: InvoiceStatus) {
|
||||
setTogglingId(id);
|
||||
statusMut.mutate({ id, status: next });
|
||||
}
|
||||
|
||||
async function handleDownloadPDF(inv: Invoice) {
|
||||
try {
|
||||
const res = await api.get(`/invoices/${inv.id}/pdf`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: url,
|
||||
download: inv.pdf_filename ?? `facture-${inv.id}.pdf`,
|
||||
});
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
} catch {
|
||||
toast.error('Erreur lors du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportCSV() {
|
||||
try {
|
||||
const params = buildParams({ ...filters, page: undefined, limit: undefined });
|
||||
const res = await api.get(`/invoices/export/csv?${params}`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'text/csv;charset=utf-8;' }));
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: url,
|
||||
download: `factures-${new Date().toISOString().split('T')[0]}.csv`,
|
||||
});
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
} catch {
|
||||
toast.error("Erreur lors de l'export CSV");
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setCompanyIds([]); setCategoryIds([]); setStatusFilter('');
|
||||
setDateFrom(''); setDateTo('');
|
||||
setSearch(''); setDebouncedSearch('');
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
// ── Summary pivot ──
|
||||
const summaryRows = useMemo(() => {
|
||||
const map = new Map<string, { pending: number; reimbursed: number }>();
|
||||
for (const item of summary) {
|
||||
if (!map.has(item.company_name)) map.set(item.company_name, { pending: 0, reimbursed: 0 });
|
||||
const row = map.get(item.company_name)!;
|
||||
const v = parseFloat(item.total) || 0;
|
||||
if (item.status === 'pending') row.pending += v;
|
||||
else row.reimbursed += v;
|
||||
}
|
||||
return Array.from(map.entries()).map(([name, v]) => ({ name, ...v, total: v.pending + v.reimbursed }));
|
||||
}, [summary]);
|
||||
|
||||
const grandPending = summaryRows.reduce((s, r) => s + r.pending, 0);
|
||||
const grandReimbursed = summaryRows.reduce((s, r) => s + r.reimbursed, 0);
|
||||
const grandTotal = grandPending + grandReimbursed;
|
||||
|
||||
const invoices = listData?.data ?? [];
|
||||
const total = listData?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
|
||||
|
||||
// Compact page buttons (up to 5 around current)
|
||||
const pageButtons = useMemo(() => {
|
||||
if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
if (page <= 3) return [1, 2, 3, 4, 5];
|
||||
if (page >= totalPages - 2) return [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
return [page - 2, page - 1, page, page + 1, page + 2];
|
||||
}, [page, totalPages]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-4 pb-8">
|
||||
|
||||
{/* ── Récapitulatif ── */}
|
||||
{summaryRows.length > 0 && (
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Récapitulatif</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Société</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-semibold text-amber-600 uppercase tracking-wide">En attente</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-semibold text-green-600 uppercase tracking-wide">Remboursé</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-semibold text-gray-600 uppercase tracking-wide">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{summaryRows.map((row) => (
|
||||
<tr key={row.name} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-800">{row.name}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-amber-700">
|
||||
{row.pending > 0 ? fmtAmount(row.pending) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-green-700">
|
||||
{row.reimbursed > 0 ? fmtAmount(row.reimbursed) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900">
|
||||
{fmtAmount(row.total)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* Grand total row */}
|
||||
<tr className="bg-gray-50 border-t border-gray-200">
|
||||
<td className="px-4 py-2.5 text-xs font-bold text-gray-500 uppercase tracking-wide">Total</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-sm font-bold text-amber-700">{fmtAmount(grandPending)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-sm font-bold text-green-700">{fmtAmount(grandReimbursed)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-sm font-bold text-gray-900">{fmtAmount(grandTotal)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Barre de filtres ── */}
|
||||
<div className="card space-y-3">
|
||||
|
||||
{/* Ligne principale : recherche + bouton filtres + export CSV */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Rechercher…"
|
||||
className="form-input pl-9 text-sm py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle filtres avancés */}
|
||||
<button
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
title="Filtres avancés"
|
||||
className={`p-2.5 rounded-xl border transition-colors
|
||||
${hasActiveFilters
|
||||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||||
: 'border-gray-200 text-gray-500 hover:border-indigo-300 hover:text-indigo-600'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707v4.586a1 1 0 01-.553.894l-4 2A1 1 0 019 21v-7.586a1 1 0 00-.293-.707L2.293 6.707A1 1 0 012 6V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Export CSV */}
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
title="Exporter en CSV"
|
||||
className="p-2.5 rounded-xl border border-gray-200 text-gray-500
|
||||
hover:border-indigo-300 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filtres avancés dépliables */}
|
||||
{filtersOpen && (
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<MultiSelect
|
||||
label="Sociétés"
|
||||
options={companies.filter((c) => c.is_active).map((c) => ({ id: c.id, name: c.name }))}
|
||||
selected={companyIds}
|
||||
onChange={(ids) => { setCompanyIds(ids); setPage(1); }}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Catégories"
|
||||
options={categories.filter((c) => c.is_active).map((c) => ({ id: c.id, name: c.name }))}
|
||||
selected={categoryIds}
|
||||
onChange={(ids) => { setCategoryIds(ids); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value as InvoiceStatus | ''); setPage(1); }}
|
||||
className="form-input text-sm py-2"
|
||||
>
|
||||
<option value="">Tous les statuts</option>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="reimbursed">Remboursé</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
|
||||
className="form-input text-sm py-2"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
|
||||
className="form-input text-sm py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="text-xs font-medium text-indigo-600 hover:text-indigo-800 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Tableau ── */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
|
||||
{/* En-tête du tableau : compteur + pagination info */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{isLoading
|
||||
? 'Chargement…'
|
||||
: `${total} facture${total !== 1 ? 's' : ''}${hasActiveFilters ? ' (filtrée' + (total !== 1 ? 's' : '') + ')' : ''}`}
|
||||
</p>
|
||||
{totalPages > 1 && (
|
||||
<p className="text-xs text-gray-400">Page {page} / {totalPages}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* État chargement */}
|
||||
{isLoading ? (
|
||||
<div className="py-14 flex flex-col items-center gap-3 text-gray-400">
|
||||
<svg className="w-6 h-6 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Chargement des factures…</span>
|
||||
</div>
|
||||
|
||||
) : invoices.length === 0 ? (
|
||||
/* État vide */
|
||||
<div className="py-14 flex flex-col items-center gap-3 text-gray-400">
|
||||
<svg className="w-12 h-12 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-sm">Aucune facture trouvée</p>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
) : (
|
||||
/* Tableau des factures */
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-100">
|
||||
<SortHeader label="Date envoi" field="sent_at" current={sortBy} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Société" field="company_name" current={sortBy} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Catégorie" field="category_name" current={sortBy} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Fournisseur" field="supplier" current={sortBy} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Montant" field="amount" current={sortBy} dir={sortDir} onSort={handleSort} right />
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="hover:bg-gray-50/60 transition-colors">
|
||||
|
||||
{/* Date d'envoi */}
|
||||
<td className="px-3 py-3 text-gray-500 whitespace-nowrap">
|
||||
{fmt(inv.sent_at)}
|
||||
</td>
|
||||
|
||||
{/* Société */}
|
||||
<td className="px-3 py-3 font-medium text-gray-800 whitespace-nowrap">
|
||||
{inv.company_name}
|
||||
</td>
|
||||
|
||||
{/* Catégorie */}
|
||||
<td className="px-3 py-3 text-gray-500 whitespace-nowrap">
|
||||
{inv.category_name}
|
||||
</td>
|
||||
|
||||
{/* Fournisseur */}
|
||||
<td className="px-3 py-3 text-gray-600 max-w-[140px]">
|
||||
<span className="block truncate">{inv.supplier || '—'}</span>
|
||||
</td>
|
||||
|
||||
{/* Montant */}
|
||||
<td className="px-3 py-3 text-right font-semibold text-gray-900 tabular-nums whitespace-nowrap">
|
||||
{fmtAmount(inv.amount)}
|
||||
</td>
|
||||
|
||||
{/* Statut */}
|
||||
<td className="px-3 py-3 whitespace-nowrap">
|
||||
<StatusBadge
|
||||
status={inv.status}
|
||||
invoiceId={inv.id}
|
||||
onToggle={handleToggleStatus}
|
||||
toggling={togglingId === inv.id}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
|
||||
{/* Voir le détail */}
|
||||
<button
|
||||
onClick={() => navigate(`/invoices/${inv.id}`)}
|
||||
title="Voir le détail"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Télécharger PDF */}
|
||||
{inv.pdf_path && (
|
||||
<button
|
||||
onClick={() => handleDownloadPDF(inv)}
|
||||
title="Télécharger le PDF"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 13l2 2 2-2m-2 2V9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Supprimer */}
|
||||
<button
|
||||
onClick={() => setDeleteTarget(inv)}
|
||||
title="Supprimer"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && totalPages > 1 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-600
|
||||
disabled:text-gray-300 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Précédent
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{pageButtons.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-8 h-8 rounded-lg text-sm font-medium transition-colors
|
||||
${page === p
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-600
|
||||
disabled:text-gray-300 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
Suivant
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Modal de confirmation suppression ── */}
|
||||
<DeleteModal
|
||||
invoice={deleteTarget}
|
||||
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
loading={deleteMut.isPending}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Page "Nouvelle facture"
|
||||
* Flux complet : capture → recadrage → OCR → formulaire → invités → envoi
|
||||
*/
|
||||
import { useState, lazy, Suspense, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api/client';
|
||||
import Camera from '../components/Camera';
|
||||
import GuestManager, { type GuestManagerHandle } from '../components/GuestManager';
|
||||
import { useOCR } from '../hooks/useOCR';
|
||||
import { useOfflineQueue } from '../hooks/useOfflineQueue';
|
||||
import type { Company, Category, Guest, InvoiceImage } from '../types';
|
||||
|
||||
// Chargement lazy du cropper (lourd, uniquement quand nécessaire)
|
||||
const ImageCropper = lazy(() => import('../components/ImageCropper'));
|
||||
|
||||
// ─── Types internes ───────────────────────────────────────────
|
||||
|
||||
type Step = 'capture' | 'crop' | 'form';
|
||||
|
||||
interface CapturedImage {
|
||||
dataUrl: string;
|
||||
filename: string; // renvoyé par le backend après upload
|
||||
order: number;
|
||||
}
|
||||
|
||||
// ─── Composant ────────────────────────────────────────────────
|
||||
|
||||
export default function NewInvoice() {
|
||||
const qc = useQueryClient();
|
||||
const { addToQueue } = useOfflineQueue();
|
||||
|
||||
// Ref pour retenir l'invoice_id créé si le send échoue hors ligne
|
||||
const createdInvoiceIdRef = useRef<string | null>(null);
|
||||
|
||||
// Ref vers GuestManager pour flusher l'invité en cours de saisie
|
||||
const guestManagerRef = useRef<GuestManagerHandle>(null);
|
||||
|
||||
// ── Étape du flux ──────────────────────────────────────────
|
||||
const [step, setStep] = useState<Step>('capture');
|
||||
|
||||
// ── Images capturées ───────────────────────────────────────
|
||||
const [pendingDataUrl, setPendingDataUrl] = useState<string>('');
|
||||
const [pendingMime, setPendingMime] = useState<string>('image/jpeg');
|
||||
const [images, setImages] = useState<CapturedImage[]>([]);
|
||||
|
||||
// ── OCR ────────────────────────────────────────────────────
|
||||
const ocr = useOCR();
|
||||
|
||||
// ── Champs formulaire ──────────────────────────────────────
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const [companyId, setCompanyId] = useState('');
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [supplier, setSupplier] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [invoiceDate, setInvoiceDate] = useState(today);
|
||||
const [comment, setComment] = useState('');
|
||||
const [addTracking, setAddTracking] = useState(true);
|
||||
const [guests, setGuests] = useState<Guest[]>([]);
|
||||
const [showGuests, setShowGuests] = useState(false);
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────
|
||||
const { data: companies = [] } = useQuery<Company[]>({
|
||||
queryKey: ['companies'],
|
||||
queryFn: () => api.get('/companies').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: categories = [] } = useQuery<Category[]>({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => api.get('/categories').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Auto-ouvrir la section invités quand la catégorie est "Restaurant"
|
||||
useEffect(() => {
|
||||
const selected = categories.find((c) => c.id === parseInt(categoryId));
|
||||
if (selected?.name?.toLowerCase().includes('restaurant')) {
|
||||
setShowGuests(true);
|
||||
}
|
||||
}, [categoryId, categories]);
|
||||
|
||||
// ── Mutation upload image ───────────────────────────────────
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (dataUrl: string) => {
|
||||
const blob = await (await fetch(dataUrl)).blob();
|
||||
const form = new FormData();
|
||||
form.append('image', blob, 'facture.jpg');
|
||||
const res = await api.post('/invoices/upload-image', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return res.data.filename as string;
|
||||
},
|
||||
});
|
||||
|
||||
// ── Mutation créer + envoyer facture ───────────────────────
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!companyId || !categoryId || !amount || !invoiceDate) {
|
||||
throw new Error('Veuillez remplir tous les champs obligatoires');
|
||||
}
|
||||
if (images.length === 0) {
|
||||
throw new Error('Aucune image attachée');
|
||||
}
|
||||
|
||||
createdInvoiceIdRef.current = null;
|
||||
|
||||
// Auto-ajouter l'invité en cours de saisie s'il n'a pas encore été
|
||||
// validé via "Ajouter l'invité" (cas fréquent : l'utilisateur tape
|
||||
// le nom et clique directement sur "Envoyer").
|
||||
// flushPending() retourne la liste complète si un guest a été flushé,
|
||||
// null sinon — on utilise cette valeur pour éviter un souci de timing
|
||||
// avec la mise à jour asynchrone du state React.
|
||||
const guestsFlushed = guestManagerRef.current?.flushPending() ?? null;
|
||||
const finalGuests = guestsFlushed ?? guests;
|
||||
|
||||
const invoiceImages: InvoiceImage[] = images.map((img) => ({
|
||||
path: img.filename,
|
||||
order: img.order,
|
||||
}));
|
||||
|
||||
// Étape 1 : créer la facture
|
||||
const createRes = await api.post('/invoices', {
|
||||
company_id: parseInt(companyId),
|
||||
category_id: parseInt(categoryId),
|
||||
supplier: supplier || undefined,
|
||||
amount: parseFloat(amount),
|
||||
invoice_date: invoiceDate,
|
||||
comment: comment || undefined,
|
||||
images: invoiceImages,
|
||||
add_to_tracking: addTracking,
|
||||
guests: finalGuests,
|
||||
});
|
||||
|
||||
// Mémoriser l'id en cas d'échec du send hors ligne
|
||||
createdInvoiceIdRef.current = createRes.data.id;
|
||||
|
||||
// Étape 2 : envoyer (génère PDF + email + Excel)
|
||||
const sendRes = await api.post(`/invoices/${createRes.data.id}/send`);
|
||||
return sendRes.data;
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
toast.success('Facture envoyée avec succès !');
|
||||
if (data?.tracking_error) {
|
||||
toast.error(`⚠️ Suivi SharePoint : ${data.tracking_error}`, { duration: 10000 });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
createdInvoiceIdRef.current = null;
|
||||
resetForm();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const invoiceId = createdInvoiceIdRef.current;
|
||||
|
||||
// Facture créée mais envoi échoué hors ligne → mise en file d'attente
|
||||
if (invoiceId && !navigator.onLine) {
|
||||
addToQueue(invoiceId);
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
createdInvoiceIdRef.current = null;
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(err.response?.data?.error ?? err.message ?? "Erreur lors de l'envoi");
|
||||
},
|
||||
});
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────
|
||||
|
||||
function handleCapture(dataUrl: string, mime: string) {
|
||||
setPendingDataUrl(dataUrl);
|
||||
setPendingMime(mime);
|
||||
setStep('crop');
|
||||
}
|
||||
|
||||
async function handleCropConfirm(croppedDataUrl: string) {
|
||||
setStep('form');
|
||||
|
||||
// Upload de l'image
|
||||
const toastId = toast.loading('Envoi de l\'image…');
|
||||
try {
|
||||
const filename = await uploadMutation.mutateAsync(croppedDataUrl);
|
||||
const newImage: CapturedImage = {
|
||||
dataUrl: croppedDataUrl,
|
||||
filename,
|
||||
order: images.length,
|
||||
};
|
||||
setImages((prev) => [...prev, newImage]);
|
||||
toast.success('Image ajoutée', { id: toastId });
|
||||
|
||||
// Lancer l'OCR sur la première image uniquement
|
||||
if (images.length === 0) {
|
||||
ocr.run(croppedDataUrl);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Erreur lors de l\'upload', { id: toastId });
|
||||
}
|
||||
}
|
||||
|
||||
// Quand l'OCR se termine, pré-remplir les champs si vides
|
||||
if (ocr.result && !amount && ocr.result.amount) {
|
||||
setAmount(ocr.result.amount);
|
||||
}
|
||||
if (ocr.result && invoiceDate === today && ocr.result.date !== today) {
|
||||
setInvoiceDate(ocr.result.date);
|
||||
}
|
||||
if (ocr.result && !supplier && ocr.result.supplier) {
|
||||
setSupplier(ocr.result.supplier);
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setStep('capture');
|
||||
setImages([]);
|
||||
setCompanyId('');
|
||||
setCategoryId('');
|
||||
setSupplier('');
|
||||
setAmount('');
|
||||
setInvoiceDate(today);
|
||||
setComment('');
|
||||
setAddTracking(true);
|
||||
setGuests([]);
|
||||
setShowGuests(false);
|
||||
ocr.reset();
|
||||
}
|
||||
|
||||
// ── Rendu ──────────────────────────────────────────────────
|
||||
|
||||
// Écran de recadrage (plein écran)
|
||||
if (step === 'crop' && pendingDataUrl) {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-black" />}>
|
||||
<ImageCropper
|
||||
src={pendingDataUrl}
|
||||
onConfirm={handleCropConfirm}
|
||||
onCancel={() => { setStep(images.length > 0 ? 'form' : 'capture'); }}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-xl font-bold text-gray-900">Nouvelle facture</h2>
|
||||
|
||||
{/* ── Étape capture ── */}
|
||||
{step === 'capture' && (
|
||||
<Camera onCapture={handleCapture} />
|
||||
)}
|
||||
|
||||
{/* ── Formulaire (après au moins une image) ── */}
|
||||
{step === 'form' && (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Images capturées */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="relative w-20 h-20">
|
||||
<img
|
||||
src={img.dataUrl}
|
||||
alt={`Photo ${i + 1}`}
|
||||
className="w-full h-full object-cover rounded-xl border border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white rounded-full
|
||||
flex items-center justify-center text-xs leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bouton ajouter une photo supplémentaire */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('capture')}
|
||||
className="w-20 h-20 border-2 border-dashed border-gray-200 rounded-xl
|
||||
flex flex-col items-center justify-center gap-1
|
||||
text-gray-400 hover:border-indigo-300 hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span className="text-xs">Photo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Barre de progression OCR */}
|
||||
{ocr.loading && (
|
||||
<div className="bg-indigo-50 rounded-xl p-3 flex items-center gap-3">
|
||||
<svg className="animate-spin w-4 h-4 text-indigo-600 flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-indigo-700 font-medium mb-1">
|
||||
Lecture automatique… {ocr.progress}%
|
||||
</div>
|
||||
<div className="h-1.5 bg-indigo-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-600 rounded-full transition-all"
|
||||
style={{ width: `${ocr.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Formulaire ── */}
|
||||
<div className="card p-4 space-y-4">
|
||||
|
||||
{/* Société */}
|
||||
<div>
|
||||
<label className="form-label">Société <span className="text-red-400">*</span></label>
|
||||
<select
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Sélectionner une société…</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Catégorie */}
|
||||
<div>
|
||||
<label className="form-label">Catégorie <span className="text-red-400">*</span></label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Sélectionner une catégorie…</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Fournisseur */}
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Fournisseur
|
||||
{ocr.result?.supplier && !supplier && (
|
||||
<span className="ml-2 text-xs text-indigo-500 font-normal">(OCR détecté)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={supplier}
|
||||
onChange={(e) => setSupplier(e.target.value)}
|
||||
placeholder="Nom du restaurant, magasin…"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Montant + Date */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Montant (€) <span className="text-red-400">*</span>
|
||||
{ocr.result?.amount && !amount && (
|
||||
<span className="ml-1 text-xs text-indigo-500">(OCR)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Date <span className="text-red-400">*</span>
|
||||
{ocr.result?.date && ocr.result.date !== today && (
|
||||
<span className="ml-1 text-xs text-indigo-500">(OCR)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={invoiceDate}
|
||||
onChange={(e) => setInvoiceDate(e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commentaire */}
|
||||
<div>
|
||||
<label className="form-label">Commentaire</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Objet du repas, contexte…"
|
||||
rows={2}
|
||||
className="form-input resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Case "Ajouter au fichier de suivi" */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addTracking}
|
||||
onChange={(e) => setAddTracking(e.target.checked)}
|
||||
className="w-5 h-5 rounded accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Ajouter au fichier de suivi SharePoint
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ── Section invités ── */}
|
||||
<div className="card p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuests(!showGuests)}
|
||||
className="flex items-center justify-between w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0" />
|
||||
</svg>
|
||||
<span className="font-medium text-gray-900 text-sm">
|
||||
Invités
|
||||
{guests.length > 0 && (
|
||||
<span className="ml-2 bg-indigo-100 text-indigo-600 text-xs px-2 py-0.5 rounded-full font-semibold">
|
||||
{guests.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-300 transition-transform ${showGuests ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showGuests && (
|
||||
<div className="mt-3">
|
||||
<GuestManager ref={guestManagerRef} guests={guests} onChange={setGuests} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Bouton envoi ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={sendMutation.isPending || !companyId || !categoryId || !amount || !invoiceDate}
|
||||
className="btn-primary"
|
||||
>
|
||||
{sendMutation.isPending ? (
|
||||
<>
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
Envoi en cours…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
Envoyer la facture
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={resetForm} className="btn-secondary">
|
||||
Recommencer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Page Paramètres — 4 sections :
|
||||
* 1. SMTP (config email par utilisateur)
|
||||
* 2. Sociétés (nom + email de remboursement)
|
||||
* 3. Catégories
|
||||
* 4. Microsoft 365 / SharePoint (Azure App Registration + fichier Excel commun)
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useRef } from 'react';
|
||||
import api from '../api/client';
|
||||
import type { Company, Category, SmtpConfig, AppSettings, Contact } from '../types';
|
||||
|
||||
// ─── Section SMTP ────────────────────────────────────────────
|
||||
|
||||
function SmtpSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery<SmtpConfig>({
|
||||
queryKey: ['smtp'],
|
||||
queryFn: () => api.get('/settings/smtp').then((r) => r.data),
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<Partial<SmtpConfig> & { smtp_pass?: string }>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (payload: object) => api.put('/settings/smtp', payload),
|
||||
onSuccess: () => { toast.success('SMTP sauvegardé'); qc.invalidateQueries({ queryKey: ['smtp'] }); setOpen(false); },
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () => api.post('/settings/smtp/test'),
|
||||
onSuccess: () => toast.success('Email de test envoyé !'),
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Échec SMTP'),
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const merged = { ...data, ...form, smtp_port: Number(form.smtp_port ?? data?.smtp_port ?? 587) };
|
||||
save.mutate(merged);
|
||||
}
|
||||
|
||||
const cfg = { ...data, ...form };
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm text-gray-900">Email (SMTP)</p>
|
||||
<p className="text-xs text-gray-400">{data?.smtp_from_email ?? 'Non configuré'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && !isLoading && (
|
||||
<form onSubmit={handleSubmit} className="px-4 pb-4 space-y-3 border-t border-gray-50 pt-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="form-label">Hôte SMTP</label>
|
||||
<input className="form-input" placeholder="smtp.gmail.com"
|
||||
value={cfg.smtp_host ?? ''} onChange={e => setForm(f => ({ ...f, smtp_host: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Port</label>
|
||||
<input className="form-input" type="number" placeholder="587"
|
||||
value={cfg.smtp_port ?? 587} onChange={e => setForm(f => ({ ...f, smtp_port: Number(e.target.value) }))} />
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="w-4 h-4 accent-indigo-600"
|
||||
checked={!!cfg.smtp_secure} onChange={e => setForm(f => ({ ...f, smtp_secure: e.target.checked }))} />
|
||||
<span className="text-sm text-gray-700">SSL/TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="form-label">Utilisateur</label>
|
||||
<input className="form-input" placeholder="votre@email.com"
|
||||
value={cfg.smtp_user ?? ''} onChange={e => setForm(f => ({ ...f, smtp_user: e.target.value }))} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="form-label">Mot de passe {data?.has_password && <span className="text-green-500 text-xs">(défini)</span>}</label>
|
||||
<input className="form-input" type="password" placeholder={data?.has_password ? '••••••• (laisser vide = conserver)' : 'Mot de passe'}
|
||||
value={form.smtp_pass ?? ''} onChange={e => setForm(f => ({ ...f, smtp_pass: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Nom affiché</label>
|
||||
<input className="form-input" placeholder="Mon Nom"
|
||||
value={cfg.smtp_from_name ?? ''} onChange={e => setForm(f => ({ ...f, smtp_from_name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Email expéditeur</label>
|
||||
<input className="form-input" type="email" placeholder="moi@email.com"
|
||||
value={cfg.smtp_from_email ?? ''} onChange={e => setForm(f => ({ ...f, smtp_from_email: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
|
||||
{save.isPending ? 'Sauvegarde…' : 'Sauvegarder'}
|
||||
</button>
|
||||
<button type="button" onClick={() => test.mutate()} disabled={test.isPending || !data?.has_password}
|
||||
className="btn-secondary py-2 text-sm">
|
||||
{test.isPending ? 'Envoi…' : 'Tester'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Sociétés ────────────────────────────────────────
|
||||
|
||||
function CompaniesSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data: companies = [] } = useQuery<Company[]>({
|
||||
queryKey: ['companies'],
|
||||
queryFn: () => api.get('/companies').then(r => r.data),
|
||||
});
|
||||
|
||||
const [editing, setEditing] = useState<Partial<Company> | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (c: Partial<Company>) => c.id
|
||||
? api.put(`/companies/${c.id}`, c)
|
||||
: api.post('/companies', c),
|
||||
onSuccess: () => { toast.success('Société sauvegardée'); qc.invalidateQueries({ queryKey: ['companies'] }); setEditing(null); },
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/companies/${id}`),
|
||||
onSuccess: () => { toast.success('Société supprimée'); qc.invalidateQueries({ queryKey: ['companies'] }); },
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (editing) save.mutate(editing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-emerald-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm text-gray-900">Sociétés</p>
|
||||
<p className="text-xs text-gray-400">{companies.length} société{companies.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-gray-50">
|
||||
{/* Liste */}
|
||||
{companies.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-3 px-4 py-3 border-b border-gray-50 last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{c.email}</p>
|
||||
</div>
|
||||
<button onClick={() => setEditing(c)} className="p-1.5 text-gray-300 hover:text-indigo-500">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => { if (confirm(`Supprimer ${c.name} ?`)) del.mutate(c.id); }}
|
||||
className="p-1.5 text-gray-300 hover:text-red-500">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Formulaire ajout/modif */}
|
||||
{editing !== null ? (
|
||||
<form onSubmit={handleSubmit} className="p-4 bg-gray-50 space-y-3">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
{editing.id ? 'Modifier la société' : 'Nouvelle société'}
|
||||
</p>
|
||||
<input className="form-input" placeholder="Nom *" required
|
||||
value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} />
|
||||
<input className="form-input" type="email" placeholder="Email destinataire *" required
|
||||
value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} />
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
|
||||
{save.isPending ? '…' : 'Sauvegarder'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditing(null)} className="btn-secondary py-2 text-sm">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<button onClick={() => setEditing({})} className="btn-secondary py-2 text-sm">
|
||||
+ Ajouter une société
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Catégories ──────────────────────────────────────
|
||||
|
||||
function CategoriesSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data: categories = [] } = useQuery<Category[]>({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => api.get('/categories').then(r => r.data),
|
||||
});
|
||||
|
||||
const [newName, setNewName] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (name: string) => api.post('/categories', { name }),
|
||||
onSuccess: () => { toast.success('Catégorie ajoutée'); qc.invalidateQueries({ queryKey: ['categories'] }); setNewName(''); },
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/categories/${id}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['categories'] }); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-amber-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a2 2 0 012-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm text-gray-900">Catégories</p>
|
||||
<p className="text-xs text-gray-400">{categories.length} catégorie{categories.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-gray-50 p-4 space-y-2">
|
||||
{categories.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-2">
|
||||
<span className="flex-1 text-sm text-gray-700">{c.name}</span>
|
||||
<button onClick={() => del.mutate(c.id)} className="p-1 text-gray-300 hover:text-red-400">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<input className="form-input text-sm py-2 flex-1" placeholder="Nouvelle catégorie"
|
||||
value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); if (newName.trim()) add.mutate(newName.trim()); }}} />
|
||||
<button onClick={() => { if (newName.trim()) add.mutate(newName.trim()); }}
|
||||
disabled={!newName.trim()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Microsoft Graph ─────────────────────────────────
|
||||
|
||||
function GraphSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data } = useQuery<AppSettings>({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => api.get('/settings/app').then(r => r.data),
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<{
|
||||
tenant?: string; client?: string; secret?: string;
|
||||
siteId?: string; itemId?: string; sheetName?: string;
|
||||
}>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (payload: object) => api.put('/settings/app', payload),
|
||||
onSuccess: () => { toast.success('Configuration Graph sauvegardée'); qc.invalidateQueries({ queryKey: ['app-settings'] }); setOpen(false); },
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
|
||||
});
|
||||
|
||||
const testSp = useMutation({
|
||||
mutationFn: () => api.post('/settings/sharepoint/test'),
|
||||
onSuccess: (r: any) => toast.success(r.data?.message ?? 'SharePoint OK'),
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur SharePoint', { duration: 8000 }),
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
save.mutate({
|
||||
graph_tenant_id: form.tenant || undefined,
|
||||
graph_client_id: form.client || undefined,
|
||||
graph_client_secret: form.secret || undefined,
|
||||
sharepoint_site_id: form.siteId || undefined,
|
||||
sharepoint_item_id: form.itemId || undefined,
|
||||
sharepoint_sheet_name: form.sheetName || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const isConfigured =
|
||||
data?.graph_tenant_id && data?.graph_client_id && data?.has_secret === 'true' &&
|
||||
data?.sharepoint_site_id && data?.sharepoint_item_id;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-sky-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm text-gray-900">Microsoft 365 / SharePoint</p>
|
||||
<p className={`text-xs ${isConfigured ? 'text-green-500' : 'text-gray-400'}`}>
|
||||
{isConfigured ? '✓ Connecté' : 'Non configuré'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<form onSubmit={handleSubmit} className="px-4 pb-4 space-y-3 border-t border-gray-50 pt-3">
|
||||
<div className="bg-sky-50 rounded-xl p-3 text-xs text-sky-700 space-y-1">
|
||||
<p className="font-semibold">Configuration Azure App Registration</p>
|
||||
<p>Voir le README pour créer l'App Registration et obtenir ces valeurs.</p>
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide pt-1">Azure App Registration</p>
|
||||
{([
|
||||
{ key: 'tenant', label: 'Tenant ID', ph: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', cur: data?.graph_tenant_id },
|
||||
{ key: 'client', label: 'Client ID', ph: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', cur: data?.graph_client_id },
|
||||
] as { key: string; label: string; ph: string; cur?: string }[]).map(({ key, label, ph, cur }) => (
|
||||
<div key={key}>
|
||||
<label className="form-label">{label} {cur && <span className="text-green-500 text-xs">(défini)</span>}</label>
|
||||
<input className="form-input font-mono text-xs" placeholder={cur ? '(conserver existant)' : ph}
|
||||
value={(form as any)[key] ?? ''} onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))} />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="form-label">Client Secret {data?.has_secret === 'true' && <span className="text-green-500 text-xs">(défini)</span>}</label>
|
||||
<input className="form-input" type="password" placeholder={data?.has_secret === 'true' ? '(laisser vide = conserver)' : 'Client Secret'}
|
||||
value={form.secret ?? ''} onChange={e => setForm(f => ({ ...f, secret: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide pt-2">Fichier Excel SharePoint (partagé entre toutes les sociétés)</p>
|
||||
<div>
|
||||
<label className="form-label">Site ID {data?.sharepoint_site_id && <span className="text-green-500 text-xs">(défini)</span>}</label>
|
||||
<input className="form-input font-mono text-xs"
|
||||
placeholder={data?.sharepoint_site_id ? '(conserver existant)' : 'contoso.sharepoint.com,xxxxxxxx,yyyyyyyy'}
|
||||
value={form.siteId ?? ''} onChange={e => setForm(f => ({ ...f, siteId: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Item ID du fichier Excel {data?.sharepoint_item_id && <span className="text-green-500 text-xs">(défini)</span>}</label>
|
||||
<input className="form-input font-mono text-xs"
|
||||
placeholder={data?.sharepoint_item_id ? '(conserver existant)' : 'ID de l\'élément (GET .../drive/root:/fichier.xlsx)'}
|
||||
value={form.itemId ?? ''} onChange={e => setForm(f => ({ ...f, itemId: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Nom de la feuille {data?.sharepoint_sheet_name && <span className="text-green-500 text-xs">({data.sharepoint_sheet_name})</span>}
|
||||
</label>
|
||||
<input className="form-input text-sm"
|
||||
placeholder={data?.sharepoint_sheet_name ?? 'Feuil1'}
|
||||
value={form.sheetName ?? ''} onChange={e => setForm(f => ({ ...f, sheetName: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
|
||||
{save.isPending ? 'Sauvegarde…' : 'Sauvegarder'}
|
||||
</button>
|
||||
<button type="button" onClick={() => testSp.mutate()}
|
||||
disabled={testSp.isPending || !isConfigured}
|
||||
className="btn-secondary py-2 text-sm">
|
||||
{testSp.isPending ? 'Test…' : 'Tester'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Contacts ────────────────────────────────────────
|
||||
|
||||
function ContactsSection() {
|
||||
const qc = useQueryClient();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCompany, setNewCompany] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const { data: contacts = [] } = useQuery<Contact[]>({
|
||||
queryKey: ['contacts'],
|
||||
queryFn: () => api.get('/contacts').then(r => r.data),
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (payload: { name: string; company: string }) =>
|
||||
api.post('/contacts', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Contact ajouté');
|
||||
qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
setNewName(''); setNewCompany('');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/contacts/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
|
||||
});
|
||||
|
||||
async function handleFileImport(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
setImporting(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const r = await api.post('/contacts/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const { inserted, skipped, total } = r.data;
|
||||
toast.success(`${inserted} contact${inserted !== 1 ? 's' : ''} importé${inserted !== 1 ? 's' : ''}${skipped > 0 ? ` (${skipped} doublon${skipped !== 1 ? 's' : ''} ignoré${skipped !== 1 ? 's' : ''})` : ''} sur ${total}`);
|
||||
qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error ?? 'Erreur lors de l\'import');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-violet-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm text-gray-900">Contacts / Invités</p>
|
||||
<p className="text-xs text-gray-400">{contacts.length} contact{contacts.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-gray-50">
|
||||
{/* Bouton import */}
|
||||
<div className="px-4 pt-3 pb-2 flex gap-2">
|
||||
<input ref={fileRef} type="file" accept=".csv,.xls,.xlsx"
|
||||
onChange={handleFileImport} className="hidden" />
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="btn-secondary py-2 text-sm flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
{importing ? 'Import en cours…' : 'Importer CSV / Excel'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 self-center">Colonnes : Nom, Société (optionnel)</p>
|
||||
</div>
|
||||
|
||||
{/* Liste des contacts */}
|
||||
{contacts.length > 0 && (
|
||||
<div className="px-4 pb-2 max-h-64 overflow-y-auto space-y-1">
|
||||
{contacts.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-3 py-1.5 border-b border-gray-50 last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{c.name}</span>
|
||||
{c.company && <span className="ml-2 text-xs text-gray-400">{c.company}</span>}
|
||||
</div>
|
||||
<button onClick={() => del.mutate(c.id)}
|
||||
className="p-1 text-gray-300 hover:text-red-400 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ajout manuel */}
|
||||
<div className="px-4 pb-4 pt-2 space-y-2 border-t border-gray-50">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Ajouter manuellement</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="form-input text-sm py-2 flex-1" placeholder="Nom *"
|
||||
value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
|
||||
<input className="form-input text-sm py-2 flex-1" placeholder="Société (optionnel)"
|
||||
value={newCompany} onChange={e => setNewCompany(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
|
||||
<button
|
||||
onClick={() => { if (newName.trim()) add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}
|
||||
disabled={!newName.trim() || add.isPending}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page principale ─────────────────────────────────────────
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Paramètres</h2>
|
||||
<SmtpSection />
|
||||
<CompaniesSection />
|
||||
<CategoriesSection />
|
||||
<ContactsSection />
|
||||
<GraphSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
|
||||
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
|
||||
setAccessToken: (token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
|
||||
setAuth: (user, accessToken, refreshToken) =>
|
||||
set({ user, accessToken, refreshToken }),
|
||||
|
||||
setAccessToken: (accessToken) =>
|
||||
set({ accessToken }),
|
||||
|
||||
logout: () =>
|
||||
set({ user: null, accessToken: null, refreshToken: null }),
|
||||
}),
|
||||
{
|
||||
name: 'notesfrais-auth',
|
||||
// Ne persiste pas le token en clair dans sessionStorage, utilise localStorage
|
||||
// (acceptable car il expire en 15 min et le refresh token sert au renouvellement)
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,135 @@
|
||||
// ─── Utilisateur ─────────────────────────────────────────────
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// ─── Société ─────────────────────────────────────────────────
|
||||
export interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ─── Catégorie ───────────────────────────────────────────────
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// ─── Invité ───────────────────────────────────────────────────
|
||||
export interface Guest {
|
||||
name: string;
|
||||
company?: string | null;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// ─── Image de facture ────────────────────────────────────────
|
||||
export interface InvoiceImage {
|
||||
path: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// ─── Facture ─────────────────────────────────────────────────
|
||||
export type InvoiceStatus = 'pending' | 'reimbursed';
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
user_id: number;
|
||||
company_id: number;
|
||||
company_name: string;
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
supplier?: string;
|
||||
amount: number;
|
||||
invoice_date: string; // YYYY-MM-DD
|
||||
comment?: string;
|
||||
images: InvoiceImage[];
|
||||
pdf_path?: string;
|
||||
pdf_filename?: string;
|
||||
add_to_tracking: boolean;
|
||||
tracking_added: boolean;
|
||||
email_sent: boolean;
|
||||
sent_at?: string;
|
||||
status: InvoiceStatus;
|
||||
guests: Guest[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ─── Création facture ────────────────────────────────────────
|
||||
export interface CreateInvoicePayload {
|
||||
company_id: number;
|
||||
category_id: number;
|
||||
supplier?: string;
|
||||
amount: number;
|
||||
invoice_date: string;
|
||||
comment?: string;
|
||||
images: InvoiceImage[];
|
||||
add_to_tracking: boolean;
|
||||
guests: Guest[];
|
||||
}
|
||||
|
||||
// ─── Réponse liste factures ──────────────────────────────────
|
||||
export interface InvoicesListResponse {
|
||||
data: Invoice[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// ─── Récapitulatif par société ───────────────────────────────
|
||||
export interface InvoiceSummaryItem {
|
||||
company_name: string;
|
||||
status: InvoiceStatus;
|
||||
total: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ─── Filtres de listing ──────────────────────────────────────
|
||||
export interface InvoiceFilters {
|
||||
company_ids?: number[];
|
||||
category_ids?: number[];
|
||||
status?: InvoiceStatus | '';
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ─── Config SMTP ─────────────────────────────────────────────
|
||||
export interface SmtpConfig {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_secure: boolean;
|
||||
smtp_user: string;
|
||||
smtp_from_name: string;
|
||||
smtp_from_email: string;
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
// ─── Contact (répertoire d'invités) ─────────────────────────
|
||||
export interface Contact {
|
||||
id: number;
|
||||
name: string;
|
||||
company: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
// ─── Config Graph + SharePoint ───────────────────────────────
|
||||
export interface AppSettings {
|
||||
graph_tenant_id?: string;
|
||||
graph_client_id?: string;
|
||||
sharepoint_site_id?: string;
|
||||
sharepoint_item_id?: string;
|
||||
sharepoint_sheet_name?: string;
|
||||
has_secret?: string;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* File d'attente hors-ligne — IndexedDB
|
||||
*
|
||||
* Stocke les invoice_id dont l'envoi a échoué faute de connexion.
|
||||
* Quand la connexion revient, le hook useOfflineQueue rejoue les envois.
|
||||
*
|
||||
* Communication inter-composants : custom event 'offline-queue-changed'
|
||||
* évite d'avoir à partager l'état via un store global.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'notesfrais-offline';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'pending_sends';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
export interface QueueItem {
|
||||
invoiceId: string;
|
||||
addedAt: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
// ─── Ouverture DB ─────────────────────────────────────────────
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: 'invoiceId' });
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
export async function queueGetAll(): Promise<QueueItem[]> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
|
||||
req.onsuccess = () => resolve(req.result as QueueItem[]);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function queueAdd(invoiceId: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
const item: QueueItem = { invoiceId, addedAt: Date.now(), retries: 0 };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).put(item);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function queueRemove(invoiceId: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).delete(invoiceId);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function queueBumpRetries(invoiceId: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
const get = store.get(invoiceId);
|
||||
|
||||
get.onsuccess = () => {
|
||||
if (!get.result) { resolve(); return; }
|
||||
const updated = { ...get.result as QueueItem, retries: (get.result as QueueItem).retries + 1 };
|
||||
const put = store.put(updated);
|
||||
put.onsuccess = () => resolve();
|
||||
put.onerror = () => reject(put.error);
|
||||
};
|
||||
get.onerror = () => reject(get.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Event de synchronisation inter-composants ───────────────
|
||||
|
||||
export const QUEUE_EVENT = 'offline-queue-changed';
|
||||
|
||||
function notify() {
|
||||
window.dispatchEvent(new CustomEvent(QUEUE_EVENT));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
// Safe area pour les téléphones avec notch/home indicator
|
||||
padding: {
|
||||
safe: 'env(safe-area-inset-bottom)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'NotesFrais',
|
||||
short_name: 'NotesFrais',
|
||||
description: 'Gestion de notes de frais professionnelles',
|
||||
theme_color: '#4f46e5',
|
||||
background_color: '#f9fafb',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Cache API responses (stale-while-revalidate pour les listes)
|
||||
urlPattern: /^\/api\/(?:companies|categories)/,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-static',
|
||||
expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Network-first pour les factures (données fraîches prioritaires)
|
||||
urlPattern: /^\/api\/invoices/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-invoices',
|
||||
networkTimeoutSeconds: 8,
|
||||
expiration: { maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user