Docs staff + docs comptables + docs généraux
This commit is contained in:
parent
103d8fa939
commit
ba6b733ad0
31 changed files with 5069 additions and 219 deletions
207
STAFF_DOCUMENTS_FEATURE.md
Normal file
207
STAFF_DOCUMENTS_FEATURE.md
Normal 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
152
STAFF_NOTES_FEATURE.md
Normal 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"
|
||||
218
STAFF_RESEND_INVITATION_FEATURE.md
Normal file
218
STAFF_RESEND_INVITATION_FEATURE.md
Normal 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
210
SUMMARY_STAFF_FEATURES.md
Normal 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
|
||||
907
app/(app)/staff/documents/page.tsx
Normal file
907
app/(app)/staff/documents/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
82
app/api/auto-declaration/documents/delete/route.ts
Normal file
82
app/api/auto-declaration/documents/delete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
148
app/api/auto-declaration/documents/route.ts
Normal file
148
app/api/auto-declaration/documents/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
158
app/api/documents/generaux/route.ts
Normal file
158
app/api/documents/generaux/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
81
app/api/staff/documents/delete-general/route.ts
Normal file
81
app/api/staff/documents/delete-general/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
83
app/api/staff/documents/delete/route.ts
Normal file
83
app/api/staff/documents/delete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
89
app/api/staff/documents/list/route.ts
Normal file
89
app/api/staff/documents/list/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
116
app/api/staff/documents/upload/route.ts
Normal file
116
app/api/staff/documents/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
app/api/staff/salaries/documents/delete/route.ts
Normal file
56
app/api/staff/salaries/documents/delete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/api/staff/salaries/documents/route.ts
Normal file
121
app/api/staff/salaries/documents/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
app/api/staff/salaries/documents/update-type/route.ts
Normal file
106
app/api/staff/salaries/documents/update-type/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
127
app/api/staff/salaries/documents/upload/route.ts
Normal file
127
app/api/staff/salaries/documents/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
169
components/DocumentPreviewModal.tsx
Normal file
169
components/DocumentPreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
components/DocumentViewModal.tsx
Normal file
107
components/DocumentViewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
components/SaveConfirmationModal.tsx
Normal file
114
components/SaveConfirmationModal.tsx
Normal 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 été transmises.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Vous pouvez revenir sur cette page à tout moment via le lien qui vous a été 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 été 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
325
components/staff/DocumentViewerModal.tsx
Normal file
325
components/staff/DocumentViewerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
components/staff/ResendInvitationModal.tsx
Normal file
179
components/staff/ResendInvitationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
260
components/staff/UploadDocumentModal.tsx
Normal file
260
components/staff/UploadDocumentModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue