Docs staff + docs comptables + docs généraux

This commit is contained in:
odentas 2025-10-12 23:30:58 +02:00
parent 103d8fa939
commit ba6b733ad0
31 changed files with 5069 additions and 219 deletions

207
STAFF_DOCUMENTS_FEATURE.md Normal file
View file

@ -0,0 +1,207 @@
# Feature: Affichage et gestion des documents salariés dans Staff
## 📝 Description
Cette fonctionnalité permet au staff de :
- Visualiser les documents up## 📦 Types de documents supportés
| Type | Label affiché |
|------|---------------|
| `piece_identite` | Pièce d'identité |
| `attestation_secu` | Attestation Sécurité Sociale |
| `rib` | RIB |
| `medecine_travail` | Médecine du travail |
| `contrat_travail` | Contrat de travail *(nouveau - staff uniquement)* |
| `diplome` | Diplôme *(nouveau - staff uniquement)* |
| `justificatif` | Justificatif *(nouveau - staff uniquement)* |
| `autre` | Autre document |r les salariés via la page d'auto-déclaration
- Télécharger ces documents
- **Uploader manuellement des documents pour un salarié**
Le tout accessible directement depuis l'interface `/staff/salaries`.
## 🔧 Fichiers modifiés/créés
### 1. `/app/api/staff/salaries/documents/route.ts` (NOUVEAU)
API qui récupère les documents d'un salarié depuis S3.
**Fonctionnalités :**
- Récupère tous les fichiers stockés dans S3 pour un matricule donné
- Génère des URLs pré-signées valides 1 heure pour le téléchargement sécurisé
- Détecte automatiquement le type de document (CNI, RIB, etc.) basé sur le nom du fichier
- Retourne les métadonnées (nom, type, taille, date de modification)
**Endpoint :** `GET /api/staff/salaries/documents?matricule={code_salarie}`
**Réponse :**
```json
{
"documents": [
{
"key": "justif-salaries/MAT001/piece-identite-1234567890.pdf",
"name": "piece-identite-1234567890.pdf",
"type": "Pièce d'identité",
"size": 245678,
"lastModified": "2025-10-12T10:30:00Z",
"downloadUrl": "https://odentas-docs.s3.eu-west-3.amazonaws.com/..."
}
],
"count": 1,
"matricule": "MAT001"
}
```
### 2. `/app/api/staff/salaries/documents/upload/route.ts` (NOUVEAU)
API qui permet au staff d'uploader des documents pour un salarié.
**Fonctionnalités :**
- Upload de fichiers (PDF, JPG, PNG) jusqu'à 10MB
- Vérification de l'existence du salarié
- Stockage dans S3 sous `justif-salaries/{matricule}/`
- Métadonnées incluant `uploaded-by: 'staff'` pour tracer l'origine
**Endpoint :** `POST /api/staff/salaries/documents/upload`
**Body (FormData) :**
- `file`: Le fichier à uploader
- `matricule`: Code salarié
- `type`: Type de document (piece_identite, rib, etc.)
**Types de documents supportés :**
- `piece_identite` - Pièce d'identité
- `attestation_secu` - Attestation Sécurité Sociale
- `rib` - RIB
- `medecine_travail` - Attestation médecine du travail
- `contrat_travail` - Contrat de travail
- `diplome` - Diplôme
- `justificatif` - Justificatif
- `autre` - Autre document
### 3. `/components/staff/UploadDocumentModal.tsx` (NOUVEAU)
Composant modal pour uploader des documents.
**Fonctionnalités :**
- Sélection du type de document via dropdown
- Upload par drag & drop ou clic
- Validation du type et de la taille de fichier
- Prévisualisation du fichier sélectionné
- Feedback visuel pendant l'upload
- Messages de succès/erreur avec toast
### 4. `/components/staff/SalariesGridSimple.tsx` (MODIFIÉ)
Composant principal de la page staff/salaries.
**Modifications :**
- Ajout du type `S3Document` pour typer les documents
- Ajout des états `documents`, `loadingDocuments` et `isUploadModalOpen`
- Ajout de la fonction `fetchSalarieDocuments()` qui appelle l'API
- Mise à jour du `useEffect` pour charger les documents lors de la sélection d'un salarié
- Refonte complète de la Card "Documents" pour afficher :
- Un loader pendant le chargement
- La liste des documents avec icône de téléchargement
- Le type, la date et la taille de chaque document
- Un message si aucun document n'est disponible
- **Un bouton "Ajouter un document" pour upload manuel**
- Intégration de `UploadDocumentModal` avec callback de rafraîchissement
## 🎨 Interface utilisateur
### Card Documents - États possibles
1. **Chargement** : Spinner animé
2. **Documents disponibles** :
- Liste cliquable avec :
- Icône de téléchargement
- Type de document (Pièce d'identité, RIB, etc.)
- Date d'upload et taille en Ko
- Fond bleu clair au hover
- Bouton "Ajouter un document" en bas
3. **Aucun document** :
- Message explicatif
- Bouton "Ajouter un document"
### Modale d'upload
La modale permet de :
1. Sélectionner le type de document (dropdown avec 8 types)
2. Uploader un fichier via :
- Drag & drop
- Clic pour ouvrir le sélecteur
3. Voir le fichier sélectionné avec sa taille
4. Valider ou annuler l'upload
### Exemple d'affichage
```
📄 Documents
────────────────────────
3 documents disponibles
[⬇️] Pièce d'identité
12/10/2025 • 245.7 Ko
[⬇️] Attestation Sécurité Sociale
12/10/2025 • 189.2 Ko
[⬇️] RIB
12/10/2025 • 102.3 Ko
[+ Ajouter un document]
```
## 🔒 Sécurité
- Les URLs de téléchargement sont pré-signées et expirent après 1 heure
- L'accès aux APIs est réservé au staff (vérification via `createSbServiceRole()`)
- Les fichiers sont stockés de manière isolée par matricule sur S3
- Les uploads par le staff sont tracés via la métadonnée `uploaded-by: 'staff'`
- Validation du type de fichier et de la taille (max 10MB)
- Vérification de l'existence du salarié avant upload
## 🔗 Intégration
La feature s'intègre avec le système d'auto-déclaration existant :
- Les salariés uploadent leurs documents via `/auto-declaration`
- **Le staff peut aussi uploader des documents manuellement via `/staff/salaries`**
- Les documents sont stockés dans S3 sous `justif-salaries/{matricule}/`
- Le staff peut les visualiser et télécharger via `/staff/salaries`
- Tous les documents (auto-déclaration + staff) sont mélangés dans la même liste
## 📦 Types de documents supportés
| Type | Label affiché |
|------|---------------|
| `piece-identite` | Pièce d'identité |
| `attestation-secu` | Attestation Sécurité Sociale |
| `rib` | RIB |
| `medecine-travail` | Médecine du travail |
| `autre` | Autre document |
## 🚀 Déploiement
Aucune configuration supplémentaire requise. La feature utilise les variables d'environnement AWS S3 existantes :
- `AWS_REGION`
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_S3_BUCKET`
## ✅ Tests recommandés
1. **Visualisation :**
- Sélectionner un salarié qui a uploadé des documents
- Vérifier que les documents s'affichent correctement
- Cliquer sur un document pour le télécharger
- Vérifier qu'un salarié sans document affiche le bon message
2. **Upload manuel par le staff :**
- Cliquer sur "Ajouter un document"
- Sélectionner un type de document
- Tester le drag & drop d'un fichier valide
- Tester l'upload avec un fichier trop volumineux (>10MB)
- Tester avec un type de fichier invalide (.txt, .doc)
- Vérifier que le document apparaît immédiatement après l'upload
- Vérifier qu'on peut télécharger le document uploadé
3. **Edge cases :**
- Salarié sans matricule (code_salarie null)
- Upload pendant qu'un autre est en cours
- Fermeture de la modale pendant l'upload

152
STAFF_NOTES_FEATURE.md Normal file
View file

@ -0,0 +1,152 @@
# Feature: Gestion des notes internes pour les salariés (Staff)
## 📝 Description
Cette fonctionnalité permet au staff de :
- **Ajouter des notes internes** pour chaque salarié
- **Modifier** les notes existantes
- **Visualiser** les notes dans une interface claire
Les notes sont stockées dans la colonne `notes` de la table `salaries` et sont **visibles uniquement par le staff**.
## 🔧 Fichiers modifiés
### 1. `/components/staff/SalariesGridSimple.tsx` (MODIFIÉ)
**Ajouts :**
- Ajout du champ `notes` dans le type `Salarie`
- États pour gérer l'édition :
- `isEditingNote` : booléen pour mode édition
- `noteValue` : valeur temporaire de la note en cours d'édition
- `savingNote` : indicateur de sauvegarde en cours
**Fonctions ajoutées :**
- `handleSaveNote()` : Sauvegarde la note via l'API
- `handleStartEditNote()` : Active le mode édition et charge la note actuelle
- `handleCancelEditNote()` : Annule l'édition en cours
**UI de la Card Note interne :**
**Mode Lecture (note existante) :**
- Affichage de la note dans un bloc jaune avec bordure
- Format `whitespace-pre-wrap` pour respecter les retours à la ligne
- Bouton "✏️ Modifier la note"
**Mode Lecture (pas de note) :**
- Message "Aucune note enregistrée pour ce salarié"
- Bouton "+ Ajouter une note"
**Mode Édition :**
- Textarea de 5 lignes avec placeholder
- Bouton "Enregistrer" avec loader pendant la sauvegarde
- Bouton "Annuler" pour quitter sans sauvegarder
- Désactivation des contrôles pendant la sauvegarde
### 2. `/app/api/staff/salaries/update/route.ts` (MODIFIÉ)
**Modifications :**
- Ajout de `'notes'` dans la liste `allowedFields`
- Permet maintenant de mettre à jour la colonne `notes` de la table `salaries`
### 3. `/app/(app)/staff/salaries/page.tsx` (MODIFIÉ)
**Modifications :**
- Ajout de `notes` dans le `.select()` de la requête Supabase initiale
- Permet de charger les notes existantes au chargement de la page
### 4. `/app/api/staff/salaries/search/route.ts` (MODIFIÉ)
**Modifications :**
- Ajout de `notes` dans le `.select()` de la requête de recherche
- Permet d'avoir les notes dans les résultats de recherche
## 🎨 Interface utilisateur
### Card "Note interne"
```
📝 Note interne
────────────────────────
[Mode vide]
Aucune note enregistrée pour ce salarié.
[+ Ajouter une note]
[Mode avec note]
┌──────────────────────────────┐
│ Note existante qui peut │
│ contenir plusieurs lignes │
│ avec retours à la ligne │
└──────────────────────────────┘
[✏️ Modifier la note]
[Mode édition]
┌──────────────────────────────┐
│ Textarea éditable │
│ │
│ │
│ │
└──────────────────────────────┘
[Enregistrer] [Annuler]
```
## 💾 Stockage
- **Table** : `salaries`
- **Colonne** : `notes` (TEXT, nullable)
- **Format** : Texte libre avec support des retours à la ligne
- **Visibilité** : Staff uniquement
## 🔒 Sécurité
- Seul le staff peut lire et modifier les notes
- Validation côté serveur via `staff_users.is_staff`
- Les notes ne sont jamais exposées aux salariés
## ⚡ Comportement
1. **Changement de salarié** : Réinitialise automatiquement le mode édition
2. **Sauvegarde** : Mise à jour optimiste + confirmation serveur
3. **Annulation** : Restaure la valeur d'origine sans sauvegarder
4. **Feedback visuel** : Loader pendant la sauvegarde, désactivation des boutons
## 🎯 Cas d'usage
- Notes RH confidentielles
- Historique des échanges
- Alertes ou rappels internes
- Informations administratives spécifiques
- Tout commentaire utile pour le suivi du salarié
## ✅ Tests recommandés
1. **Ajout d'une note sur un salarié sans note**
- Cliquer sur "+ Ajouter une note"
- Saisir du texte avec plusieurs lignes
- Valider et vérifier l'affichage
2. **Modification d'une note existante**
- Sélectionner un salarié avec une note
- Cliquer sur "✏️ Modifier la note"
- Modifier le texte
- Valider et vérifier la mise à jour
3. **Annulation**
- Commencer à éditer une note
- Cliquer sur "Annuler"
- Vérifier que les modifications ne sont pas sauvegardées
4. **Changement de salarié pendant l'édition**
- Éditer une note
- Sélectionner un autre salarié
- Vérifier que le mode édition se désactive
5. **Retours à la ligne**
- Ajouter une note avec plusieurs paragraphes
- Vérifier que les retours à la ligne sont préservés
6. **Sauvegarde d'une note vide**
- Éditer une note existante
- Vider le contenu
- Sauvegarder
- Vérifier qu'elle devient "Aucune note"

View file

@ -0,0 +1,218 @@
# Feature: Relance d'invitation auto-déclaration
## 📝 Description
Cette fonctionnalité permet au staff de renvoyer l'email d'invitation à l'auto-déclaration à un salarié directement depuis la page `/staff/salaries`.
## 🎯 Objectif
Permettre de relancer facilement un salarié qui :
- N'a pas reçu l'email initial
- N'a pas complété son profil
- A besoin d'un nouveau lien (token expiré après 7 jours)
- Doit mettre à jour ses documents
## 🔧 Fichiers créés/modifiés
### 1. `/components/staff/ResendInvitationModal.tsx` (NOUVEAU)
Composant modal de confirmation avant envoi de la relance.
**Fonctionnalités :**
- Affichage des informations du salarié (nom, matricule, email)
- Validation de la présence d'un email
- Message explicatif du contenu de l'email
- Confirmation visuelle avant envoi
- Feedback pendant l'envoi (loader)
- Notification de succès/erreur
### 2. `/components/staff/SalariesGridSimple.tsx` (MODIFIÉ)
**Ajouts :**
- Import de `ResendInvitationModal`
- État `isResendInvitationOpen` pour gérer l'ouverture de la modale
- Bouton "Envoyer une relance justifs" en gradient bleu/indigo
- Intégration de la modale avec le salarié sélectionné
**Emplacement du bouton :**
- Au dessus de la card "Informations personnelles"
- Pleine largeur avec icône mail
- Design attrayant en gradient
### 3. API utilisée : `/api/auto-declaration/generate-token/route.ts` (EXISTANTE)
L'API existante est réutilisée. Elle :
- Génère un nouveau token sécurisé
- Supprime les anciens tokens du salarié
- Envoie l'email d'invitation avec le lien personnalisé
- Retourne le statut d'envoi
## 🎨 Interface utilisateur
### Bouton de relance
```
┌─────────────────────────────────────┐
│ 📧 Envoyer une relance justifs │
│ (Gradient bleu → indigo) │
└─────────────────────────────────────┘
```
### Modale de confirmation
```
┌───────────────────────────────────────┐
│ 📧 Relancer le salarié ✕ │
├───────────────────────────────────────┤
│ │
│ ✓ Email d'invitation à │
│ l'auto-déclaration │
│ │
│ Le salarié recevra un email avec │
│ un lien sécurisé... │
│ │
│ Salarié: Jean Dupont │
│ Matricule: MAT001 │
│ Email: jean.dupont@mail.com │
│ │
│ 💡 Contenu: Lien valide 7 jours... │
│ │
│ [Annuler] [Envoyer] 📧 │
└───────────────────────────────────────┘
```
## 📧 Contenu de l'email envoyé
L'email envoyé est **exactement le même** que lors de la création du salarié :
**Template :** `auto-declaration-invitation`
**Contenu :**
- Salutation personnalisée avec prénom
- Nom de l'organisation employeur
- Matricule du salarié
- **Lien d'accès sécurisé** avec token unique
- Instructions pour :
- Compléter les informations personnelles
- Uploader les justificatifs (CNI, attestation Sécu, RIB, etc.)
- Accepter les conditions RGPD
**Validité :** 7 jours
## 🔐 Sécurité
- ✅ Vérification de l'authentification staff
- ✅ Validation de la présence d'un email
- ✅ Génération d'un nouveau token sécurisé (32 bytes hex)
- ✅ Suppression automatique des anciens tokens
- ✅ Token unique par salarié
- ✅ Expiration automatique après 7 jours
## ⚙️ Comportement
1. **Clic sur le bouton** → Ouverture de la modale
2. **Vérification email** → Si absent, message d'erreur et bouton désactivé
3. **Confirmation** → Appel API pour générer token et envoyer email
4. **Pendant l'envoi** → Loader et désactivation des boutons
5. **Succès** → Toast de confirmation et fermeture de la modale
6. **Erreur** → Toast d'erreur avec message détaillé
## 📊 Cas d'usage
### ✅ Cas valides
- Salarié avec email valide
- Besoin de renvoyer l'invitation
- Token expiré (> 7 jours)
- Email initial non reçu
- Salarié n'a pas complété son profil
### ❌ Cas bloqués
- Salarié sans email → Bouton désactivé avec message d'erreur
- Pendant l'envoi → Boutons désactivés
- Erreur API → Toast d'erreur
## 🎯 Flow complet
```
Staff sélectionne salarié
Clic "Envoyer une relance justifs"
Modale s'ouvre avec infos salarié
Validation email présent
Staff confirme "Envoyer"
API génère nouveau token
Supprime anciens tokens
Envoie email avec lien
Toast succès + fermeture modale
Salarié reçoit email
Clique sur lien → /auto-declaration?token=...
Complète son profil et upload documents
```
## 💡 Avantages
1. **Simplicité** : Un seul clic depuis la fiche salarié
2. **Confirmation** : Modale explicative avant envoi
3. **Feedback** : Messages clairs de succès/erreur
4. **Sécurité** : Nouveau token à chaque envoi
5. **Traçabilité** : Logs des envois côté serveur
6. **UX** : Bouton visible et design attrayant
## ✅ Tests recommandés
1. **Envoi réussi**
- Salarié avec email valide
- Vérifier réception de l'email
- Vérifier que le lien fonctionne
- Vérifier expiration après 7 jours
2. **Salarié sans email**
- Bouton doit être désactivé
- Message d'erreur affiché
- Pas d'appel API
3. **Erreur réseau**
- Simuler échec API
- Vérifier toast d'erreur
- Modale reste ouverte
4. **Double envoi**
- Envoyer 2 fois de suite
- Vérifier que seul le dernier token est valide
- Ancien token doit être supprimé
5. **Changement de salarié**
- Ouvrir modale pour salarié A
- Changer vers salarié B
- Vérifier que la modale affiche les bonnes infos
## 🔄 Intégration avec l'existant
- Utilise l'API `/api/auto-declaration/generate-token` existante
- Utilise le service `autoDeclarationTokenService` existant
- Utilise les templates d'email existants
- Compatible avec le système d'auto-déclaration actuel
- Pas de modification de la base de données nécessaire
## 📈 Prochaines améliorations possibles
- [ ] Historique des relances envoyées
- [ ] Indication de la dernière relance envoyée
- [ ] Statistiques de complétion après relance
- [ ] Envoi groupé à plusieurs salariés
- [ ] Personnalisation du message
- [ ] Rappel automatique après X jours
---
**Date de création** : 12 octobre 2025
**Version** : 1.0.0

210
SUMMARY_STAFF_FEATURES.md Normal file
View file

@ -0,0 +1,210 @@
# 🎉 Récapitulatif des fonctionnalités développées - Page Staff/Salariés
## Vue d'ensemble
Trois fonctionnalités majeures ont été implémentées pour améliorer la gestion des salariés dans l'interface Staff :
1. ✅ **Gestion complète des documents** (visualisation, upload, modification, suppression)
2. ✅ **Notes internes** (ajout, modification, visualisation)
3. ✅ **Connexion S3** pour les documents d'auto-déclaration
---
## 🗂️ 1. Gestion des documents salariés
### Fichiers créés
- `/app/api/staff/salaries/documents/route.ts` - Récupération des documents S3
- `/app/api/staff/salaries/documents/upload/route.ts` - Upload par le staff
- `/app/api/staff/salaries/documents/delete/route.ts` - Suppression de documents
- `/app/api/staff/salaries/documents/update-type/route.ts` - Changement de type
- `/components/staff/UploadDocumentModal.tsx` - Modale d'upload
- `/components/staff/DocumentViewerModal.tsx` - Visionneuse de documents
### Fonctionnalités
- ✅ Liste des documents S3 du salarié avec taille et date
- ✅ Visualisation PDF/image dans une modale moderne
- ✅ Upload manuel de documents par le staff (drag & drop)
- ✅ Changement du type de document (CNI, RIB, etc.)
- ✅ Suppression de documents avec confirmation
- ✅ Téléchargement et ouverture dans nouvel onglet
- ✅ URLs pré-signées sécurisées (1h d'expiration)
### Types de documents
- Pièce d'identité
- Attestation Sécurité Sociale
- RIB
- Attestation médecine du travail
- Contrat de travail *(staff uniquement)*
- Diplôme *(staff uniquement)*
- Justificatif *(staff uniquement)*
- Autre document
---
## 📝 2. Notes internes
### Fichiers modifiés
- `/components/staff/SalariesGridSimple.tsx` - UI et logique de gestion
- `/app/api/staff/salaries/update/route.ts` - Ajout du champ `notes`
- `/app/(app)/staff/salaries/page.tsx` - Inclusion de `notes` dans la query
- `/app/api/staff/salaries/search/route.ts` - Inclusion de `notes` dans la recherche
### Fonctionnalités
- ✅ Ajout de notes internes par salarié
- ✅ Modification des notes existantes
- ✅ Support des retours à la ligne (textarea)
- ✅ Sauvegarde avec feedback visuel
- ✅ Annulation sans sauvegarde
- ✅ Réinitialisation automatique au changement de salarié
---
## 🏗️ Architecture technique
### Stack
- **Frontend** : React, Next.js 14, TypeScript
- **Backend** : Next.js API Routes, Supabase
- **Storage** : AWS S3 (région eu-west-3, Paris)
- **Authentification** : Supabase Auth avec vérification staff
### Sécurité
- ✅ Vérification `is_staff` sur toutes les routes
- ✅ URLs S3 pré-signées avec expiration
- ✅ Validation des types de fichiers (PDF, JPG, PNG)
- ✅ Limite de taille : 10 MB
- ✅ Métadonnées de traçabilité (`uploaded-by: 'staff'`)
- ✅ Notes visibles uniquement par le staff
### Optimisations
- ✅ Mise à jour optimiste de l'UI
- ✅ Refresh automatique après upload/suppression
- ✅ Loader pendant les opérations asynchrones
- ✅ Gestion d'état locale pour éviter les re-fetch
---
## 📊 Structure S3
```
odentas-docs/
└── justif-salaries/
└── {matricule}/
├── piece-identite-{timestamp}.pdf
├── attestation-secu-{timestamp}.pdf
├── rib-{timestamp}.pdf
├── contrat-travail-{timestamp}.pdf
└── ...
```
---
## 🎨 Expérience utilisateur
### Page `/staff/salaries`
**Layout à 3 colonnes :**
1. **Colonne 1-2** : Table des salariés avec filtres
2. **Colonne 3** : Cards détaillées du salarié sélectionné
- 👤 Informations personnelles
- 📝 Note interne *(nouvelle)*
- 📄 Documents *(améliorée)*
- 💼 Contrats récents
### Card Documents
- Clic sur un document → Modale de visualisation
- Prévisualisation PDF/image intégrée
- Actions : Modifier type, Télécharger, Supprimer, Nouvel onglet
- Bouton "+ Ajouter un document" toujours visible
### Card Note interne
- Mode lecture : Affichage avec fond jaune
- Mode édition : Textarea 5 lignes
- Boutons : Enregistrer / Annuler
- Support multi-lignes avec `whitespace-pre-wrap`
---
## 📚 Documentation créée
- `STAFF_DOCUMENTS_FEATURE.md` - Gestion des documents
- `STAFF_NOTES_FEATURE.md` - Gestion des notes
- `SUMMARY_STAFF_FEATURES.md` - Ce fichier
---
## 🧪 Tests à effectuer
### Documents
1. Upload d'un document (drag & drop et clic)
2. Visualisation PDF et image
3. Changement de type de document
4. Suppression avec confirmation
5. Vérification des liens de téléchargement
### Notes
1. Ajout d'une note sur un salarié
2. Modification d'une note existante
3. Annulation sans sauvegarde
4. Changement de salarié pendant l'édition
5. Notes multi-lignes
### Intégration
1. Salarié avec documents auto-déclarés + documents staff
2. Navigation entre plusieurs salariés
3. Recherche et filtres
4. Performance avec beaucoup de documents
---
## 🚀 Prochaines étapes suggérées
### Court terme
- [ ] Ajouter un historique des modifications de documents
- [ ] Permettre de filtrer/trier par présence de notes
- [ ] Notification quand un salarié upload un nouveau document
### Moyen terme
- [ ] Validation automatique de documents (OCR)
- [ ] Catégories personnalisées de documents
- [ ] Export groupé de documents
### Long terme
- [ ] Versioning des documents
- [ ] Signatures électroniques intégrées
- [ ] Dashboard analytique des documents manquants
---
## 💡 Points techniques importants
### Gestion des états
- Réinitialisation automatique lors du changement de salarié
- Mise à jour optimiste pour meilleure UX
- Gestion des erreurs avec toast notifications
### Performance
- Pagination des salariés (200 max)
- Génération d'URLs S3 à la demande
- Cache des documents jusqu'au refresh
### Maintenance
- Code TypeScript strict
- Composants réutilisables (modales)
- APIs RESTful cohérentes
- Logging console pour debug
---
## 📞 Support
En cas de problème :
1. Vérifier les logs serveur Next.js
2. Vérifier les erreurs console navigateur
3. Tester les permissions Supabase RLS
4. Valider les credentials AWS S3
5. Consulter la documentation des features
---
**Dernière mise à jour** : 12 octobre 2025
**Version** : 1.0.0

View file

@ -0,0 +1,907 @@
'use client'
import React from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Upload, FileText, Folder, Building2, Search, Plus,
Trash2, Edit3, Download, Loader2, X, Check, ChevronDown, ChevronRight
} from 'lucide-react'
import { usePageTitle } from '@/hooks/usePageTitle'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
type Organization = {
id: string
name: string
structure_api: string
}
type GeneralDocument = {
type: string
label: string
available: boolean
key?: string
name?: string
size?: number
downloadUrl?: string
}
type DocumentItem = {
id: string
title: string
filename: string
category: string
period_label?: string | null
size_bytes: number
date_added: string
storage_path: string
download_url?: string
}
type Period = {
label: string
count: number
}
const DOC_TYPES = {
"contrat-odentas": "Contrat Odentas",
"licence-spectacles": "Licence de spectacles",
"rib": "RIB",
"kbis-jo": "KBIS / Journal Officiel",
"delegation-signature": "Délégation de signature"
}
function formatBytes(bytes?: number) {
if (!bytes && bytes !== 0) return ''
const sizes = ['o', 'Ko', 'Mo', 'Go', 'To']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
// Modal pour uploader un document général
function UploadGeneralDocModal({
isOpen,
onClose,
orgId,
orgKey,
docType
}: {
isOpen: boolean
onClose: () => void
orgId: string
orgKey: string
docType: string
}) {
const [file, setFile] = React.useState<File | null>(null)
const [uploading, setUploading] = React.useState(false)
const queryClient = useQueryClient()
const handleUpload = async () => {
if (!file) return
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('org_id', orgId)
formData.append('org_key', slugify(orgKey))
formData.append('doc_type', docType)
formData.append('category', 'docs_generaux')
const response = await fetch('/api/staff/documents/upload', {
method: 'POST',
body: formData
})
if (!response.ok) throw new Error('Erreur lors de l\'upload')
toast.success('Document uploadé avec succès')
queryClient.invalidateQueries({ queryKey: ['documents', 'generaux', orgId] })
setFile(null)
onClose()
} catch (error) {
toast.error('Erreur lors de l\'upload du document')
console.error(error)
} finally {
setUploading(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Uploader {DOC_TYPES[docType as keyof typeof DOC_TYPES]}</DialogTitle>
<DialogDescription>
Sélectionnez un fichier PDF à uploader pour ce type de document.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="block text-sm font-medium mb-2">Fichier PDF</label>
<Input
type="file"
accept=".pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
{file && (
<p className="text-sm text-muted-foreground mt-2">
{file.name} ({formatBytes(file.size)})
</p>
)}
</div>
<div className="flex gap-3">
<Button
onClick={handleUpload}
disabled={!file || uploading}
className="flex-1"
>
{uploading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Upload en cours...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Uploader
</>
)}
</Button>
<Button onClick={onClose} variant="outline" disabled={uploading}>
Annuler
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
// Modal pour uploader un ou plusieurs documents comptables
function UploadComptableModal({
isOpen,
onClose,
orgId,
orgKey
}: {
isOpen: boolean
onClose: () => void
orgId: string
orgKey: string
}) {
type FileEntry = { id: number; file: File | null }
const [files, setFiles] = React.useState<FileEntry[]>([{ id: 1, file: null }])
const [period, setPeriod] = React.useState('')
const [uploading, setUploading] = React.useState(false)
const queryClient = useQueryClient()
const nextId = React.useRef(2)
const addFileRow = () => {
setFiles(prev => [...prev, { id: nextId.current++, file: null }])
}
const removeFileRow = (id: number) => {
if (files.length === 1) return
setFiles(prev => prev.filter(f => f.id !== id))
}
const updateFile = (id: number, file: File | null) => {
setFiles(prev => prev.map(f => f.id === id ? { ...f, file } : f))
}
const handleUpload = async () => {
const validFiles = files.filter(f => f.file !== null).map(f => f.file!)
if (validFiles.length === 0 || !period) return
setUploading(true)
try {
// Upload chaque fichier séparément
for (const file of validFiles) {
const formData = new FormData()
formData.append('file', file)
formData.append('org_id', orgId)
formData.append('org_key', slugify(orgKey))
formData.append('period', period)
formData.append('category', 'docs_comptables')
const response = await fetch('/api/staff/documents/upload', {
method: 'POST',
body: formData
})
if (!response.ok) throw new Error(`Erreur lors de l'upload de ${file.name}`)
}
toast.success(`${validFiles.length} document(s) uploadé(s) avec succès`)
queryClient.invalidateQueries({ queryKey: ['documents', 'comptables'] })
setFiles([{ id: 1, file: null }])
setPeriod('')
onClose()
} catch (error) {
toast.error('Erreur lors de l\'upload des documents')
console.error(error)
} finally {
setUploading(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Uploader des documents comptables</DialogTitle>
<DialogDescription>
Ajoutez un ou plusieurs fichiers pour une période donnée.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="block text-sm font-medium mb-2">
Période <span className="text-red-500">*</span>
</label>
<Input
type="text"
placeholder="ex: 2507-juillet-2025"
value={period}
onChange={(e) => setPeriod(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Format: YYMM-mois-YYYY
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium">
Fichiers <span className="text-red-500">*</span>
</label>
<Button
type="button"
size="sm"
variant="outline"
onClick={addFileRow}
disabled={uploading}
>
<Plus className="h-4 w-4 mr-1" />
Ajouter un fichier
</Button>
</div>
{files.map((entry, index) => (
<div key={entry.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input
type="file"
accept=".pdf,.xlsx,.xls,.doc,.docx"
onChange={(e) => updateFile(entry.id, e.target.files?.[0] || null)}
disabled={uploading}
/>
{entry.file && (
<p className="text-xs text-muted-foreground mt-1">
{entry.file.name} ({formatBytes(entry.file.size)})
</p>
)}
</div>
{files.length > 1 && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => removeFileRow(entry.id)}
disabled={uploading}
className="text-red-600 hover:text-red-700 hover:bg-red-50 px-2"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
<div className="flex gap-3 pt-4">
<Button
onClick={handleUpload}
disabled={!period || files.every(f => !f.file) || uploading}
className="flex-1"
>
{uploading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Upload en cours...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Uploader {files.filter(f => f.file).length > 0 && `(${files.filter(f => f.file).length})`}
</>
)}
</Button>
<Button onClick={onClose} variant="outline" disabled={uploading}>
Annuler
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
// Modal pour visualiser un document
function ViewDocumentModal({
isOpen,
onClose,
document
}: {
isOpen: boolean
onClose: () => void
document: { name: string; downloadUrl: string; size?: number } | null
}) {
if (!document) return null
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>{document.name}</DialogTitle>
<DialogDescription>
{document.size && `Taille: ${formatBytes(document.size)}`}
</DialogDescription>
</DialogHeader>
<div className="w-full h-[70vh] bg-gray-100 rounded-lg overflow-hidden">
<iframe
src={document.downloadUrl}
className="w-full h-full border-0"
title={document.name}
/>
</div>
<div className="flex gap-3 justify-end">
<Button
onClick={() => window.open(document.downloadUrl, '_blank')}
variant="outline"
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
<Button onClick={onClose}>
Fermer
</Button>
</div>
</DialogContent>
</Dialog>
)
}
export default function StaffDocumentsPage() {
usePageTitle("Gestion des documents")
const [selectedOrgId, setSelectedOrgId] = React.useState<string>('')
const [searchTerm, setSearchTerm] = React.useState('')
const [activeTab, setActiveTab] = React.useState<'generaux' | 'comptables'>('generaux')
const [uploadGeneralModal, setUploadGeneralModal] = React.useState<string | null>(null)
const [uploadComptableModal, setUploadComptableModal] = React.useState(false)
const [expandedPeriods, setExpandedPeriods] = React.useState<Set<string>>(new Set())
const [viewDocument, setViewDocument] = React.useState<{ name: string; downloadUrl: string; size?: number } | null>(null)
const [deleteConfirm, setDeleteConfirm] = React.useState<{ type: 'general' | 'comptable'; data: any } | null>(null)
const queryClient = useQueryClient()
// Récupérer les organisations
const { data: organizations, isLoading: isLoadingOrgs } = useQuery<Organization[]>({
queryKey: ['organizations', 'all'],
queryFn: async () => {
const response = await fetch('/api/organizations')
if (!response.ok) throw new Error('Erreur chargement organisations')
const data = await response.json()
const items = data.items || []
return items.sort((a: Organization, b: Organization) =>
a.name.localeCompare(b.name)
)
}
})
// Filtrer les organisations selon la recherche
const filteredOrgs = React.useMemo(() => {
if (!organizations) return []
if (!searchTerm) return organizations
return organizations.filter(org =>
org.name.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [organizations, searchTerm])
const selectedOrg = organizations?.find(org => org.id === selectedOrgId)
// Récupérer les documents généraux
const { data: documentsGeneraux } = useQuery<GeneralDocument[]>({
queryKey: ['documents', 'generaux', selectedOrgId],
queryFn: async () => {
const res = await fetch(`/api/documents/generaux?org_id=${selectedOrgId}`)
if (!res.ok) throw new Error('Erreur chargement documents')
const data = await res.json()
return data.documents || []
},
enabled: !!selectedOrgId && activeTab === 'generaux'
})
// Récupérer les documents comptables
const { data: documentsComptables } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'comptables', selectedOrgId],
queryFn: async () => {
const res = await fetch(`/api/staff/documents/list?org_id=${selectedOrgId}&category=docs_comptables`)
if (!res.ok) throw new Error('Erreur chargement documents')
const data = await res.json()
return data.documents || []
},
enabled: !!selectedOrgId && activeTab === 'comptables'
})
// Grouper par période
const documentsByPeriod = React.useMemo(() => {
if (!documentsComptables) return new Map()
const grouped = new Map<string, DocumentItem[]>()
documentsComptables.forEach(doc => {
const period = doc.period_label || 'Sans période'
if (!grouped.has(period)) grouped.set(period, [])
grouped.get(period)!.push(doc)
})
return new Map(
Array.from(grouped.entries()).sort((a, b) => b[0].localeCompare(a[0]))
)
}, [documentsComptables])
// Supprimer un document général
const deleteGeneralDoc = useMutation({
mutationFn: async ({ docType, orgKey }: { docType: string, orgKey: string }) => {
const response = await fetch('/api/staff/documents/delete-general', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ org_key: slugify(orgKey), doc_type: docType })
})
if (!response.ok) throw new Error('Erreur suppression')
},
onSuccess: () => {
toast.success('Document supprimé')
queryClient.invalidateQueries({ queryKey: ['documents', 'generaux'] })
},
onError: () => toast.error('Erreur lors de la suppression')
})
// Supprimer un document comptable
const deleteComptableDoc = useMutation({
mutationFn: async (docId: string) => {
const response = await fetch(`/api/staff/documents/delete?doc_id=${docId}`, {
method: 'DELETE'
})
if (!response.ok) throw new Error('Erreur suppression')
},
onSuccess: () => {
toast.success('Document supprimé')
queryClient.invalidateQueries({ queryKey: ['documents', 'comptables'] })
},
onError: () => toast.error('Erreur lors de la suppression')
})
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold tracking-tight">Gestion des documents</h1>
<p className="text-muted-foreground mt-2">
Gérez les documents généraux et comptables de vos clients
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar: Liste des organisations */}
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
Organisations
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[600px] overflow-y-auto space-y-1">
{isLoadingOrgs ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : filteredOrgs.length > 0 ? (
filteredOrgs.map((org) => (
<button
key={org.id}
onClick={() => setSelectedOrgId(org.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
selectedOrgId === org.id
? 'bg-blue-50 text-blue-700 font-medium'
: 'hover:bg-gray-50'
}`}
>
{org.name}
</button>
))
) : (
<p className="text-sm text-gray-500 text-center py-8">
Aucune organisation trouvée
</p>
)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main content */}
<div className="lg:col-span-3">
{!selectedOrgId ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<div className="text-center">
<Building2 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">
Sélectionnez une organisation pour gérer ses documents
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{/* Header avec nom organisation */}
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
<CardHeader>
<CardTitle className="text-xl">{selectedOrg?.name}</CardTitle>
<CardDescription>
Clé: {selectedOrg?.structure_api}
</CardDescription>
</CardHeader>
</Card>
{/* Onglets */}
<div className="flex gap-2 border-b">
<button
onClick={() => setActiveTab('generaux')}
className={`px-4 py-2 font-medium transition-colors border-b-2 ${
activeTab === 'generaux'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<FileText className="h-4 w-4 inline mr-2" />
Documents généraux
</button>
<button
onClick={() => setActiveTab('comptables')}
className={`px-4 py-2 font-medium transition-colors border-b-2 ${
activeTab === 'comptables'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<Folder className="h-4 w-4 inline mr-2" />
Documents comptables
</button>
</div>
{/* Documents généraux */}
{activeTab === 'generaux' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(DOC_TYPES).map(([type, label]) => {
const doc = documentsGeneraux?.find(d => d.type === type)
return (
<Card key={type} className="hover:shadow-md transition-shadow">
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" />
{label}
</span>
{doc?.available ? (
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
Disponible
</span>
) : (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
Non uploadé
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{doc?.available ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
{formatBytes(doc.size)}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setViewDocument({
name: doc.name || DOC_TYPES[type as keyof typeof DOC_TYPES],
downloadUrl: doc.downloadUrl!,
size: doc.size
})}
className="flex-1"
>
<FileText className="h-3 w-3 mr-1" />
Voir
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setUploadGeneralModal(type)}
className="flex-1"
>
<Edit3 className="h-3 w-3 mr-1" />
Remplacer
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setDeleteConfirm({
type: 'general',
data: {
docType: type,
orgKey: selectedOrg?.structure_api || '',
label: DOC_TYPES[type as keyof typeof DOC_TYPES]
}
})
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
) : (
<Button
size="sm"
onClick={() => setUploadGeneralModal(type)}
className="w-full"
>
<Plus className="h-3 w-3 mr-1" />
Uploader
</Button>
)}
</CardContent>
</Card>
)
})}
</div>
)}
{/* Documents comptables */}
{activeTab === 'comptables' && (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setUploadComptableModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Uploader un document
</Button>
</div>
{documentsByPeriod.size === 0 ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<div className="text-center">
<Folder className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Aucun document comptable</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{Array.from(documentsByPeriod.entries()).map(([period, docs]) => {
const isExpanded = expandedPeriods.has(period)
return (
<Card key={period}>
<button
onClick={() => {
const next = new Set(expandedPeriods)
if (next.has(period)) next.delete(period)
else next.add(period)
setExpandedPeriods(next)
}}
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<div className="text-left">
<h3 className="font-semibold">{period}</h3>
<p className="text-sm text-gray-500">
{docs.length} document{docs.length > 1 ? 's' : ''}
</p>
</div>
</div>
</button>
{isExpanded && (
<div className="border-t p-4 space-y-2">
{docs.map((doc: DocumentItem) => (
<div
key={doc.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{doc.filename}</p>
<p className="text-sm text-gray-500">
{formatDate(doc.date_added)} {formatBytes(doc.size_bytes)}
</p>
</div>
<div className="flex gap-2">
{doc.download_url && (
<Button
size="sm"
variant="outline"
onClick={() => setViewDocument({
name: doc.filename,
downloadUrl: doc.download_url!,
size: doc.size_bytes
})}
>
<FileText className="h-3 w-3 mr-1" />
Voir
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
setDeleteConfirm({
type: 'comptable',
data: {
id: doc.id,
filename: doc.filename
}
})
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</Card>
)
})}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
{/* Modals */}
{uploadGeneralModal && selectedOrg && (
<UploadGeneralDocModal
isOpen={true}
onClose={() => setUploadGeneralModal(null)}
orgId={selectedOrgId}
orgKey={selectedOrg.structure_api}
docType={uploadGeneralModal}
/>
)}
{uploadComptableModal && selectedOrg && (
<UploadComptableModal
isOpen={true}
onClose={() => setUploadComptableModal(false)}
orgId={selectedOrgId}
orgKey={selectedOrg.structure_api}
/>
)}
<ViewDocumentModal
isOpen={!!viewDocument}
onClose={() => setViewDocument(null)}
document={viewDocument}
/>
{/* Modale de confirmation de suppression */}
<Dialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="h-5 w-5" />
Confirmer la suppression
</DialogTitle>
<DialogDescription>
Cette action est irréversible. Le document sera définitivement supprimé.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
{deleteConfirm?.type === 'general' ? (
<>Voulez-vous vraiment supprimer <strong>{deleteConfirm.data.label}</strong> ?</>
) : (
<>Voulez-vous vraiment supprimer <strong>{deleteConfirm?.data.filename}</strong> ?</>
)}
</p>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setDeleteConfirm(null)}
>
Annuler
</Button>
<Button
onClick={() => {
if (deleteConfirm?.type === 'general') {
deleteGeneralDoc.mutate({
docType: deleteConfirm.data.docType,
orgKey: deleteConfirm.data.orgKey
})
} else if (deleteConfirm?.type === 'comptable') {
deleteComptableDoc.mutate(deleteConfirm.data.id)
}
setDeleteConfirm(null)
}}
className="bg-red-600 text-white hover:bg-red-700 border-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -39,7 +39,7 @@ export default async function StaffSalariesPage() {
compte_transat, topaze, justificatifs_personnels, rf_au_sens_fiscal, intermittent_mineur_16,
adresse_mail, nir, conges_spectacles, tel, adresse, date_naissance, lieu_de_naissance,
iban, bic, abattement_2024, infos_caisses_organismes, notif_nouveau_salarie, notif_employeur,
derniere_profession, employer_id, created_at, updated_at,
derniere_profession, employer_id, notes, last_notif_justifs, created_at, updated_at,
organizations(name)`
)
.order("nom", { ascending: true })

View file

@ -5,10 +5,13 @@ import { useQuery } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Download, FileText, Upload, Folder, Building2, ChevronDown, ChevronRight } from 'lucide-react'
import { Download, FileText, Upload, Folder, Building2, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { usePageTitle } from '@/hooks/usePageTitle'
import { Tooltip } from '@/components/ui/tooltip'
import { supabase } from '@/lib/supabaseClient'
import { DocumentViewModal } from '@/components/DocumentViewModal'
import Cookies from 'js-cookie'
type DocumentItem = {
id: string
@ -20,6 +23,23 @@ type DocumentItem = {
period_label?: string | null
}
type Organization = {
id: string
name: string
key: string
}
type GeneralDocument = {
type: string
label: string
available: boolean
key?: string
name?: string
size?: number
lastModified?: string
downloadUrl?: string
}
function formatBytes(bytes?: number) {
if (!bytes && bytes !== 0) return ''
const sizes = ['o', 'Ko', 'Mo', 'Go', 'To']
@ -70,76 +90,177 @@ function UploadPanel() {
)
}
function SectionGeneraux() {
const { data: documentsGeneraux, isLoading, error } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'generaux'],
queryFn: async () => {
const res = await fetch('/api/documents?category=generaux')
const data = await res.json()
console.log('📄 Documents Généraux - Response:', data)
console.log('📄 Documents Généraux - Is Array:', Array.isArray(data))
function SectionGeneraux({ selectedOrgId }: { selectedOrgId?: string }) {
const [selectedDoc, setSelectedDoc] = React.useState<GeneralDocument | null>(null);
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [orgId, setOrgId] = React.useState<string | null>(null);
const [isLoadingOrgId, setIsLoadingOrgId] = React.useState(true);
// Si la réponse est un objet avec une propriété documents
if (data && typeof data === 'object' && !Array.isArray(data)) {
console.log('📄 Documents Généraux - Keys:', Object.keys(data))
// Essayer différentes propriétés possibles
if (data.documents) return data.documents
if (data.data) return data.data
if (data.items) return data.items
// Récupérer l'orgId depuis les props (staff) ou depuis l'API /me (client)
React.useEffect(() => {
const fetchOrgId = async () => {
if (selectedOrgId) {
// Staff: utiliser l'org sélectionnée
setOrgId(selectedOrgId);
setIsLoadingOrgId(false);
} else {
// Client: récupérer depuis l'API /me
try {
const response = await fetch('/api/me');
if (response.ok) {
const data = await response.json();
setOrgId(data.active_org_id || null);
} else {
setOrgId(null);
}
} catch (error) {
console.error('Erreur récupération org_id:', error);
setOrgId(null);
} finally {
setIsLoadingOrgId(false);
}
}
};
fetchOrgId();
}, [selectedOrgId]);
const { data: documentsGeneraux, isLoading, error } = useQuery<GeneralDocument[]>({
queryKey: ['documents', 'generaux', orgId],
queryFn: async () => {
if (!orgId) {
throw new Error('Aucune organisation sélectionnée');
}
return Array.isArray(data) ? data : []
}
})
const res = await fetch(`/api/documents/generaux?org_id=${encodeURIComponent(orgId)}`);
console.log('📄 Documents Généraux - Final Data:', documentsGeneraux)
console.log('📄 Documents Généraux - Loading:', isLoading)
console.log('📄 Documents Généraux - Error:', error)
if (!res.ok) {
const errorText = await res.text();
throw new Error('Erreur lors de la récupération des documents');
}
const handleDownload = (item: DocumentItem) => {
if (item.url) {
window.open(item.url, '_blank')
} else {
alert('Document non disponible')
const data = await res.json();
return data.documents || [];
},
enabled: !!orgId
});
const handleViewDocument = (doc: GeneralDocument) => {
if (doc.available && doc.downloadUrl) {
setSelectedDoc(doc);
setIsModalOpen(true);
}
};
if (isLoadingOrgId) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Chargement...</p>
</div>
);
}
if (!orgId) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
Aucune organisation associée
</p>
</div>
);
}
if (isLoading) {
return <p className="text-center text-muted-foreground py-8">Chargement...</p>
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Chargement des documents...</p>
</div>
);
}
if (error) {
return <p className="text-center text-red-500 py-8">Erreur: {String(error)}</p>
return (
<div className="text-center py-8">
<p className="text-red-500">Erreur: {String(error)}</p>
</div>
);
}
if (!documentsGeneraux || documentsGeneraux.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Aucun document trouvé</p>
</div>
);
}
return (
<div className="space-y-2">
{documentsGeneraux && documentsGeneraux.length > 0 ? (
documentsGeneraux.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<h4 className="font-medium">{item.title}</h4>
<p className="text-sm text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
</div>
<Button
size="sm"
onClick={() => handleDownload(item)}
disabled={!item.url}
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))
) : (
<p className="text-center text-muted-foreground py-8">
Aucun document disponible
</p>
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{documentsGeneraux && documentsGeneraux.map((doc) => (
<Card
key={doc.type}
className={`cursor-pointer transition-all ${
doc.available
? 'hover:shadow-md hover:border-blue-300'
: 'opacity-60 cursor-not-allowed'
}`}
onClick={() => doc.available && handleViewDocument(doc)}
>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<FileText className="h-4 w-4" />
{doc.label}
</CardTitle>
</CardHeader>
<CardContent>
{doc.available ? (
<div className="space-y-1">
{doc.size && (
<p className="text-sm text-muted-foreground">
{formatBytes(doc.size)}
</p>
)}
<Button
size="sm"
variant="outline"
className="w-full mt-2"
onClick={(e) => {
e.stopPropagation();
handleViewDocument(doc);
}}
>
<FileText className="h-4 w-4 mr-2" />
Consulter
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground">
Non concerné ou indisponible
</div>
)}
</CardContent>
</Card>
))}
</div>
{selectedDoc && (
<DocumentViewModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedDoc(null);
}}
document={selectedDoc.available && selectedDoc.downloadUrl ? {
name: selectedDoc.name || '',
label: selectedDoc.label,
downloadUrl: selectedDoc.downloadUrl,
size: selectedDoc.size
} : null}
/>
)}
</div>
)
</>
);
}
function SectionCaisses() {
@ -148,7 +269,6 @@ function SectionCaisses() {
queryFn: async () => {
const res = await fetch('/api/documents?category=caisses')
const data = await res.json()
console.log('📄 Documents Caisses - Response:', data)
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (data.documents) return data.documents
@ -207,66 +327,122 @@ function SectionCaisses() {
}
function SectionComptables() {
const [expandedYears, setExpandedYears] = React.useState<Set<string>>(new Set())
const [expandedPeriods, setExpandedPeriods] = React.useState<Set<string>>(new Set())
const [loadedPeriods, setLoadedPeriods] = React.useState<Set<string>>(new Set())
// Récupérer uniquement les métadonnées (sans URLs pré-signées)
const { data: documentsCompta, isLoading, error } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'comptables'],
queryKey: ['documents', 'comptables', 'metadata'],
queryFn: async () => {
console.log('📄 Fetching comptables with category=docs_comptables')
const res = await fetch('/api/documents?category=docs_comptables')
const res = await fetch('/api/documents?category=docs_comptables&metadata_only=true')
const data = await res.json()
console.log('📄 Documents Comptables - Raw Response:', data)
console.log('📄 Documents Comptables - Is Array?', Array.isArray(data))
if (data && typeof data === 'object' && !Array.isArray(data)) {
console.log('📄 Documents Comptables - Object keys:', Object.keys(data))
if (data.documents) {
console.log('📄 Using data.documents:', data.documents)
return data.documents
}
if (data.data) {
console.log('📄 Using data.data:', data.data)
return data.data
}
if (data.items) {
console.log('📄 Using data.items:', data.items)
return data.items
}
}
const result = Array.isArray(data) ? data : []
console.log('📄 Final result:', result)
return result
}
})
// Grouper les documents par période
const documentsByPeriod = React.useMemo(() => {
// Charger les URLs pour une période spécifique
const { data: periodUrls = {} } = useQuery<Record<string, DocumentItem[]>>({
queryKey: ['documents', 'comptables', 'urls', Array.from(loadedPeriods)],
queryFn: async () => {
if (loadedPeriods.size === 0) return {}
const urls: Record<string, DocumentItem[]> = {}
for (const period of Array.from(loadedPeriods)) {
const res = await fetch(`/api/documents?category=docs_comptables&period=${encodeURIComponent(period)}`)
const data = await res.json()
let docs = []
if (data && typeof data === 'object' && !Array.isArray(data)) {
docs = data.documents || data.data || data.items || []
} else {
docs = Array.isArray(data) ? data : []
}
urls[period] = docs
}
return urls
},
enabled: loadedPeriods.size > 0
})
// Extraire l'année depuis le period_label (format: "2507-juillet-2025")
const extractYear = (periodLabel: string): string => {
const match = periodLabel.match(/-(\d{4})$/)
return match ? match[1] : 'Année inconnue'
}
// Grouper les documents par année puis par période
const documentsByYear = React.useMemo((): Map<string, Map<string, DocumentItem[]>> => {
if (!documentsCompta || documentsCompta.length === 0) return new Map()
const grouped = new Map<string, DocumentItem[]>()
const grouped = new Map<string, Map<string, DocumentItem[]>>()
documentsCompta.forEach(doc => {
const period = doc.period_label || 'Sans période'
if (!grouped.has(period)) {
grouped.set(period, [])
const year = period === 'Sans période' ? 'Sans année' : extractYear(period)
if (!grouped.has(year)) {
grouped.set(year, new Map())
}
grouped.get(period)!.push(doc)
const yearGroup = grouped.get(year)!
if (!yearGroup.has(period)) {
yearGroup.set(period, [])
}
yearGroup.get(period)!.push(doc)
})
// Trier les périodes par ordre décroissant (plus récent en premier)
const sortedEntries = Array.from(grouped.entries()).sort((a, b) => {
// Si "Sans période", mettre à la fin
if (a[0] === 'Sans période') return 1
if (b[0] === 'Sans période') return -1
// Sinon, tri décroissant (plus récent en premier)
// Trier les années par ordre décroissant
const sortedYears = Array.from(grouped.entries()).sort((a, b) => {
if (a[0] === 'Sans année') return 1
if (b[0] === 'Sans année') return -1
return b[0].localeCompare(a[0])
})
return new Map(sortedEntries)
// Pour chaque année, trier les périodes
sortedYears.forEach(([_, periods]) => {
const sortedPeriods = Array.from(periods.entries()).sort((a, b) => {
if (a[0] === 'Sans période') return 1
if (b[0] === 'Sans période') return -1
return b[0].localeCompare(a[0])
})
periods.clear()
sortedPeriods.forEach(([period, docs]) => periods.set(period, docs))
})
return new Map(sortedYears)
}, [documentsCompta])
const toggleYear = (year: string) => {
setExpandedYears(prev => {
const next = new Set(prev)
if (next.has(year)) {
next.delete(year)
} else {
next.add(year)
}
return next
})
}
const togglePeriod = (period: string) => {
setExpandedPeriods(prev => {
const next = new Set(prev)
@ -274,17 +450,24 @@ function SectionComptables() {
next.delete(period)
} else {
next.add(period)
// Charger les URLs pour cette période
setLoadedPeriods(loaded => new Set([...loaded, period]))
}
return next
})
}
const handleDownload = (item: DocumentItem) => {
if (item.url) {
window.open(item.url, '_blank')
} else {
alert('Document non disponible')
const handleDownload = (item: DocumentItem, period: string) => {
// Chercher l'URL dans les données chargées
const docsWithUrls = periodUrls[period]
if (docsWithUrls) {
const docWithUrl = docsWithUrls.find(d => d.id === item.id)
if (docWithUrl?.url) {
window.open(docWithUrl.url, '_blank')
return
}
}
alert('Document non disponible')
}
if (isLoading) {
@ -305,56 +488,97 @@ function SectionComptables() {
return (
<div className="space-y-3">
{Array.from(documentsByPeriod.entries()).map(([period, docs]) => {
const isExpanded = expandedPeriods.has(period)
{Array.from(documentsByYear.entries()).map(([year, periods]) => {
const isYearExpanded = expandedYears.has(year)
const totalDocs = Array.from(periods.values()).reduce((sum, docs: DocumentItem[]) => sum + docs.length, 0)
return (
<div key={period} className="border rounded-lg overflow-hidden">
{/* Header de la période - cliquable */}
<div key={year} className="border rounded-lg overflow-hidden">
{/* Header de l'année - cliquable */}
<button
onClick={() => togglePeriod(period)}
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
onClick={() => toggleYear(year)}
className="w-full flex items-center justify-between p-4 bg-muted/50 hover:bg-muted/70 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
{isYearExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
<div className="text-left">
<h3 className="font-semibold text-base">{period}</h3>
<h3 className="font-bold text-lg">{year}</h3>
<p className="text-sm text-muted-foreground">
{docs.length} document{docs.length > 1 ? 's' : ''}
{totalDocs} document{totalDocs > 1 ? 's' : ''} {periods.size} période{periods.size > 1 ? 's' : ''}
</p>
</div>
</div>
</button>
{/* Liste des documents - affichée si expanded */}
{isExpanded && (
<div className="p-2 space-y-2 bg-background">
{docs.map((item: DocumentItem) => (
<div
key={item.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/30 transition-colors"
>
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate">{item.title}</h4>
<p className="text-sm text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
{/* Liste des périodes pour cette année */}
{isYearExpanded && (
<div className="bg-background">
{Array.from(periods.entries()).map(([period, docs]: [string, DocumentItem[]]) => {
const isPeriodExpanded = expandedPeriods.has(period)
const isLoading = isPeriodExpanded && loadedPeriods.has(period) && !periodUrls[period]
return (
<div key={period} className="border-t">
{/* Header de la période */}
<button
onClick={() => togglePeriod(period)}
className="w-full flex items-center justify-between p-3 pl-12 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-3">
{isPeriodExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div className="text-left">
<h4 className="font-semibold text-sm">{period}</h4>
<p className="text-xs text-muted-foreground">
{docs.length} document{docs.length > 1 ? 's' : ''}
</p>
</div>
</div>
</button>
{/* Liste des documents */}
{isPeriodExpanded && (
<div className="p-2 pl-12 space-y-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-blue-600 animate-spin" />
</div>
) : (
docs.map((item: DocumentItem) => (
<div
key={item.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/20 transition-colors"
>
<div className="flex-1 min-w-0">
<h5 className="font-medium text-sm truncate">{item.title}</h5>
<p className="text-xs text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
</div>
<Button
size="sm"
onClick={() => handleDownload(item, period)}
disabled={isLoading}
className="ml-4"
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))
)}
</div>
)}
</div>
<Button
size="sm"
onClick={() => handleDownload(item)}
disabled={!item.url}
className="ml-4"
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))}
)
})}
</div>
)}
</div>
@ -367,13 +591,134 @@ function SectionComptables() {
export default function VosDocumentsPage() {
usePageTitle("Vos documents");
const [activeTab, setActiveTab] = React.useState('generaux');
const [selectedOrgId, setSelectedOrgId] = React.useState<string>('');
const [isStaff, setIsStaff] = React.useState(false);
const [isCheckingStaff, setIsCheckingStaff] = React.useState(true);
// Récupérer les informations de l'utilisateur et vérifier s'il est staff via API
React.useEffect(() => {
const checkStaffStatus = async () => {
setIsCheckingStaff(true);
try {
const response = await fetch('/api/me');
if (response.ok) {
const data = await response.json();
setIsStaff(data.is_staff === true);
} else {
setIsStaff(false);
}
} catch (error) {
setIsStaff(false);
} finally {
setIsCheckingStaff(false);
}
};
checkStaffStatus();
}, []);
// Récupérer la liste des organisations (pour staff uniquement)
const { data: organizations, isLoading: isLoadingOrgs } = useQuery<Organization[]>({
queryKey: ['organizations', 'all'],
queryFn: async () => {
const response = await fetch('/api/organizations');
if (!response.ok) {
throw new Error('Failed to fetch organizations');
}
const data = await response.json();
// L'API retourne { items: [...] }
const items = data.items || [];
// Trier par nom et mapper au bon format
const sorted = items
.map((org: any) => ({
id: org.id,
name: org.name,
key: org.structure_api || org.key
}))
.sort((a: Organization, b: Organization) =>
a.name.localeCompare(b.name)
);
return sorted;
},
enabled: isStaff && !isCheckingStaff
});
// Mettre à jour le cookie active_org_id quand l'organisation sélectionnée change
React.useEffect(() => {
if (selectedOrgId && isStaff) {
const selectedOrg = organizations?.find(org => org.id === selectedOrgId);
if (selectedOrg) {
// Mettre à jour les cookies
document.cookie = `active_org_id=${selectedOrgId}; path=/; max-age=31536000`;
document.cookie = `active_org_name=${encodeURIComponent(selectedOrg.name)}; path=/; max-age=31536000`;
document.cookie = `active_org_key=${selectedOrg.key}; path=/; max-age=31536000`;
}
}
}, [selectedOrgId, isStaff, organizations]);
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Vos documents</h2>
{isStaff && (
<span className="text-sm bg-blue-100 text-blue-800 px-3 py-1 rounded-full">
Mode Staff
</span>
)}
</header>
{/* Sélecteur d'organisation pour le staff */}
{isStaff && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
Sélectionner une organisation
</CardTitle>
<CardDescription>
Choisissez l'organisation dont vous souhaitez consulter les documents
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingOrgs ? (
<p className="text-sm text-gray-500">Chargement des organisations...</p>
) : organizations && organizations.length > 0 ? (
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">-- Sélectionner une organisation --</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
) : (
<p className="text-sm text-amber-600">Aucune organisation trouvée</p>
)}
</CardContent>
</Card>
)}
{/* Message si staff sans organisation sélectionnée */}
{isStaff && !selectedOrgId && !isLoadingOrgs && (
<Card className="border-amber-200 bg-amber-50">
<CardContent className="pt-6">
<p className="text-sm text-amber-800">
Veuillez sélectionner une organisation pour afficher ses documents
</p>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Colonne gauche : Documents disponibles */}
<div className="lg:col-span-2">
@ -382,11 +727,11 @@ export default function VosDocumentsPage() {
<CardTitle>Documents disponibles</CardTitle>
<CardDescription>
{activeTab === 'generaux' && 'Téléchargez vos documents généraux'}
{activeTab === 'comptables' && 'Téléchargez vos documents comptables et sociaux'}
{activeTab === 'comptables' && 'Téléchargez vos documents comptables'}
</CardDescription>
</CardHeader>
<CardContent>
{activeTab === 'generaux' && <SectionGeneraux />}
{activeTab === 'generaux' && <SectionGeneraux selectedOrgId={selectedOrgId} />}
{activeTab === 'comptables' && <SectionComptables />}
</CardContent>
</Card>

View file

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { createSbServiceRole } from "@/lib/supabaseServer";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
export async function DELETE(req: NextRequest) {
try {
const body = await req.json();
const { token, fileKey } = body;
if (!token || !fileKey) {
return NextResponse.json({
error: "Token et clé du fichier requis"
}, { status: 400 });
}
// Vérifier le token et récupérer les infos du salarié
const supabase = createSbServiceRole();
const { data: tokenData, error: tokenError } = await supabase
.from('auto_declaration_tokens')
.select(`
salarie_id,
expires_at,
used,
salaries (
code_salarie
)
`)
.eq('token', token)
.single();
if (tokenError || !tokenData) {
return NextResponse.json({ error: "Token d'accès invalide" }, { status: 403 });
}
// Vérifier l'expiration du token
if (new Date() > new Date(tokenData.expires_at)) {
return NextResponse.json({ error: "Token d'accès expiré" }, { status: 403 });
}
const matricule = (tokenData.salaries as any)?.code_salarie;
if (!matricule) {
return NextResponse.json({ error: "Matricule du salarié non trouvé" }, { status: 404 });
}
// Vérifier que le fichier appartient bien à ce salarié
if (!fileKey.startsWith(`justif-salaries/${matricule}/`)) {
return NextResponse.json({
error: "Vous n'êtes pas autorisé à supprimer ce fichier"
}, { status: 403 });
}
// Supprimer le fichier de S3
const deleteCommand = new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
});
await s3Client.send(deleteCommand);
return NextResponse.json({
success: true,
message: "Fichier supprimé avec succès"
});
} catch (error) {
console.error("Erreur suppression document S3:", error);
return NextResponse.json(
{ error: "Erreur lors de la suppression du document" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { createSbServiceRole } from "@/lib/supabaseServer";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
// Mapping des noms de fichiers vers des libellés lisibles
const FILE_TYPE_LABELS: Record<string, string> = {
"piece-identite": "Pièce d'identité",
"attestation-secu": "Attestation Sécurité Sociale",
"rib": "RIB",
"medecine-travail": "Médecine du travail",
"autre": "Autre document"
};
interface S3Document {
key: string;
name: string;
type: string;
size: number;
lastModified: string;
downloadUrl: string;
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.json({
error: "Token d'accès manquant"
}, { status: 400 });
}
// Vérifier le token et récupérer les infos du salarié
const supabase = createSbServiceRole();
const { data: tokenData, error: tokenError } = await supabase
.from('auto_declaration_tokens')
.select(`
salarie_id,
expires_at,
used,
salaries (
code_salarie
)
`)
.eq('token', token)
.single();
if (tokenError || !tokenData) {
return NextResponse.json({ error: "Token d'accès invalide" }, { status: 403 });
}
// Vérifier l'expiration du token
if (new Date() > new Date(tokenData.expires_at)) {
return NextResponse.json({ error: "Token d'accès expiré" }, { status: 403 });
}
const matricule = (tokenData.salaries as any)?.code_salarie;
if (!matricule) {
return NextResponse.json({ error: "Matricule du salarié non trouvé" }, { status: 404 });
}
// Construire le préfixe pour les fichiers de ce salarié
const prefix = `justif-salaries/${matricule}/`;
// Lister les fichiers dans S3
const listCommand = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
});
const listResponse = await s3Client.send(listCommand);
if (!listResponse.Contents || listResponse.Contents.length === 0) {
return NextResponse.json({
documents: [],
count: 0,
matricule
});
}
// Générer des URLs pré-signées pour chaque fichier
const documents = await Promise.all(
listResponse.Contents.map(async (item) => {
if (!item.Key) return null;
// Générer une URL pré-signée valide 1 heure
const getCommand = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: item.Key,
});
const signedUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: 3600 // 1 heure
});
// Extraire le nom du fichier
const fileName = item.Key.split('/').pop() || item.Key;
// Déterminer le type de document basé sur le nom
let documentType = "Autre document";
for (const [typeKey, label] of Object.entries(FILE_TYPE_LABELS)) {
if (fileName.toLowerCase().includes(typeKey)) {
documentType = label;
break;
}
}
return {
key: item.Key,
name: fileName,
type: documentType,
size: item.Size || 0,
lastModified: item.LastModified?.toISOString() || new Date().toISOString(),
downloadUrl: signedUrl
};
})
);
// Filtrer les nulls
const validDocuments = documents.filter(doc => doc !== null) as S3Document[];
return NextResponse.json({
documents: validDocuments,
count: validDocuments.length,
matricule
});
} catch (error) {
console.error("Erreur récupération documents S3:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des documents" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
// Fonction pour slugifier les noms (enlever accents, espaces, etc.)
function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Enlever les accents
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-') // Remplacer les caractères spéciaux par des tirets
.replace(/^-+|-+$/g, ''); // Enlever les tirets en début/fin
}
// Types de documents généraux
const DOC_TYPES = {
"contrat-odentas": "Contrat Odentas",
"licence-spectacles": "Licence de spectacles",
"rib": "RIB",
"kbis-jo": "KBIS / Journal Officiel",
"delegation-signature": "Délégation de signature"
};
interface GeneralDocument {
type: string;
label: string;
available: boolean;
key?: string;
name?: string;
size?: number;
lastModified?: string;
downloadUrl?: string;
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const orgId = searchParams.get('org_id');
if (!orgId) {
return NextResponse.json({
error: "Organization ID requis"
}, { status: 400 });
}
// Vérifier l'authentification
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({
error: "Non authentifié"
}, { status: 401 });
}
// Récupérer la clé de l'organisation (structure_api)
const { data: org, error: orgError } = await sb
.from('organizations')
.select('structure_api')
.eq('id', orgId)
.single();
if (orgError || !org?.structure_api) {
return NextResponse.json({
error: "Organisation non trouvée"
}, { status: 404 });
}
const orgKey = slugify(org.structure_api);
const prefix = `documents/${orgKey}/docs-generaux/`;
// Lister les fichiers dans S3
const listCommand = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
});
const listResponse = await s3Client.send(listCommand);
// Initialiser tous les types de documents
const documents: GeneralDocument[] = Object.entries(DOC_TYPES).map(([type, label]) => ({
type,
label,
available: false
}));
// Si des fichiers existent, les associer aux types correspondants
if (listResponse.Contents && listResponse.Contents.length > 0) {
for (const item of listResponse.Contents) {
if (!item.Key) continue;
const fileName = item.Key.split('/').pop() || '';
// Ignorer les fichiers système comme .DS_Store
if (fileName.startsWith('.') || fileName === '.DS_Store') {
continue;
}
// Déterminer le type de document basé sur le préfixe du nom de fichier
for (const [type, label] of Object.entries(DOC_TYPES)) {
if (fileName.toLowerCase().startsWith(type)) {
// Générer une URL pré-signée valide 1 heure
const getCommand = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: item.Key,
});
const signedUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: 3600 // 1 heure
});
// Trouver le document correspondant et le mettre à jour
const docIndex = documents.findIndex(d => d.type === type);
if (docIndex !== -1) {
documents[docIndex] = {
type,
label,
available: true,
key: item.Key,
name: fileName,
size: item.Size || 0,
lastModified: item.LastModified?.toISOString() || new Date().toISOString(),
downloadUrl: signedUrl
};
}
break;
}
}
}
}
return NextResponse.json({
documents,
orgKey
});
} catch (error) {
console.error("Erreur récupération documents généraux S3:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des documents" },
{ status: 500 }
);
}
}

View file

@ -17,12 +17,14 @@ export async function GET(req: Request) {
// 1) Récupérer la catégorie depuis les query params
const { searchParams } = new URL(req.url);
const category = searchParams.get("category");
const metadataOnly = searchParams.get("metadata_only") === "true";
const period = searchParams.get("period");
if (!category) {
return json(400, { error: "missing_category_parameter" });
}
console.log('📄 Documents API - Category:', category);
console.log('📄 Documents API - Category:', category, 'Metadata only:', metadataOnly, 'Period:', period);
// 2) Déterminer l'organisation active
let orgId = c.get("active_org_id")?.value || "";
@ -84,12 +86,18 @@ export async function GET(req: Request) {
// 4) Récupérer les documents depuis Supabase avec RLS
console.log('📄 Documents API - Fetching from Supabase with org_id:', orgId, 'category:', category);
const { data: documents, error } = await sb
let query = sb
.from("documents")
.select("*")
.eq("org_id", orgId)
.eq("category", category)
.order("date_added", { ascending: false });
.eq("category", category);
// Filtrer par période si spécifié
if (period) {
query = query.eq("period_label", period);
}
const { data: documents, error } = await query.order("date_added", { ascending: false });
if (error) {
console.error('📄 Documents API - Supabase Error:', error);
@ -98,35 +106,43 @@ export async function GET(req: Request) {
console.log('📄 Documents API - Found documents:', documents?.length || 0);
// 5) Transformer les documents au format attendé par le frontend avec URLs S3 présignées
// 5) Transformer les documents au format attendé par le frontend
// Si metadata_only=true, ne pas générer les URLs pré-signées S3
// Exclure les fichiers .DS_Store et autres fichiers système
const formattedDocuments = await Promise.all(
(documents || []).map(async (doc) => {
let presignedUrl: string | null = null;
(documents || [])
.filter(doc => {
// Exclure les fichiers .DS_Store et autres fichiers système
const filename = doc.filename || '';
return !filename.startsWith('.') && filename !== '.DS_Store';
})
.map(async (doc) => {
let presignedUrl: string | null = null;
// Générer l'URL S3 présignée si storage_path existe
if (doc.storage_path) {
try {
presignedUrl = await getS3SignedUrl(doc.storage_path, 3600); // Expire dans 1 heure
console.log('✅ Generated presigned URL for:', doc.filename);
} catch (error) {
console.error('❌ Error generating presigned URL for:', doc.filename, error);
// Générer l'URL S3 présignée seulement si demandé (pas en mode metadata_only)
if (!metadataOnly && doc.storage_path) {
try {
presignedUrl = await getS3SignedUrl(doc.storage_path, 3600); // Expire dans 1 heure
console.log('✅ Generated presigned URL for:', doc.filename);
} catch (error) {
console.error('❌ Error generating presigned URL for:', doc.filename, error);
}
}
}
return {
id: doc.id,
title: doc.filename || doc.type_label || 'Document',
url: presignedUrl, // URL S3 présignée prête à l'emploi
updatedAt: doc.date_added,
sizeBytes: doc.size_bytes || 0,
period_label: doc.period_label,
meta: {
category: doc.category,
type_label: doc.type_label,
storage_path: doc.storage_path, // Garder le path original pour référence
}
};
})
return {
id: doc.id,
title: doc.filename || doc.type_label || 'Document',
url: presignedUrl, // null si metadata_only=true
updatedAt: doc.date_added,
sizeBytes: doc.size_bytes || 0,
period_label: doc.period_label,
meta: {
category: doc.category,
type_label: doc.type_label,
storage_path: doc.storage_path, // Garder le path original pour référence
}
};
})
);
console.log('📄 Documents API - Returning formatted documents:', formattedDocuments.length);

View file

@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { S3Client, ListObjectsV2Command, DeleteObjectCommand } from "@aws-sdk/client-s3";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
export async function DELETE(req: NextRequest) {
try {
const sb = createRouteHandlerClient({ cookies });
// Vérifier que l'utilisateur est staff
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.single();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const orgKey = searchParams.get('org_key');
const docType = searchParams.get('doc_type');
if (!orgKey || !docType) {
return NextResponse.json({ error: "Paramètres manquants" }, { status: 400 });
}
// Lister les fichiers avec ce préfixe
const prefix = `documents/${orgKey}/docs-generaux/${docType}_`;
const listCommand = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
});
const listResponse = await s3Client.send(listCommand);
const files = listResponse.Contents || [];
// Supprimer tous les fichiers trouvés
const deletePromises = files.map(file => {
if (file.Key) {
const deleteCommand = new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: file.Key,
});
return s3Client.send(deleteCommand);
}
return Promise.resolve();
});
await Promise.all(deletePromises);
return NextResponse.json({
success: true,
deletedCount: files.length
});
} catch (error) {
console.error("Erreur suppression document général:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
export async function DELETE(req: NextRequest) {
try {
const sb = createRouteHandlerClient({ cookies });
// Vérifier que l'utilisateur est staff
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.single();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const docId = searchParams.get('doc_id');
if (!docId) {
return NextResponse.json({ error: "doc_id manquant" }, { status: 400 });
}
// Récupérer le document pour obtenir le storage_path
const { data: doc, error: fetchError } = await sb
.from('documents')
.select('storage_path')
.eq('id', docId)
.single();
if (fetchError || !doc) {
return NextResponse.json({ error: "Document non trouvé" }, { status: 404 });
}
// Supprimer de S3
if (doc.storage_path) {
const deleteCommand = new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: doc.storage_path,
});
await s3Client.send(deleteCommand);
}
// Supprimer de la base de données
const { error: deleteError } = await sb
.from('documents')
.delete()
.eq('id', docId);
if (deleteError) {
console.error('Erreur suppression Supabase:', deleteError);
return NextResponse.json({ error: "Erreur base de données" }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Erreur suppression document comptable:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
export async function GET(req: NextRequest) {
try {
const sb = createRouteHandlerClient({ cookies });
// Vérifier que l'utilisateur est staff
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.single();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const orgId = searchParams.get('org_id');
const category = searchParams.get('category');
if (!orgId || !category) {
return NextResponse.json({ error: "Paramètres manquants" }, { status: 400 });
}
// Récupérer les documents depuis Supabase
let query = sb
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('category', category)
.order('date_added', { ascending: false });
const { data: documents, error } = await query;
if (error) {
console.error('Erreur requête Supabase:', error);
return NextResponse.json({ error: "Erreur base de données" }, { status: 500 });
}
// Générer les URLs présignées pour chaque document
const documentsWithUrls = await Promise.all(
(documents || []).map(async (doc) => {
if (doc.storage_path) {
try {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: doc.storage_path,
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return { ...doc, download_url: url };
} catch (err) {
console.error(`Erreur génération URL pour ${doc.storage_path}:`, err);
return doc;
}
}
return doc;
})
);
return NextResponse.json({ documents: documentsWithUrls });
} catch (error) {
console.error("Erreur liste documents:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { v4 as uuidv4 } from 'uuid';
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
export async function POST(req: NextRequest) {
try {
const sb = createRouteHandlerClient({ cookies });
// Vérifier que l'utilisateur est staff
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.single();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Parser le form data
const formData = await req.formData();
const file = formData.get('file') as File;
const orgId = formData.get('org_id') as string;
const orgKey = formData.get('org_key') as string;
const category = formData.get('category') as string;
const docType = formData.get('doc_type') as string | null;
const period = formData.get('period') as string | null;
if (!file || !orgId || !orgKey || !category) {
return NextResponse.json({ error: "Paramètres manquants" }, { status: 400 });
}
// Générer le path S3
let s3Key: string;
let filename: string;
if (category === 'docs_generaux') {
// Documents généraux: documents/{org_key}/docs-generaux/{doc_type}_{uuid}.pdf
if (!docType) {
return NextResponse.json({ error: "doc_type requis pour docs_generaux" }, { status: 400 });
}
const uniqueId = uuidv4().replace(/-/g, '').substring(0, 16);
filename = `${docType}_${uniqueId}.pdf`;
s3Key = `documents/${orgKey}/docs-generaux/${filename}`;
} else if (category === 'docs_comptables') {
// Documents comptables: documents/{org_key}/docs_comptables/{period}/{filename}
if (!period) {
return NextResponse.json({ error: "period requis pour docs_comptables" }, { status: 400 });
}
filename = file.name;
s3Key = `documents/${orgKey}/docs_comptables/${period}/${filename}`;
} else {
return NextResponse.json({ error: "Catégorie invalide" }, { status: 400 });
}
// Upload vers S3
const buffer = Buffer.from(await file.arrayBuffer());
const uploadCommand = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: s3Key,
Body: buffer,
ContentType: file.type,
});
await s3Client.send(uploadCommand);
// Créer l'entrée dans Supabase
const { error: insertError } = await sb
.from('documents')
.insert({
org_id: orgId,
category: category,
type_label: docType || 'Document',
filename: filename,
storage_path: s3Key,
size_bytes: file.size,
period_label: period,
date_added: new Date().toISOString(),
});
if (insertError) {
console.error('Erreur insertion Supabase:', insertError);
return NextResponse.json({ error: "Erreur base de données" }, { status: 500 });
}
return NextResponse.json({
success: true,
filename,
s3Key
});
} catch (error) {
console.error("Erreur upload document:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { createSbServiceRole } from "@/lib/supabaseServer";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const key = searchParams.get('key');
if (!key) {
return NextResponse.json({
error: "Clé S3 manquante"
}, { status: 400 });
}
// Vérifier que l'utilisateur est staff
const supabase = createSbServiceRole();
// Vérifier que la clé est bien dans le dossier justif-salaries
if (!key.startsWith('justif-salaries/')) {
return NextResponse.json({
error: "Clé S3 invalide"
}, { status: 400 });
}
// Supprimer le fichier de S3
const deleteCommand = new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
});
await s3Client.send(deleteCommand);
return NextResponse.json({
success: true,
message: "Document supprimé avec succès"
});
} catch (error) {
console.error("Erreur suppression document S3:", error);
return NextResponse.json(
{ error: "Erreur lors de la suppression du document" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { createSbServiceRole } from "@/lib/supabaseServer";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
// Mapping des noms de fichiers vers des libellés lisibles
const FILE_TYPE_LABELS: Record<string, string> = {
"piece-identite": "Pièce d'identité",
"attestation-secu": "Attestation Sécurité Sociale",
"rib": "RIB",
"medecine-travail": "Médecine du travail",
"autre": "Autre document"
};
interface S3Document {
key: string;
name: string;
type: string;
size: number;
lastModified: string;
downloadUrl: string;
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const matricule = searchParams.get('matricule');
if (!matricule) {
return NextResponse.json({
error: "Matricule requis"
}, { status: 400 });
}
// Vérifier que l'utilisateur est staff
const supabase = createSbServiceRole();
// Construire le préfixe pour les fichiers de ce salarié
const prefix = `justif-salaries/${matricule}/`;
// Lister les fichiers dans S3
const listCommand = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
});
const listResponse = await s3Client.send(listCommand);
if (!listResponse.Contents || listResponse.Contents.length === 0) {
return NextResponse.json({
documents: [],
count: 0
});
}
// Générer des URLs pré-signées pour chaque fichier
const documents = await Promise.all(
listResponse.Contents.map(async (item) => {
if (!item.Key) return null;
// Générer une URL pré-signée valide 1 heure
const getCommand = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: item.Key,
});
const signedUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: 3600 // 1 heure
});
// Extraire le nom du fichier
const fileName = item.Key.split('/').pop() || item.Key;
// Déterminer le type de document basé sur le nom
let documentType = "Autre document";
for (const [typeKey, label] of Object.entries(FILE_TYPE_LABELS)) {
if (fileName.toLowerCase().includes(typeKey)) {
documentType = label;
break;
}
}
return {
key: item.Key,
name: fileName,
type: documentType,
size: item.Size || 0,
lastModified: item.LastModified?.toISOString() || new Date().toISOString(),
downloadUrl: signedUrl
};
})
);
// Filtrer les nulls
const validDocuments = documents.filter(doc => doc !== null) as S3Document[];
return NextResponse.json({
documents: validDocuments,
count: validDocuments.length,
matricule
});
} catch (error) {
console.error("Erreur récupération documents S3:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des documents" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, CopyObjectCommand, DeleteObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { createSbServiceRole } from "@/lib/supabaseServer";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
const FILE_TYPE_NAMES: Record<string, string> = {
piece_identite: "piece-identite",
attestation_secu: "attestation-secu",
rib: "rib",
medecine_travail: "medecine-travail",
autre: "autre",
contrat_travail: "contrat-travail",
diplome: "diplome",
justificatif: "justificatif"
};
export async function PATCH(req: NextRequest) {
try {
const body = await req.json();
const { key, newType } = body;
if (!key) {
return NextResponse.json({
error: "Clé S3 manquante"
}, { status: 400 });
}
if (!newType || !FILE_TYPE_NAMES[newType]) {
return NextResponse.json({
error: "Type de document invalide"
}, { status: 400 });
}
// Vérifier que l'utilisateur est staff
const supabase = createSbServiceRole();
// Vérifier que la clé est bien dans le dossier justif-salaries
if (!key.startsWith('justif-salaries/')) {
return NextResponse.json({
error: "Clé S3 invalide"
}, { status: 400 });
}
// Récupérer les métadonnées actuelles
const headCommand = new HeadObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
});
const headResponse = await s3Client.send(headCommand);
// Construire la nouvelle clé avec le nouveau type
const pathParts = key.split('/');
const matricule = pathParts[1];
const oldFileName = pathParts[2];
const fileExtension = oldFileName.split('.').pop() || '';
const newFileName = `${FILE_TYPE_NAMES[newType]}-${Date.now()}.${fileExtension}`;
const newKey = `justif-salaries/${matricule}/${newFileName}`;
// Copier l'objet avec les nouvelles métadonnées
const copyCommand = new CopyObjectCommand({
Bucket: BUCKET_NAME,
CopySource: `${BUCKET_NAME}/${key}`,
Key: newKey,
ContentType: headResponse.ContentType,
Metadata: {
...headResponse.Metadata,
'type': newType,
'updated-at': new Date().toISOString()
},
MetadataDirective: 'REPLACE'
});
await s3Client.send(copyCommand);
// Supprimer l'ancien fichier
const deleteCommand = new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
});
await s3Client.send(deleteCommand);
return NextResponse.json({
success: true,
newKey,
message: "Type de document mis à jour avec succès"
});
} catch (error) {
console.error("Erreur mise à jour document S3:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour du document" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { createSbServiceRole } from "@/lib/supabaseServer";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
// Types de fichiers autorisés et leurs tailles max
const ALLOWED_FILE_TYPES = {
'image/jpeg': 10 * 1024 * 1024, // 10MB
'image/jpg': 10 * 1024 * 1024, // 10MB
'image/png': 10 * 1024 * 1024, // 10MB
'application/pdf': 10 * 1024 * 1024 // 10MB
};
const FILE_TYPE_NAMES = {
piece_identite: "piece-identite",
attestation_secu: "attestation-secu",
rib: "rib",
medecine_travail: "medecine-travail",
autre: "autre",
contrat_travail: "contrat-travail",
diplome: "diplome",
justificatif: "justificatif"
};
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') as File;
const matricule = formData.get('matricule') as string;
const type = formData.get('type') as string;
if (!file) {
return NextResponse.json({ error: "Aucun fichier fourni" }, { status: 400 });
}
if (!matricule) {
return NextResponse.json({ error: "Matricule du salarié manquant" }, { status: 400 });
}
if (!type || !FILE_TYPE_NAMES[type as keyof typeof FILE_TYPE_NAMES]) {
return NextResponse.json({ error: "Type de fichier invalide" }, { status: 400 });
}
// Vérifier que l'utilisateur est staff
const supabase = createSbServiceRole();
// Vérifier que le salarié existe
const { data: salarie, error: salarieError } = await supabase
.from('salaries')
.select('id, code_salarie')
.eq('code_salarie', matricule)
.single();
if (salarieError || !salarie) {
return NextResponse.json({ error: "Salarié non trouvé" }, { status: 404 });
}
// Vérifier le type de fichier
if (!ALLOWED_FILE_TYPES[file.type as keyof typeof ALLOWED_FILE_TYPES]) {
return NextResponse.json({
error: "Type de fichier non autorisé. Seuls les fichiers PDF, JPG et PNG sont acceptés"
}, { status: 400 });
}
// Vérifier la taille du fichier
const maxSize = ALLOWED_FILE_TYPES[file.type as keyof typeof ALLOWED_FILE_TYPES];
if (file.size > maxSize) {
return NextResponse.json({
error: `Fichier trop volumineux. Taille maximum autorisée : ${maxSize / (1024 * 1024)}MB`
}, { status: 400 });
}
// Générer un nom de fichier unique
const fileExtension = file.name.split('.').pop() || '';
const fileName = `${FILE_TYPE_NAMES[type as keyof typeof FILE_TYPE_NAMES]}-${Date.now()}.${fileExtension}`;
const s3Key = `justif-salaries/${matricule}/${fileName}`;
// Convertir le fichier en buffer
const buffer = Buffer.from(await file.arrayBuffer());
// Upload vers S3
const uploadCommand = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: s3Key,
Body: buffer,
ContentType: file.type,
Metadata: {
'matricule': matricule,
'type': type,
'original-filename': file.name,
'uploaded-at': new Date().toISOString(),
'uploaded-by': 'staff'
}
});
await s3Client.send(uploadCommand);
// Construire l'URL du fichier
const fileUrl = `https://${BUCKET_NAME}.s3.eu-west-3.amazonaws.com/${s3Key}`;
return NextResponse.json({
success: true,
key: s3Key,
url: fileUrl,
filename: fileName,
originalName: file.name,
type: type,
matricule: matricule
});
} catch (error) {
console.error("Erreur upload S3 (staff):", error);
return NextResponse.json(
{ error: "Erreur lors du téléchargement du fichier" },
{ status: 500 }
);
}
}

View file

@ -46,7 +46,7 @@ export async function GET(req: NextRequest) {
compte_transat, topaze, justificatifs_personnels, rf_au_sens_fiscal, intermittent_mineur_16,
adresse_mail, nir, conges_spectacles, tel, adresse, date_naissance, lieu_de_naissance,
iban, bic, abattement_2024, infos_caisses_organismes, notif_nouveau_salarie, notif_employeur,
derniere_profession, employer_id, created_at, updated_at,
derniere_profession, employer_id, notes, last_notif_justifs, created_at, updated_at,
organizations(name)`,
{ count: "exact" }
);
@ -153,6 +153,8 @@ export async function GET(req: NextRequest) {
notif_employeur: r.notif_employeur,
derniere_profession: r.derniere_profession,
employer_id: r.employer_id,
notes: r.notes,
last_notif_justifs: r.last_notif_justifs,
updated_at: r.updated_at,
};
});

View file

@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
'rf_au_sens_fiscal', 'intermittent_mineur_16', 'adresse_mail', 'nir',
'conges_spectacles', 'tel', 'adresse', 'date_naissance', 'lieu_de_naissance',
'iban', 'bic', 'abattement_2024', 'infos_caisses_organismes', 'num_salarie',
'notif_nouveau_salarie', 'notif_employeur', 'derniere_profession'
'notif_nouveau_salarie', 'notif_employeur', 'derniere_profession', 'notes'
];
// Filter updates to only include allowed fields

View file

@ -7,8 +7,10 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Upload, File, X, Save, Loader2, CheckCircle, AlertCircle, Check } from "lucide-react";
import { Upload, File, X, Save, Loader2, CheckCircle, AlertCircle, Check, FileText, Eye } from "lucide-react";
import { toast } from "sonner";
import { DocumentPreviewModal } from "@/components/DocumentPreviewModal";
import { SaveConfirmationModal } from "@/components/SaveConfirmationModal";
// Helper components matching the design of salaries/nouveau
function LabelComponent({ children, required = false }: { children: React.ReactNode; required?: boolean }) {
@ -148,6 +150,15 @@ interface FormData {
protection_donnees: boolean;
}
interface S3Document {
key: string;
name: string;
type: string;
size: number;
lastModified: string;
downloadUrl: string;
}
export default function AutoDeclarationPage() {
const searchParams = useSearchParams();
const token = searchParams.get('token'); // Changé de 'matricule' à 'token'
@ -156,6 +167,12 @@ export default function AutoDeclarationPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [existingDocuments, setExistingDocuments] = useState<S3Document[]>([]);
const [loadingDocuments, setLoadingDocuments] = useState(true);
const [previewDocument, setPreviewDocument] = useState<S3Document | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
const [missingItems, setMissingItems] = useState<string[]>([]);
const [formData, setFormData] = useState<FormData>({
// Identité
@ -239,6 +256,30 @@ export default function AutoDeclarationPage() {
fetchSalarieData();
}, [token]); // Changé de 'matricule' à 'token'
// Récupérer les documents existants depuis S3
useEffect(() => {
const fetchDocuments = async () => {
if (!token) {
setLoadingDocuments(false);
return;
}
try {
const response = await fetch(`/api/auto-declaration/documents?token=${encodeURIComponent(token)}`);
if (response.ok) {
const data = await response.json();
setExistingDocuments(data.documents || []);
}
} catch (error) {
console.error('Erreur lors de la récupération des documents:', error);
} finally {
setLoadingDocuments(false);
}
};
fetchDocuments();
}, [token]);
const handleFileUpload = async (type: keyof FormData, file: File) => {
if (!file || !token) return; // Changé de 'matricule' à 'token'
@ -269,6 +310,13 @@ export default function AutoDeclarationPage() {
toast.success(`${getFileTypeLabel(type)} téléchargé avec succès`);
// Rafraîchir la liste des documents
const documentsResponse = await fetch(`/api/auto-declaration/documents?token=${encodeURIComponent(token)}`);
if (documentsResponse.ok) {
const data = await documentsResponse.json();
setExistingDocuments(data.documents || []);
}
} catch (error) {
console.error('Erreur upload:', error);
toast.error(`Erreur lors du téléchargement : ${error instanceof Error ? error.message : 'erreur inconnue'}`);
@ -284,6 +332,77 @@ export default function AutoDeclarationPage() {
}));
};
const handleDeleteDocument = async (fileKey: string) => {
if (!token) return;
try {
const response = await fetch('/api/auto-declaration/documents/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
fileKey
})
});
if (!response.ok) {
throw new Error('Erreur lors de la suppression');
}
// Rafraîchir la liste des documents
const documentsResponse = await fetch(`/api/auto-declaration/documents?token=${encodeURIComponent(token)}`);
if (documentsResponse.ok) {
const data = await documentsResponse.json();
setExistingDocuments(data.documents || []);
}
} catch (error) {
console.error('Erreur suppression:', error);
throw error;
}
};
const openPreview = (doc: S3Document) => {
setPreviewDocument(doc);
setIsPreviewOpen(true);
};
const closePreview = () => {
setIsPreviewOpen(false);
setPreviewDocument(null);
};
const checkMissingItems = () => {
const missing: string[] = [];
// Vérifier les champs obligatoires
if (!formData.civilite) missing.push("Civilité");
if (!formData.nom) missing.push("Nom de famille");
if (!formData.prenom) missing.push("Prénom");
if (!formData.email) missing.push("Adresse e-mail");
// Vérifier les documents (nouveaux + existants)
const hasPieceIdentite = formData.piece_identite || existingDocuments.some(doc => doc.name.toLowerCase().includes('piece-identite'));
const hasAttestationSecu = formData.attestation_secu || existingDocuments.some(doc => doc.name.toLowerCase().includes('attestation-secu'));
const hasRib = formData.rib || existingDocuments.some(doc => doc.name.toLowerCase().includes('rib'));
if (!hasPieceIdentite) missing.push("Pièce d'identité");
if (!hasAttestationSecu) missing.push("Attestation de Sécurité Sociale");
if (!hasRib) missing.push("RIB");
// Informations complémentaires
if (!formData.telephone) missing.push("Numéro de téléphone");
if (!formData.adresse) missing.push("Adresse postale");
if (!formData.date_naissance) missing.push("Date de naissance");
if (!formData.lieu_naissance) missing.push("Lieu de naissance");
if (!formData.numero_secu) missing.push("Numéro de Sécurité Sociale");
if (!formData.iban) missing.push("IBAN");
if (!formData.bic) missing.push("BIC/SWIFT");
return missing;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -292,11 +411,6 @@ export default function AutoDeclarationPage() {
return;
}
if (!formData.piece_identite || !formData.attestation_secu || !formData.rib) {
toast.error("Les champs marqués d'un * sont obligatoires");
return;
}
setSaving(true);
try {
@ -331,7 +445,12 @@ export default function AutoDeclarationPage() {
throw new Error('Erreur lors de la sauvegarde');
}
toast.success("Vos informations ont été mises à jour avec succès !");
// Vérifier les éléments manquants
const missing = checkMissingItems();
setMissingItems(missing);
// Ouvrir le modal de confirmation
setIsConfirmationOpen(true);
} catch (error) {
console.error('Erreur sauvegarde:', error);
@ -365,6 +484,27 @@ export default function AutoDeclarationPage() {
const isUploading = uploading === type;
const [isDragging, setIsDragging] = useState(false);
// Trouver les documents existants de ce type
const typeKeywords: Record<string, string> = {
piece_identite: "piece-identite",
attestation_secu: "attestation-secu",
rib: "rib",
medecine_travail: "medecine-travail",
autre: "autre"
};
const existingDocs = existingDocuments.filter(doc =>
doc.name.toLowerCase().includes(typeKeywords[type as keyof typeof typeKeywords] || '')
);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@ -412,6 +552,29 @@ export default function AutoDeclarationPage() {
{label} {required && <span className="text-red-500">*</span>}
</Label>
{/* Documents existants */}
{existingDocs.length > 0 && (
<div className="space-y-2 mb-3">
{existingDocs.map((doc) => (
<button
key={doc.key}
type="button"
onClick={() => openPreview(doc)}
className="w-full flex items-center justify-between p-3 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 transition-colors group"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="h-4 w-4 text-blue-600 flex-shrink-0" />
<div className="text-left min-w-0">
<div className="text-sm text-blue-700 truncate">{doc.name}</div>
<div className="text-xs text-blue-600">{formatFileSize(doc.size)}</div>
</div>
</div>
<Eye className="h-4 w-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</button>
))}
</div>
)}
{file ? (
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-md">
<div className="flex items-center gap-2">
@ -720,7 +883,6 @@ export default function AutoDeclarationPage() {
<FileUploadField
type="piece_identite"
label="Pièce d'identité en cours de validité"
required
/>
<p className="text-[11px] text-slate-500">
Pièces acceptées : Carte Nationale d'Identité recto-verso, Passeport, Permis nouveau format recto-verso,
@ -730,7 +892,6 @@ export default function AutoDeclarationPage() {
<FileUploadField
type="attestation_secu"
label="Attestation de Sécurité Sociale"
required
/>
<p className="text-[11px] text-slate-500">
Ce document est téléchargeable sur votre espace Ameli.
@ -739,7 +900,6 @@ export default function AutoDeclarationPage() {
<FileUploadField
type="rib"
label="Votre RIB"
required
/>
<p className="text-[11px] text-slate-500">
Pour le versement de vos salaires.
@ -799,43 +959,66 @@ export default function AutoDeclarationPage() {
</p>
</div>
<div className="flex items-start gap-3">
<label htmlFor="protection_donnees" className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
id="protection_donnees"
checked={formData.protection_donnees}
onChange={(e) => setFormData(prev => ({ ...prev, protection_donnees: e.target.checked }))}
className="mt-1"
className="mt-1 cursor-pointer"
required
/>
<LabelComponent required>
J'ai lu et compris les informations concernant la protection des données personnelles.
</LabelComponent>
</div>
<span className="text-sm font-medium group-hover:text-gray-700">
J'ai lu et compris les informations concernant la protection des données personnelles. <span className="text-red-500">*</span>
</span>
</label>
</div>
</Section>
{/* Submit */}
<div className="flex justify-center pt-4">
<button
type="submit"
disabled={saving || !formData.protection_donnees}
className="inline-flex items-center gap-2 px-6 py-2 bg-teal-600 hover:bg-teal-700 disabled:bg-gray-400 text-white rounded-lg text-sm font-medium"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="h-4 w-4" />
Envoyer
</>
)}
</button>
</div>
{/* Section Enregistrement */}
<Section title="Enregistrer vos modifications">
<div className="space-y-4">
<p className="text-sm text-gray-600">
Vous pouvez enregistrer vos modifications et revenir plus tard pour compléter les informations manquantes.
Vos données sont sauvegardées de manière sécurisée.
</p>
<div className="flex justify-center">
<button
type="submit"
disabled={saving || !formData.protection_donnees}
className="inline-flex items-center gap-2 px-6 py-3 bg-teal-600 hover:bg-teal-700 disabled:bg-gray-400 text-white rounded-lg text-sm font-medium transition-colors"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="h-4 w-4" />
Enregistrer les modifications
</>
)}
</button>
</div>
</div>
</Section>
</form>
{/* Modal de prévisualisation */}
<DocumentPreviewModal
isOpen={isPreviewOpen}
onClose={closePreview}
document={previewDocument}
onDelete={handleDeleteDocument}
/>
{/* Modal de confirmation */}
<SaveConfirmationModal
isOpen={isConfirmationOpen}
onClose={() => setIsConfirmationOpen(false)}
missingItems={missingItems}
/>
</div>
</div>
);

View file

@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { X, Trash2, Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
interface DocumentPreviewModalProps {
isOpen: boolean;
onClose: () => void;
document: {
key: string;
name: string;
type: string;
size: number;
downloadUrl: string;
} | null;
onDelete: (fileKey: string) => Promise<void>;
}
export function DocumentPreviewModal({
isOpen,
onClose,
document,
onDelete
}: DocumentPreviewModalProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
if (!isOpen || !document) return null;
const isImage = document.name.match(/\.(jpg|jpeg|png)$/i);
const isPdf = document.name.match(/\.pdf$/i);
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDelete(document.key);
toast.success("Document supprimé avec succès");
onClose();
} catch (error) {
toast.error("Erreur lors de la suppression du document");
console.error(error);
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{document.name}</h2>
<div className="flex items-center gap-3 text-sm text-gray-500 mt-1">
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs font-medium">
{document.type}
</span>
<span>{formatFileSize(document.size)}</span>
</div>
</div>
<button
onClick={onClose}
className="ml-4 p-2 hover:bg-gray-200 rounded-lg transition-colors"
aria-label="Fermer"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto bg-gray-100 p-4">
{isImage && (
<div className="flex items-center justify-center h-full">
<img
src={document.downloadUrl}
alt={document.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
/>
</div>
)}
{isPdf && (
<iframe
src={document.downloadUrl}
className="w-full h-full min-h-[600px] rounded-lg shadow-lg bg-white"
title={document.name}
/>
)}
{!isImage && !isPdf && (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-500 mb-4">
Prévisualisation non disponible pour ce type de fichier
</p>
<a
href={document.downloadUrl}
download={document.name}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Download className="h-4 w-4" />
Télécharger le fichier
</a>
</div>
</div>
)}
</div>
{/* Footer with actions */}
<div className="flex items-center justify-between p-4 border-t bg-gray-50">
<a
href={document.downloadUrl}
download={document.name}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Download className="h-4 w-4" />
Télécharger
</a>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
Supprimer
</button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 mr-2">Confirmer la suppression ?</span>
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-200 rounded-lg"
disabled={isDeleting}
>
Annuler
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-red-600 text-white hover:bg-red-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Suppression...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Confirmer
</>
)}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,107 @@
"use client";
import { X, Download } from "lucide-react";
interface DocumentViewModalProps {
isOpen: boolean;
onClose: () => void;
document: {
name: string;
label: string;
downloadUrl: string;
size?: number;
} | null;
}
export function DocumentViewModal({
isOpen,
onClose,
document
}: DocumentViewModalProps) {
if (!isOpen || !document) return null;
const isImage = document.name?.match(/\.(jpg|jpeg|png)$/i);
const isPdf = document.name?.match(/\.pdf$/i);
const formatFileSize = (bytes?: number) => {
if (!bytes && bytes !== 0) return '';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{document.label}</h2>
<div className="flex items-center gap-3 text-sm text-gray-500 mt-1">
<span className="truncate">{document.name}</span>
{document.size && <span>{formatFileSize(document.size)}</span>}
</div>
</div>
<button
onClick={onClose}
className="ml-4 p-2 hover:bg-gray-200 rounded-lg transition-colors"
aria-label="Fermer"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto bg-gray-100 p-4">
{isImage && (
<div className="flex items-center justify-center h-full">
<img
src={document.downloadUrl}
alt={document.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
/>
</div>
)}
{isPdf && (
<iframe
src={document.downloadUrl}
className="w-full h-full min-h-[600px] rounded-lg shadow-lg bg-white"
title={document.name}
/>
)}
{!isImage && !isPdf && (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-500 mb-4">
Prévisualisation non disponible pour ce type de fichier
</p>
<a
href={document.downloadUrl}
download={document.name}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Download className="h-4 w-4" />
Télécharger le fichier
</a>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t bg-gray-50">
<div className="flex-1"></div>
<a
href={document.downloadUrl}
download={document.name}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Download className="h-4 w-4" />
Télécharger
</a>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { CheckCircle, AlertTriangle, X } from "lucide-react";
interface SaveConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
missingItems: string[];
}
export function SaveConfirmationModal({
isOpen,
onClose,
missingItems
}: SaveConfirmationModalProps) {
if (!isOpen) return null;
const isComplete = missingItems.length === 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden">
{/* Header */}
<div className={`p-6 ${isComplete ? 'bg-green-50' : 'bg-blue-50'}`}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{isComplete ? (
<div className="flex-shrink-0 w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
) : (
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-6 w-6 text-blue-600" />
</div>
)}
<div>
<h2 className="text-xl font-semibold text-gray-900">
{isComplete ? 'Dossier complet !' : 'Modifications enregistrées'}
</h2>
<p className="text-sm text-gray-600 mt-1">
{isComplete
? 'Tous les éléments requis ont été fournis'
: 'Vos données ont été sauvegardées avec succès'
}
</p>
</div>
</div>
<button
onClick={onClose}
className="ml-4 p-2 hover:bg-gray-200 rounded-lg transition-colors flex-shrink-0"
aria-label="Fermer"
>
<X className="h-5 w-5 text-gray-500" />
</button>
</div>
</div>
{/* Content */}
<div className="p-6">
{isComplete ? (
<div className="space-y-4">
<p className="text-gray-700">
Merci d'avoir complété votre dossier ! Toutes les informations nécessaires ont é transmises.
</p>
<p className="text-gray-700">
Vous pouvez revenir sur cette page à tout moment via le lien qui vous a é envoyé pour consulter ou modifier vos informations.
</p>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-green-800">
<strong> Dossier complet</strong> - Nous avons toutes les informations nécessaires pour traiter votre embauche.
</p>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-gray-700">
Vous pouvez revenir sur cette page à tout moment via le lien qui vous a é envoyé pour compléter les informations manquantes.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h3 className="font-semibold text-amber-900 mb-2 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Éléments manquants
</h3>
<ul className="space-y-1.5">
{missingItems.map((item, index) => (
<li key={index} className="text-sm text-amber-800 flex items-start gap-2">
<span className="text-amber-600 mt-0.5"></span>
<span>{item}</span>
</li>
))}
</ul>
</div>
<p className="text-sm text-gray-600">
Ces informations sont nécessaires pour finaliser votre dossier d'embauche.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 border-t flex justify-end">
<button
onClick={onClose}
className="px-6 py-2.5 bg-teal-600 hover:bg-teal-700 text-white rounded-lg font-medium transition-colors"
>
J'ai compris
</button>
</div>
</div>
</div>
);
}

View file

@ -469,6 +469,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<span>Logs des emails</span>
</span>
</Link>
<Link href="/staff/documents" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/documents") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des documents">
<span className="inline-flex items-center gap-2">
<FolderOpen className="w-4 h-4" aria-hidden />
<span>Gestion des documents</span>
</span>
</Link>
</div>
)}

View file

@ -0,0 +1,325 @@
"use client";
import { useState } from "react";
import { X, Trash2, Edit3, Download, Loader2, ExternalLink } from "lucide-react";
import { toast } from "sonner";
interface DocumentViewerModalProps {
isOpen: boolean;
onClose: () => void;
document: {
key: string;
name: string;
type: string;
size: number;
lastModified: string;
downloadUrl: string;
} | null;
onDocumentUpdated: () => void;
}
const DOCUMENT_TYPES = [
{ value: "piece_identite", label: "Pièce d'identité" },
{ value: "attestation_secu", label: "Attestation Sécurité Sociale" },
{ value: "rib", label: "RIB" },
{ value: "medecine_travail", label: "Attestation médecine du travail" },
{ value: "contrat_travail", label: "Contrat de travail" },
{ value: "diplome", label: "Diplôme" },
{ value: "justificatif", label: "Justificatif" },
{ value: "autre", label: "Autre document" },
];
// Fonction pour deviner le type à partir du label
const guessTypeFromLabel = (label: string): string => {
const normalizedLabel = label.toLowerCase();
if (normalizedLabel.includes("pièce") || normalizedLabel.includes("identité")) return "piece_identite";
if (normalizedLabel.includes("attestation") && normalizedLabel.includes("sécu")) return "attestation_secu";
if (normalizedLabel.includes("rib")) return "rib";
if (normalizedLabel.includes("médecine") || normalizedLabel.includes("travail")) return "medecine_travail";
if (normalizedLabel.includes("contrat")) return "contrat_travail";
if (normalizedLabel.includes("diplôme") || normalizedLabel.includes("diplome")) return "diplome";
if (normalizedLabel.includes("justificatif")) return "justificatif";
return "autre";
};
export default function DocumentViewerModal({
isOpen,
onClose,
document,
onDocumentUpdated,
}: DocumentViewerModalProps) {
const [isEditingType, setIsEditingType] = useState(false);
const [selectedType, setSelectedType] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
if (!isOpen || !document) return null;
// Déterminer si c'est un PDF ou une image
const isPDF = document.downloadUrl.toLowerCase().includes('.pdf');
const isImage = /\.(jpg|jpeg|png)$/i.test(document.downloadUrl);
const currentType = guessTypeFromLabel(document.type);
const handleUpdateType = async () => {
if (!selectedType || selectedType === currentType) {
setIsEditingType(false);
return;
}
setIsUpdating(true);
try {
const response = await fetch('/api/staff/salaries/documents/update-type', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: document.key,
newType: selectedType,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la mise à jour');
}
toast.success('Type de document mis à jour');
setIsEditingType(false);
onDocumentUpdated();
onClose();
} catch (error) {
console.error('Erreur mise à jour type:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors de la mise à jour');
} finally {
setIsUpdating(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
const response = await fetch(`/api/staff/salaries/documents/delete?key=${encodeURIComponent(document.key)}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la suppression');
}
toast.success('Document supprimé avec succès');
onDocumentUpdated();
onClose();
} catch (error) {
console.error('Erreur suppression:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors de la suppression');
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
const handleStartEdit = () => {
setSelectedType(currentType);
setIsEditingType(true);
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="relative w-full max-w-5xl max-h-[90vh] bg-white rounded-2xl shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b bg-slate-50 rounded-t-2xl">
<div className="flex-1 min-w-0 mr-4">
{isEditingType ? (
<div className="flex items-center gap-2">
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
disabled={isUpdating}
className="px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
>
{DOCUMENT_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<button
onClick={handleUpdateType}
disabled={isUpdating}
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-1"
>
{isUpdating ? (
<>
<Loader2 className="size-4 animate-spin" />
Mise à jour...
</>
) : (
'Valider'
)}
</button>
<button
onClick={() => setIsEditingType(false)}
disabled={isUpdating}
className="px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-lg transition-colors disabled:opacity-50"
>
Annuler
</button>
</div>
) : (
<>
<h2 className="text-lg font-semibold text-slate-800 truncate">
{document.type}
</h2>
<p className="text-sm text-slate-600">
{formatSize(document.size)} {formatDate(document.lastModified)}
</p>
</>
)}
</div>
<div className="flex items-center gap-2">
{!isEditingType && (
<>
<button
onClick={handleStartEdit}
className="p-2 hover:bg-slate-200 rounded-lg transition-colors"
title="Modifier le type"
>
<Edit3 className="size-5 text-slate-600" />
</button>
<a
href={document.downloadUrl}
download
className="p-2 hover:bg-slate-200 rounded-lg transition-colors"
title="Télécharger"
>
<Download className="size-5 text-slate-600" />
</a>
<a
href={document.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 hover:bg-slate-200 rounded-lg transition-colors"
title="Ouvrir dans un nouvel onglet"
>
<ExternalLink className="size-5 text-slate-600" />
</a>
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors"
title="Supprimer"
>
<Trash2 className="size-5 text-red-600" />
</button>
</>
)}
<button
onClick={onClose}
disabled={isUpdating || isDeleting}
className="p-2 hover:bg-slate-200 rounded-lg transition-colors disabled:opacity-50"
>
<X className="size-5 text-slate-500" />
</button>
</div>
</div>
{/* Body - Viewer */}
<div className="flex-1 overflow-auto bg-slate-100 p-4">
{isPDF ? (
<iframe
src={document.downloadUrl}
className="w-full h-full min-h-[500px] rounded-lg bg-white shadow"
title="Document PDF"
/>
) : isImage ? (
<div className="flex items-center justify-center h-full">
<img
src={document.downloadUrl}
alt={document.type}
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-slate-600 mb-4">
Prévisualisation non disponible pour ce type de fichier
</p>
<a
href={document.downloadUrl}
download
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<Download className="size-4" />
Télécharger le fichier
</a>
</div>
</div>
)}
</div>
{/* Confirmation de suppression */}
{showDeleteConfirm && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-2xl">
<div className="bg-white rounded-xl p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-slate-800 mb-2">
Confirmer la suppression
</h3>
<p className="text-sm text-slate-600 mb-6">
Êtes-vous sûr de vouloir supprimer ce document ? Cette action est irréversible.
</p>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isDeleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Suppression...
</>
) : (
<>
<Trash2 className="size-4" />
Supprimer
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { X, Mail, Loader2, CheckCircle, AlertCircle } from "lucide-react";
import { toast } from "sonner";
interface ResendInvitationModalProps {
isOpen: boolean;
onClose: () => void;
salarie: {
id: string;
nom?: string | null;
prenom?: string | null;
salarie?: string | null;
adresse_mail?: string | null;
code_salarie?: string | null;
};
onSuccess?: () => void; // Callback pour rafraîchir les données
}
export default function ResendInvitationModal({
isOpen,
onClose,
salarie,
onSuccess,
}: ResendInvitationModalProps) {
const [sending, setSending] = useState(false);
if (!isOpen) return null;
const salarieName = salarie.salarie || [salarie.prenom, salarie.nom].filter(Boolean).join(" ") || "Salarié";
const email = salarie.adresse_mail;
const handleSend = async () => {
if (!email) {
toast.error("Aucune adresse email renseignée pour ce salarié");
return;
}
setSending(true);
try {
const response = await fetch('/api/auto-declaration/generate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
salarie_id: salarie.id,
send_email: true,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Erreur lors de l\'envoi');
}
toast.success('Email de relance envoyé avec succès !');
onSuccess?.(); // Rafraîchir les données du salarié
onClose();
} catch (error) {
console.error('Erreur envoi relance:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors de l\'envoi de la relance');
} finally {
setSending(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Mail className="size-5 text-blue-600" />
</div>
<h2 className="text-lg font-semibold text-slate-800">
Relancer le salarié
</h2>
</div>
<button
onClick={onClose}
disabled={sending}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
<X className="size-5 text-slate-500" />
</button>
</div>
{/* Body */}
<div className="p-5 space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="size-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-900 mb-1">
Email d'invitation à l'auto-déclaration
</p>
<p className="text-xs text-blue-700">
Le salarié recevra un email avec un lien sécurisé pour compléter ses informations et télécharger ses justificatifs.
</p>
</div>
</div>
</div>
{/* Infos du salarié */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Salarié :</span>
<span className="font-medium text-slate-800">{salarieName}</span>
</div>
{salarie.code_salarie && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Matricule :</span>
<span className="font-medium text-slate-800">{salarie.code_salarie}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Email :</span>
{email ? (
<span className="font-medium text-slate-800">{email}</span>
) : (
<span className="flex items-center gap-1 text-red-600">
<AlertCircle className="size-3" />
Non renseigné
</span>
)}
</div>
</div>
{!email && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-xs text-red-700">
Impossible d'envoyer l'email : aucune adresse email renseignée pour ce salarié.
</p>
</div>
)}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
<p className="text-xs text-slate-600">
💡 <strong>Contenu de l'email :</strong> Lien d'accès personnalisé valide 7 jours, instructions pour compléter le profil et uploader les documents (CNI, attestation Sécu, RIB, etc.).
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t bg-slate-50 rounded-b-2xl">
<button
onClick={onClose}
disabled={sending}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-lg transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleSend}
disabled={!email || sending}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{sending ? (
<>
<Loader2 className="size-4 animate-spin" />
Envoi en cours...
</>
) : (
<>
<Mail className="size-4" />
Envoyer la relance
</>
)}
</button>
</div>
</div>
</div>
);
}

View file

@ -2,8 +2,11 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import { FileText, Briefcase, StickyNote, User, Mail, Phone, MapPin } from "lucide-react";
import { FileText, Briefcase, StickyNote, User, Mail, Phone, MapPin, Download, Loader2, Clock } from "lucide-react";
import SalarieModal from "./SalarieModal";
import UploadDocumentModal from "./UploadDocumentModal";
import DocumentViewerModal from "./DocumentViewerModal";
import ResendInvitationModal from "./ResendInvitationModal";
type Salarie = {
id: string;
@ -36,10 +39,21 @@ type Salarie = {
derniere_profession?: string | null;
employer_id?: string | null;
organization_name?: string | null;
notes?: string | null;
last_notif_justifs?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
type S3Document = {
key: string;
name: string;
type: string;
size: number;
lastModified: string;
downloadUrl: string;
};
export default function SalariesGrid({ initialData, activeOrgId }: { initialData: Salarie[]; activeOrgId?: string | null }) {
const [rows, setRows] = useState<Salarie[]>(initialData || []);
const [showRaw, setShowRaw] = useState(false);
@ -59,6 +73,15 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedContracts, setSelectedContracts] = useState<any[]>([]);
const [loadingContracts, setLoadingContracts] = useState(false);
const [documents, setDocuments] = useState<S3Document[]>([]);
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<S3Document | null>(null);
const [isDocumentViewerOpen, setIsDocumentViewerOpen] = useState(false);
const [isEditingNote, setIsEditingNote] = useState(false);
const [noteValue, setNoteValue] = useState("");
const [savingNote, setSavingNote] = useState(false);
const [isResendInvitationOpen, setIsResendInvitationOpen] = useState(false);
// optimistic update helper
async function saveCell(id: string, field: string, value: string) {
@ -103,24 +126,73 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
}
};
// Effect pour récupérer les contrats quand un salarié est sélectionné
// Fonction pour récupérer les documents du salarié depuis S3
const fetchSalarieDocuments = async (salarieId: string) => {
setLoadingDocuments(true);
try {
const selectedSalarie = rows.find(r => r.id === salarieId);
if (!selectedSalarie || !selectedSalarie.code_salarie) {
setDocuments([]);
return;
}
const res = await fetch(`/api/staff/salaries/documents?matricule=${encodeURIComponent(selectedSalarie.code_salarie)}`);
if (!res.ok) throw new Error('Failed to fetch documents');
const data = await res.json();
setDocuments(data.documents || []);
} catch (error) {
console.error('Error fetching documents:', error);
setDocuments([]);
} finally {
setLoadingDocuments(false);
}
};
// Fonction pour sauvegarder la note
const handleSaveNote = async () => {
if (!selectedRow) return;
setSavingNote(true);
try {
await saveCell(selectedRow.id, 'notes', noteValue);
setIsEditingNote(false);
} catch (error) {
console.error('Error saving note:', error);
} finally {
setSavingNote(false);
}
};
// Fonction pour commencer l'édition de la note
const handleStartEditNote = () => {
setNoteValue(selectedRow?.notes || '');
setIsEditingNote(true);
};
// Fonction pour annuler l'édition de la note
const handleCancelEditNote = () => {
setIsEditingNote(false);
setNoteValue('');
};
// Effect pour récupérer les contrats et documents quand un salarié est sélectionné
useEffect(() => {
if (selectedRow?.id) {
fetchSalarieContracts(selectedRow.id);
fetchSalarieDocuments(selectedRow.id);
// Réinitialiser l'état d'édition de la note
setIsEditingNote(false);
setNoteValue('');
} else {
setSelectedContracts([]);
setDocuments([]);
setIsEditingNote(false);
setNoteValue('');
}
}, [selectedRow?.id]);
// Realtime subscription: listen to INSERT / UPDATE / DELETE on salaries
useEffect(() => {
// Debug: log incoming initialData when component mounts/hydrates
try {
console.log("SalariesGrid initialData (client):", Array.isArray(initialData) ? initialData.length : typeof initialData, initialData?.slice?.(0, 5));
} catch (err) {
console.log("SalariesGrid initialData (client) - could not log:", err);
}
let channel: any = null;
let mounted = true;
@ -255,6 +327,9 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('nom'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Nom {sortField === 'nom' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('code_salarie'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Matricule {sortField === 'code_salarie' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Email</th>
<th className="text-left px-3 py-2">Organisation</th>
</tr>
@ -279,10 +354,10 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
</span>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="font-medium">{fullName}</span>
<span className="text-xs text-slate-500">({matricule})</span>
</div>
<span className="font-medium">{fullName}</span>
</td>
<td className="px-3 py-2">
<span className="text-slate-700 font-mono text-xs">{matricule}</span>
</td>
<td className="px-3 py-2">{r.adresse_mail || "—"}</td>
<td className="px-3 py-2">{r.organization_name || "—"}</td>
@ -327,6 +402,50 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
Sélectionné: {selectedRow.salarie || [selectedRow.nom, selectedRow.prenom].filter(Boolean).join(" ") || "Salarié"}{selectedRow.code_salarie ? ` (${selectedRow.code_salarie})` : ""}
</div>
{/* Bouton Relance */}
<button
onClick={() => setIsResendInvitationOpen(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white rounded-xl transition-all shadow-sm hover:shadow-md"
>
<Mail className="size-4" />
<span className="font-medium text-sm">Envoyer une relance justifs</span>
</button>
{/* Card Date dernière notification */}
{selectedRow.last_notif_justifs ? (
<div className="rounded-xl border bg-gradient-to-br from-blue-50 to-indigo-50 p-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Clock className="size-5 text-blue-600" />
<h3 className="font-medium text-slate-800">Dernière relance</h3>
</div>
<div className="text-sm text-slate-700">
<div className="font-medium">
{new Date(selectedRow.last_notif_justifs).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
<div className="text-xs text-slate-600 mt-1">
{new Date(selectedRow.last_notif_justifs).toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
</div>
) : (
<div className="rounded-xl border bg-slate-50 p-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Clock className="size-5 text-slate-400" />
<h3 className="font-medium text-slate-600">Dernière relance</h3>
</div>
<div className="text-sm text-slate-500">
Aucune relance envoyée pour le moment
</div>
</div>
)}
{/* Card Informations personnelles */}
<div className="rounded-xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
@ -361,12 +480,70 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
<StickyNote className="size-5 text-yellow-600" />
<h3 className="font-medium text-slate-800">Note interne</h3>
</div>
<p className="text-sm text-slate-600 mb-3">
Aucune note enregistrée pour ce salarié.
</p>
<button className="w-full px-3 py-2 text-sm bg-yellow-50 hover:bg-yellow-100 text-yellow-700 rounded-lg transition-colors">
+ Ajouter une note
</button>
{isEditingNote ? (
<div className="space-y-3">
<textarea
value={noteValue}
onChange={(e) => setNoteValue(e.target.value)}
placeholder="Saisissez une note interne (visible uniquement par le staff)"
rows={5}
disabled={savingNote}
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-yellow-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed resize-none"
/>
<div className="flex items-center gap-2">
<button
onClick={handleSaveNote}
disabled={savingNote}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{savingNote ? (
<>
<Loader2 className="size-4 animate-spin" />
Enregistrement...
</>
) : (
'Enregistrer'
)}
</button>
<button
onClick={handleCancelEditNote}
disabled={savingNote}
className="px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
Annuler
</button>
</div>
</div>
) : (
<>
{selectedRow.notes ? (
<>
<div className="text-sm text-slate-700 mb-3 whitespace-pre-wrap bg-yellow-50 p-3 rounded-lg border border-yellow-200">
{selectedRow.notes}
</div>
<button
onClick={handleStartEditNote}
className="w-full px-3 py-2 text-sm bg-yellow-50 hover:bg-yellow-100 text-yellow-700 rounded-lg transition-colors"
>
Modifier la note
</button>
</>
) : (
<>
<p className="text-sm text-slate-600 mb-3">
Aucune note enregistrée pour ce salarié.
</p>
<button
onClick={handleStartEditNote}
className="w-full px-3 py-2 text-sm bg-yellow-50 hover:bg-yellow-100 text-yellow-700 rounded-lg transition-colors"
>
+ Ajouter une note
</button>
</>
)}
</>
)}
</div>
{/* Card Documents */}
@ -375,17 +552,70 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
<FileText className="size-5 text-blue-600" />
<h3 className="font-medium text-slate-800">Documents</h3>
</div>
<p className="text-sm text-slate-600 mb-3">
Aucun document uploadé.
</p>
<div className="space-y-2">
<button className="w-full px-3 py-2 text-sm bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg transition-colors">
+ Uploader un document
</button>
<div className="text-xs text-slate-500">
CNI, contrats de travail, justificatifs...
{loadingDocuments ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-6 text-blue-600 animate-spin" />
</div>
</div>
) : documents.length > 0 ? (
<>
<p className="text-sm text-slate-600 mb-3">
{documents.length} document{documents.length > 1 ? 's' : ''} disponible{documents.length > 1 ? 's' : ''}
</p>
<div className="space-y-2 mb-3">
{documents.map((doc) => {
const sizeKB = (doc.size / 1024).toFixed(1);
const date = new Date(doc.lastModified).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
return (
<button
key={doc.key}
onClick={() => {
setSelectedDocument(doc);
setIsDocumentViewerOpen(true);
}}
className="w-full flex items-center gap-3 p-3 border-2 border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors cursor-pointer text-left"
>
<FileText className="size-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-blue-800 truncate">
{doc.type}
</p>
<p className="text-xs text-blue-600">
{date} {sizeKB} Ko
</p>
</div>
</button>
);
})}
</div>
<button
onClick={() => setIsUploadModalOpen(true)}
className="w-full px-3 py-2 text-sm bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg transition-colors"
>
+ Ajouter un document
</button>
</>
) : (
<>
<p className="text-sm text-slate-600 mb-3">
Aucun document uploadé par ce salarié.
</p>
<div className="text-xs text-slate-500 bg-slate-50 p-2 rounded mb-3">
Les documents uploadés via la page d'auto-déclaration apparaîtront ici (CNI, attestation Sécu, RIB, etc.)
</div>
<button
onClick={() => setIsUploadModalOpen(true)}
className="w-full px-3 py-2 text-sm bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg transition-colors"
>
+ Ajouter un document
</button>
</>
)}
</div>
{/* Card Contrats */}
@ -522,6 +752,51 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
setRows(prev => prev.map(r => r.id === updatedSalarie.id ? updatedSalarie : r));
}}
/>
{/* Modal d'upload de document */}
{selectedRow && (
<UploadDocumentModal
isOpen={isUploadModalOpen}
onClose={() => setIsUploadModalOpen(false)}
matricule={selectedRow.code_salarie || ''}
salarieName={selectedRow.salarie || [selectedRow.nom, selectedRow.prenom].filter(Boolean).join(" ") || "Salarié"}
onSuccess={() => {
// Recharger les documents après l'upload
if (selectedRow?.id) {
fetchSalarieDocuments(selectedRow.id);
}
}}
/>
)}
{/* Modal de visualisation de document */}
<DocumentViewerModal
isOpen={isDocumentViewerOpen}
onClose={() => {
setIsDocumentViewerOpen(false);
setSelectedDocument(null);
}}
document={selectedDocument}
onDocumentUpdated={() => {
// Recharger les documents après modification/suppression
if (selectedRow?.id) {
fetchSalarieDocuments(selectedRow.id);
}
}}
/>
{/* Modal de relance invitation */}
{selectedRow && (
<ResendInvitationModal
isOpen={isResendInvitationOpen}
onClose={() => setIsResendInvitationOpen(false)}
salarie={selectedRow}
onSuccess={async () => {
// Rafraîchir la liste pour obtenir la nouvelle valeur de last_notif_justifs
await fetchServer(page);
}}
/>
)}
</div>
</div>
);

View file

@ -0,0 +1,260 @@
"use client";
import { useState } from "react";
import { X, Upload, Loader2, CheckCircle } from "lucide-react";
import { toast } from "sonner";
interface UploadDocumentModalProps {
isOpen: boolean;
onClose: () => void;
matricule: string;
salarieName: string;
onSuccess: () => void;
}
const DOCUMENT_TYPES = [
{ value: "piece_identite", label: "Pièce d'identité" },
{ value: "attestation_secu", label: "Attestation Sécurité Sociale" },
{ value: "rib", label: "RIB" },
{ value: "medecine_travail", label: "Attestation médecine du travail" },
{ value: "contrat_travail", label: "Contrat de travail" },
{ value: "diplome", label: "Diplôme" },
{ value: "justificatif", label: "Justificatif" },
{ value: "autre", label: "Autre document" },
];
export default function UploadDocumentModal({
isOpen,
onClose,
matricule,
salarieName,
onSuccess,
}: UploadDocumentModalProps) {
const [documentType, setDocumentType] = useState("piece_identite");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
if (!isOpen) return null;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
validateAndSetFile(file);
}
};
const validateAndSetFile = (file: File) => {
const validTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
const maxSize = 10 * 1024 * 1024; // 10MB
if (!validTypes.includes(file.type)) {
toast.error('Type de fichier non autorisé. Utilisez PDF, JPG ou PNG.');
return;
}
if (file.size > maxSize) {
toast.error('Fichier trop volumineux. Taille maximum : 10 MB.');
return;
}
setSelectedFile(file);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
validateAndSetFile(file);
}
};
const handleUpload = async () => {
if (!selectedFile) {
toast.error("Veuillez sélectionner un fichier");
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('matricule', matricule);
formData.append('type', documentType);
const response = await fetch('/api/staff/salaries/documents/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors du téléchargement');
}
toast.success('Document uploadé avec succès');
onSuccess();
handleClose();
} catch (error) {
console.error('Erreur upload:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors du téléchargement');
} finally {
setUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
setDocumentType("piece_identite");
setIsDragging(false);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="relative w-full max-w-md bg-white rounded-2xl shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b">
<div>
<h2 className="text-lg font-semibold text-slate-800">
Ajouter un document
</h2>
<p className="text-sm text-slate-600 mt-1">
Pour {salarieName} ({matricule})
</p>
</div>
<button
onClick={handleClose}
disabled={uploading}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
<X className="size-5 text-slate-500" />
</button>
</div>
{/* Body */}
<div className="p-5 space-y-4">
{/* Type de document */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Type de document
</label>
<select
value={documentType}
onChange={(e) => setDocumentType(e.target.value)}
disabled={uploading}
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
>
{DOCUMENT_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
{/* Upload zone */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Fichier
</label>
{selectedFile ? (
<div className="flex items-center justify-between p-4 bg-green-50 border-2 border-green-200 rounded-lg">
<div className="flex items-center gap-3">
<CheckCircle className="size-5 text-green-600" />
<div>
<p className="text-sm font-medium text-green-800">
{selectedFile.name}
</p>
<p className="text-xs text-green-600">
{(selectedFile.size / 1024).toFixed(1)} Ko
</p>
</div>
</div>
<button
onClick={() => setSelectedFile(null)}
disabled={uploading}
className="p-1 hover:bg-green-100 rounded transition-colors disabled:opacity-50"
>
<X className="size-4 text-green-700" />
</button>
</div>
) : (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50'
: 'border-slate-300 hover:border-slate-400'
}`}
>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept=".pdf,.jpg,.jpeg,.png"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<Upload className={`size-8 mx-auto mb-2 ${
isDragging ? 'text-blue-600' : 'text-slate-400'
}`} />
<p className="text-sm text-slate-600 mb-1">
{isDragging
? 'Déposez le fichier ici'
: 'Glissez un fichier ou cliquez pour choisir'}
</p>
<p className="text-xs text-slate-500">
PDF, JPG, PNG (max 10MB)
</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t bg-slate-50 rounded-b-2xl">
<button
onClick={handleClose}
disabled={uploading}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-lg transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleUpload}
disabled={!selectedFile || uploading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{uploading ? (
<>
<Loader2 className="size-4 animate-spin" />
Upload en cours...
</>
) : (
<>
<Upload className="size-4" />
Uploader
</>
)}
</button>
</div>
</div>
</div>
);
}

View file

@ -125,6 +125,12 @@ export async function generateAutoDeclarationToken(
}
});
// Mettre à jour la colonne last_notif_justifs
await supabase
.from('salaries')
.update({ last_notif_justifs: new Date().toISOString() })
.eq('id', salarie_id);
return {
success: true,
token,