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,
|
compte_transat, topaze, justificatifs_personnels, rf_au_sens_fiscal, intermittent_mineur_16,
|
||||||
adresse_mail, nir, conges_spectacles, tel, adresse, date_naissance, lieu_de_naissance,
|
adresse_mail, nir, conges_spectacles, tel, adresse, date_naissance, lieu_de_naissance,
|
||||||
iban, bic, abattement_2024, infos_caisses_organismes, notif_nouveau_salarie, notif_employeur,
|
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)`
|
organizations(name)`
|
||||||
)
|
)
|
||||||
.order("nom", { ascending: true })
|
.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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { Textarea } from '@/components/ui/textarea'
|
||||||
import { usePageTitle } from '@/hooks/usePageTitle'
|
import { usePageTitle } from '@/hooks/usePageTitle'
|
||||||
import { Tooltip } from '@/components/ui/tooltip'
|
import { Tooltip } from '@/components/ui/tooltip'
|
||||||
|
import { supabase } from '@/lib/supabaseClient'
|
||||||
|
import { DocumentViewModal } from '@/components/DocumentViewModal'
|
||||||
|
import Cookies from 'js-cookie'
|
||||||
|
|
||||||
type DocumentItem = {
|
type DocumentItem = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -20,6 +23,23 @@ type DocumentItem = {
|
||||||
period_label?: string | null
|
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) {
|
function formatBytes(bytes?: number) {
|
||||||
if (!bytes && bytes !== 0) return ''
|
if (!bytes && bytes !== 0) return ''
|
||||||
const sizes = ['o', 'Ko', 'Mo', 'Go', 'To']
|
const sizes = ['o', 'Ko', 'Mo', 'Go', 'To']
|
||||||
|
|
@ -70,76 +90,177 @@ function UploadPanel() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionGeneraux() {
|
function SectionGeneraux({ selectedOrgId }: { selectedOrgId?: string }) {
|
||||||
const { data: documentsGeneraux, isLoading, error } = useQuery<DocumentItem[]>({
|
const [selectedDoc, setSelectedDoc] = React.useState<GeneralDocument | null>(null);
|
||||||
queryKey: ['documents', 'generaux'],
|
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||||
|
const [orgId, setOrgId] = React.useState<string | null>(null);
|
||||||
|
const [isLoadingOrgId, setIsLoadingOrgId] = React.useState(true);
|
||||||
|
|
||||||
|
// 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 () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/api/documents?category=generaux')
|
if (!orgId) {
|
||||||
const data = await res.json()
|
throw new Error('Aucune organisation sélectionnée');
|
||||||
console.log('📄 Documents Généraux - Response:', data)
|
}
|
||||||
console.log('📄 Documents Généraux - Is Array:', Array.isArray(data))
|
|
||||||
|
const res = await fetch(`/api/documents/generaux?org_id=${encodeURIComponent(orgId)}`);
|
||||||
|
|
||||||
// Si la réponse est un objet avec une propriété documents
|
if (!res.ok) {
|
||||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
const errorText = await res.text();
|
||||||
console.log('📄 Documents Généraux - Keys:', Object.keys(data))
|
throw new Error('Erreur lors de la récupération des documents');
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(data) ? data : []
|
const data = await res.json();
|
||||||
}
|
return data.documents || [];
|
||||||
})
|
},
|
||||||
|
enabled: !!orgId
|
||||||
|
});
|
||||||
|
|
||||||
console.log('📄 Documents Généraux - Final Data:', documentsGeneraux)
|
const handleViewDocument = (doc: GeneralDocument) => {
|
||||||
console.log('📄 Documents Généraux - Loading:', isLoading)
|
if (doc.available && doc.downloadUrl) {
|
||||||
console.log('📄 Documents Généraux - Error:', error)
|
setSelectedDoc(doc);
|
||||||
|
setIsModalOpen(true);
|
||||||
const handleDownload = (item: DocumentItem) => {
|
|
||||||
if (item.url) {
|
|
||||||
window.open(item.url, '_blank')
|
|
||||||
} else {
|
|
||||||
alert('Document non disponible')
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
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) {
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
{documentsGeneraux && documentsGeneraux.length > 0 ? (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
documentsGeneraux.map((item) => (
|
{documentsGeneraux && documentsGeneraux.map((doc) => (
|
||||||
<div key={item.id} className="flex items-center justify-between p-3 border rounded-lg">
|
<Card
|
||||||
<div>
|
key={doc.type}
|
||||||
<h4 className="font-medium">{item.title}</h4>
|
className={`cursor-pointer transition-all ${
|
||||||
<p className="text-sm text-muted-foreground">
|
doc.available
|
||||||
{formatDateLast(item.updatedAt)} • {formatBytes(item.sizeBytes)}
|
? 'hover:shadow-md hover:border-blue-300'
|
||||||
</p>
|
: 'opacity-60 cursor-not-allowed'
|
||||||
</div>
|
}`}
|
||||||
<Button
|
onClick={() => doc.available && handleViewDocument(doc)}
|
||||||
size="sm"
|
>
|
||||||
onClick={() => handleDownload(item)}
|
<CardHeader>
|
||||||
disabled={!item.url}
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
>
|
<FileText className="h-4 w-4" />
|
||||||
<Download className="h-4 w-4 mr-2" />
|
{doc.label}
|
||||||
Télécharger
|
</CardTitle>
|
||||||
</Button>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
))
|
{doc.available ? (
|
||||||
) : (
|
<div className="space-y-1">
|
||||||
<p className="text-center text-muted-foreground py-8">
|
{doc.size && (
|
||||||
Aucun document disponible
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
{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() {
|
function SectionCaisses() {
|
||||||
|
|
@ -148,7 +269,6 @@ function SectionCaisses() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/api/documents?category=caisses')
|
const res = await fetch('/api/documents?category=caisses')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
console.log('📄 Documents Caisses - Response:', data)
|
|
||||||
|
|
||||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||||
if (data.documents) return data.documents
|
if (data.documents) return data.documents
|
||||||
|
|
@ -207,66 +327,122 @@ function SectionCaisses() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionComptables() {
|
function SectionComptables() {
|
||||||
|
const [expandedYears, setExpandedYears] = React.useState<Set<string>>(new Set())
|
||||||
const [expandedPeriods, setExpandedPeriods] = 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[]>({
|
const { data: documentsCompta, isLoading, error } = useQuery<DocumentItem[]>({
|
||||||
queryKey: ['documents', 'comptables'],
|
queryKey: ['documents', 'comptables', 'metadata'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('📄 Fetching comptables with category=docs_comptables')
|
const res = await fetch('/api/documents?category=docs_comptables&metadata_only=true')
|
||||||
const res = await fetch('/api/documents?category=docs_comptables')
|
|
||||||
const data = await res.json()
|
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)) {
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||||
console.log('📄 Documents Comptables - Object keys:', Object.keys(data))
|
|
||||||
if (data.documents) {
|
if (data.documents) {
|
||||||
console.log('📄 Using data.documents:', data.documents)
|
|
||||||
return data.documents
|
return data.documents
|
||||||
}
|
}
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
console.log('📄 Using data.data:', data.data)
|
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
if (data.items) {
|
if (data.items) {
|
||||||
console.log('📄 Using data.items:', data.items)
|
|
||||||
return data.items
|
return data.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = Array.isArray(data) ? data : []
|
const result = Array.isArray(data) ? data : []
|
||||||
console.log('📄 Final result:', result)
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Grouper les documents par période
|
// Charger les URLs pour une période spécifique
|
||||||
const documentsByPeriod = React.useMemo(() => {
|
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()
|
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 => {
|
documentsCompta.forEach(doc => {
|
||||||
const period = doc.period_label || 'Sans période'
|
const period = doc.period_label || 'Sans période'
|
||||||
if (!grouped.has(period)) {
|
const year = period === 'Sans période' ? 'Sans année' : extractYear(period)
|
||||||
grouped.set(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)
|
// Trier les années par ordre décroissant
|
||||||
const sortedEntries = Array.from(grouped.entries()).sort((a, b) => {
|
const sortedYears = Array.from(grouped.entries()).sort((a, b) => {
|
||||||
// Si "Sans période", mettre à la fin
|
if (a[0] === 'Sans année') return 1
|
||||||
if (a[0] === 'Sans période') return 1
|
if (b[0] === 'Sans année') return -1
|
||||||
if (b[0] === 'Sans période') return -1
|
|
||||||
|
|
||||||
// Sinon, tri décroissant (plus récent en premier)
|
|
||||||
return b[0].localeCompare(a[0])
|
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])
|
}, [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) => {
|
const togglePeriod = (period: string) => {
|
||||||
setExpandedPeriods(prev => {
|
setExpandedPeriods(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
|
|
@ -274,17 +450,24 @@ function SectionComptables() {
|
||||||
next.delete(period)
|
next.delete(period)
|
||||||
} else {
|
} else {
|
||||||
next.add(period)
|
next.add(period)
|
||||||
|
// Charger les URLs pour cette période
|
||||||
|
setLoadedPeriods(loaded => new Set([...loaded, period]))
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (item: DocumentItem) => {
|
const handleDownload = (item: DocumentItem, period: string) => {
|
||||||
if (item.url) {
|
// Chercher l'URL dans les données chargées
|
||||||
window.open(item.url, '_blank')
|
const docsWithUrls = periodUrls[period]
|
||||||
} else {
|
if (docsWithUrls) {
|
||||||
alert('Document non disponible')
|
const docWithUrl = docsWithUrls.find(d => d.id === item.id)
|
||||||
|
if (docWithUrl?.url) {
|
||||||
|
window.open(docWithUrl.url, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
alert('Document non disponible')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -305,56 +488,97 @@ function SectionComptables() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from(documentsByPeriod.entries()).map(([period, docs]) => {
|
{Array.from(documentsByYear.entries()).map(([year, periods]) => {
|
||||||
const isExpanded = expandedPeriods.has(period)
|
const isYearExpanded = expandedYears.has(year)
|
||||||
|
const totalDocs = Array.from(periods.values()).reduce((sum, docs: DocumentItem[]) => sum + docs.length, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={period} className="border rounded-lg overflow-hidden">
|
<div key={year} className="border rounded-lg overflow-hidden">
|
||||||
{/* Header de la période - cliquable */}
|
{/* Header de l'année - cliquable */}
|
||||||
<button
|
<button
|
||||||
onClick={() => togglePeriod(period)}
|
onClick={() => toggleYear(year)}
|
||||||
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
|
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">
|
<div className="flex items-center gap-3">
|
||||||
{isExpanded ? (
|
{isYearExpanded ? (
|
||||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<div className="text-left">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Liste des documents - affichée si expanded */}
|
{/* Liste des périodes pour cette année */}
|
||||||
{isExpanded && (
|
{isYearExpanded && (
|
||||||
<div className="p-2 space-y-2 bg-background">
|
<div className="bg-background">
|
||||||
{docs.map((item: DocumentItem) => (
|
{Array.from(periods.entries()).map(([period, docs]: [string, DocumentItem[]]) => {
|
||||||
<div
|
const isPeriodExpanded = expandedPeriods.has(period)
|
||||||
key={item.id}
|
const isLoading = isPeriodExpanded && loadedPeriods.has(period) && !periodUrls[period]
|
||||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
return (
|
||||||
<div className="flex-1 min-w-0">
|
<div key={period} className="border-t">
|
||||||
<h4 className="font-medium truncate">{item.title}</h4>
|
{/* Header de la période */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<button
|
||||||
{formatDateLast(item.updatedAt)} • {formatBytes(item.sizeBytes)}
|
onClick={() => togglePeriod(period)}
|
||||||
</p>
|
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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -367,13 +591,134 @@ function SectionComptables() {
|
||||||
export default function VosDocumentsPage() {
|
export default function VosDocumentsPage() {
|
||||||
usePageTitle("Vos documents");
|
usePageTitle("Vos documents");
|
||||||
const [activeTab, setActiveTab] = React.useState('generaux');
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Vos documents</h2>
|
<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>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Colonne gauche : Documents disponibles */}
|
{/* Colonne gauche : Documents disponibles */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
|
|
@ -382,11 +727,11 @@ export default function VosDocumentsPage() {
|
||||||
<CardTitle>Documents disponibles</CardTitle>
|
<CardTitle>Documents disponibles</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{activeTab === 'generaux' && 'Téléchargez vos documents généraux'}
|
{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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeTab === 'generaux' && <SectionGeneraux />}
|
{activeTab === 'generaux' && <SectionGeneraux selectedOrgId={selectedOrgId} />}
|
||||||
{activeTab === 'comptables' && <SectionComptables />}
|
{activeTab === 'comptables' && <SectionComptables />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
// 1) Récupérer la catégorie depuis les query params
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const category = searchParams.get("category");
|
const category = searchParams.get("category");
|
||||||
|
const metadataOnly = searchParams.get("metadata_only") === "true";
|
||||||
|
const period = searchParams.get("period");
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return json(400, { error: "missing_category_parameter" });
|
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
|
// 2) Déterminer l'organisation active
|
||||||
let orgId = c.get("active_org_id")?.value || "";
|
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
|
// 4) Récupérer les documents depuis Supabase avec RLS
|
||||||
console.log('📄 Documents API - Fetching from Supabase with org_id:', orgId, 'category:', category);
|
console.log('📄 Documents API - Fetching from Supabase with org_id:', orgId, 'category:', category);
|
||||||
|
|
||||||
const { data: documents, error } = await sb
|
let query = sb
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("org_id", orgId)
|
.eq("org_id", orgId)
|
||||||
.eq("category", category)
|
.eq("category", category);
|
||||||
.order("date_added", { ascending: false });
|
|
||||||
|
// 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) {
|
if (error) {
|
||||||
console.error('📄 Documents API - Supabase Error:', 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);
|
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(
|
const formattedDocuments = await Promise.all(
|
||||||
(documents || []).map(async (doc) => {
|
(documents || [])
|
||||||
let presignedUrl: string | null = null;
|
.filter(doc => {
|
||||||
|
// Exclure les fichiers .DS_Store et autres fichiers système
|
||||||
// Générer l'URL S3 présignée si storage_path existe
|
const filename = doc.filename || '';
|
||||||
if (doc.storage_path) {
|
return !filename.startsWith('.') && filename !== '.DS_Store';
|
||||||
try {
|
})
|
||||||
presignedUrl = await getS3SignedUrl(doc.storage_path, 3600); // Expire dans 1 heure
|
.map(async (doc) => {
|
||||||
console.log('✅ Generated presigned URL for:', doc.filename);
|
let presignedUrl: string | null = null;
|
||||||
} 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 {
|
||||||
return {
|
id: doc.id,
|
||||||
id: doc.id,
|
title: doc.filename || doc.type_label || 'Document',
|
||||||
title: doc.filename || doc.type_label || 'Document',
|
url: presignedUrl, // null si metadata_only=true
|
||||||
url: presignedUrl, // URL S3 présignée prête à l'emploi
|
updatedAt: doc.date_added,
|
||||||
updatedAt: doc.date_added,
|
sizeBytes: doc.size_bytes || 0,
|
||||||
sizeBytes: doc.size_bytes || 0,
|
period_label: doc.period_label,
|
||||||
period_label: doc.period_label,
|
meta: {
|
||||||
meta: {
|
category: doc.category,
|
||||||
category: doc.category,
|
type_label: doc.type_label,
|
||||||
type_label: doc.type_label,
|
storage_path: doc.storage_path, // Garder le path original pour référence
|
||||||
storage_path: doc.storage_path, // Garder le path original pour référence
|
}
|
||||||
}
|
};
|
||||||
};
|
})
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('📄 Documents API - Returning formatted documents:', formattedDocuments.length);
|
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,
|
compte_transat, topaze, justificatifs_personnels, rf_au_sens_fiscal, intermittent_mineur_16,
|
||||||
adresse_mail, nir, conges_spectacles, tel, adresse, date_naissance, lieu_de_naissance,
|
adresse_mail, nir, conges_spectacles, tel, adresse, date_naissance, lieu_de_naissance,
|
||||||
iban, bic, abattement_2024, infos_caisses_organismes, notif_nouveau_salarie, notif_employeur,
|
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)`,
|
organizations(name)`,
|
||||||
{ count: "exact" }
|
{ count: "exact" }
|
||||||
);
|
);
|
||||||
|
|
@ -153,6 +153,8 @@ export async function GET(req: NextRequest) {
|
||||||
notif_employeur: r.notif_employeur,
|
notif_employeur: r.notif_employeur,
|
||||||
derniere_profession: r.derniere_profession,
|
derniere_profession: r.derniere_profession,
|
||||||
employer_id: r.employer_id,
|
employer_id: r.employer_id,
|
||||||
|
notes: r.notes,
|
||||||
|
last_notif_justifs: r.last_notif_justifs,
|
||||||
updated_at: r.updated_at,
|
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',
|
'rf_au_sens_fiscal', 'intermittent_mineur_16', 'adresse_mail', 'nir',
|
||||||
'conges_spectacles', 'tel', 'adresse', 'date_naissance', 'lieu_de_naissance',
|
'conges_spectacles', 'tel', 'adresse', 'date_naissance', 'lieu_de_naissance',
|
||||||
'iban', 'bic', 'abattement_2024', 'infos_caisses_organismes', 'num_salarie',
|
'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
|
// Filter updates to only include allowed fields
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { toast } from "sonner";
|
||||||
|
import { DocumentPreviewModal } from "@/components/DocumentPreviewModal";
|
||||||
|
import { SaveConfirmationModal } from "@/components/SaveConfirmationModal";
|
||||||
|
|
||||||
// Helper components matching the design of salaries/nouveau
|
// Helper components matching the design of salaries/nouveau
|
||||||
function LabelComponent({ children, required = false }: { children: React.ReactNode; required?: boolean }) {
|
function LabelComponent({ children, required = false }: { children: React.ReactNode; required?: boolean }) {
|
||||||
|
|
@ -148,6 +150,15 @@ interface FormData {
|
||||||
protection_donnees: boolean;
|
protection_donnees: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface S3Document {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AutoDeclarationPage() {
|
export default function AutoDeclarationPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const token = searchParams.get('token'); // Changé de 'matricule' à 'token'
|
const token = searchParams.get('token'); // Changé de 'matricule' à 'token'
|
||||||
|
|
@ -156,6 +167,12 @@ export default function AutoDeclarationPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploading, setUploading] = useState<string | null>(null);
|
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>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
// Identité
|
// Identité
|
||||||
|
|
@ -239,6 +256,30 @@ export default function AutoDeclarationPage() {
|
||||||
fetchSalarieData();
|
fetchSalarieData();
|
||||||
}, [token]); // Changé de 'matricule' à 'token'
|
}, [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) => {
|
const handleFileUpload = async (type: keyof FormData, file: File) => {
|
||||||
if (!file || !token) return; // Changé de 'matricule' à 'token'
|
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`);
|
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) {
|
} catch (error) {
|
||||||
console.error('Erreur upload:', error);
|
console.error('Erreur upload:', error);
|
||||||
toast.error(`Erreur lors du téléchargement : ${error instanceof Error ? error.message : 'erreur inconnue'}`);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -292,11 +411,6 @@ export default function AutoDeclarationPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.piece_identite || !formData.attestation_secu || !formData.rib) {
|
|
||||||
toast.error("Les champs marqués d'un * sont obligatoires");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -331,7 +445,12 @@ export default function AutoDeclarationPage() {
|
||||||
throw new Error('Erreur lors de la sauvegarde');
|
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) {
|
} catch (error) {
|
||||||
console.error('Erreur sauvegarde:', error);
|
console.error('Erreur sauvegarde:', error);
|
||||||
|
|
@ -365,6 +484,27 @@ export default function AutoDeclarationPage() {
|
||||||
const isUploading = uploading === type;
|
const isUploading = uploading === type;
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
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) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -412,6 +552,29 @@ export default function AutoDeclarationPage() {
|
||||||
{label} {required && <span className="text-red-500">*</span>}
|
{label} {required && <span className="text-red-500">*</span>}
|
||||||
</Label>
|
</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 ? (
|
{file ? (
|
||||||
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-md">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -720,7 +883,6 @@ export default function AutoDeclarationPage() {
|
||||||
<FileUploadField
|
<FileUploadField
|
||||||
type="piece_identite"
|
type="piece_identite"
|
||||||
label="Pièce d'identité en cours de validité"
|
label="Pièce d'identité en cours de validité"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
Pièces acceptées : Carte Nationale d'Identité recto-verso, Passeport, Permis nouveau format recto-verso,
|
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
|
<FileUploadField
|
||||||
type="attestation_secu"
|
type="attestation_secu"
|
||||||
label="Attestation de Sécurité Sociale"
|
label="Attestation de Sécurité Sociale"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
Ce document est téléchargeable sur votre espace Ameli.
|
Ce document est téléchargeable sur votre espace Ameli.
|
||||||
|
|
@ -739,7 +900,6 @@ export default function AutoDeclarationPage() {
|
||||||
<FileUploadField
|
<FileUploadField
|
||||||
type="rib"
|
type="rib"
|
||||||
label="Votre RIB"
|
label="Votre RIB"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
Pour le versement de vos salaires.
|
Pour le versement de vos salaires.
|
||||||
|
|
@ -799,43 +959,66 @@ export default function AutoDeclarationPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<label htmlFor="protection_donnees" className="flex items-start gap-3 cursor-pointer group">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="protection_donnees"
|
id="protection_donnees"
|
||||||
checked={formData.protection_donnees}
|
checked={formData.protection_donnees}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, protection_donnees: e.target.checked }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, protection_donnees: e.target.checked }))}
|
||||||
className="mt-1"
|
className="mt-1 cursor-pointer"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<LabelComponent required>
|
<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.
|
J'ai lu et compris les informations concernant la protection des données personnelles. <span className="text-red-500">*</span>
|
||||||
</LabelComponent>
|
</span>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Section Enregistrement */}
|
||||||
<div className="flex justify-center pt-4">
|
<Section title="Enregistrer vos modifications">
|
||||||
<button
|
<div className="space-y-4">
|
||||||
type="submit"
|
<p className="text-sm text-gray-600">
|
||||||
disabled={saving || !formData.protection_donnees}
|
Vous pouvez enregistrer vos modifications et revenir plus tard pour compléter les informations manquantes.
|
||||||
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"
|
Vos données sont sauvegardées de manière sécurisée.
|
||||||
>
|
</p>
|
||||||
{saving ? (
|
<div className="flex justify-center">
|
||||||
<>
|
<button
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
type="submit"
|
||||||
Enregistrement...
|
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 ? (
|
||||||
<Save className="h-4 w-4" />
|
<>
|
||||||
Envoyer
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</>
|
Enregistrement...
|
||||||
)}
|
</>
|
||||||
</button>
|
) : (
|
||||||
</div>
|
<>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Enregistrer les modifications
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
</form>
|
</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>
|
||||||
</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>Logs des emails</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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>
|
</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 { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { supabase } from "@/lib/supabaseClient";
|
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 SalarieModal from "./SalarieModal";
|
||||||
|
import UploadDocumentModal from "./UploadDocumentModal";
|
||||||
|
import DocumentViewerModal from "./DocumentViewerModal";
|
||||||
|
import ResendInvitationModal from "./ResendInvitationModal";
|
||||||
|
|
||||||
type Salarie = {
|
type Salarie = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -36,10 +39,21 @@ type Salarie = {
|
||||||
derniere_profession?: string | null;
|
derniere_profession?: string | null;
|
||||||
employer_id?: string | null;
|
employer_id?: string | null;
|
||||||
organization_name?: string | null;
|
organization_name?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
last_notif_justifs?: string | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_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 }) {
|
export default function SalariesGrid({ initialData, activeOrgId }: { initialData: Salarie[]; activeOrgId?: string | null }) {
|
||||||
const [rows, setRows] = useState<Salarie[]>(initialData || []);
|
const [rows, setRows] = useState<Salarie[]>(initialData || []);
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
|
@ -59,6 +73,15 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedContracts, setSelectedContracts] = useState<any[]>([]);
|
const [selectedContracts, setSelectedContracts] = useState<any[]>([]);
|
||||||
const [loadingContracts, setLoadingContracts] = useState(false);
|
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
|
// optimistic update helper
|
||||||
async function saveCell(id: string, field: string, value: string) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedRow?.id) {
|
if (selectedRow?.id) {
|
||||||
fetchSalarieContracts(selectedRow.id);
|
fetchSalarieContracts(selectedRow.id);
|
||||||
|
fetchSalarieDocuments(selectedRow.id);
|
||||||
|
// Réinitialiser l'état d'édition de la note
|
||||||
|
setIsEditingNote(false);
|
||||||
|
setNoteValue('');
|
||||||
} else {
|
} else {
|
||||||
setSelectedContracts([]);
|
setSelectedContracts([]);
|
||||||
|
setDocuments([]);
|
||||||
|
setIsEditingNote(false);
|
||||||
|
setNoteValue('');
|
||||||
}
|
}
|
||||||
}, [selectedRow?.id]);
|
}, [selectedRow?.id]);
|
||||||
|
|
||||||
// Realtime subscription: listen to INSERT / UPDATE / DELETE on salaries
|
// Realtime subscription: listen to INSERT / UPDATE / DELETE on salaries
|
||||||
useEffect(() => {
|
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 channel: any = null;
|
||||||
let mounted = true;
|
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'); }}>
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('nom'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
||||||
Nom {sortField === 'nom' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
Nom {sortField === 'nom' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||||
</th>
|
</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">Email</th>
|
||||||
<th className="text-left px-3 py-2">Organisation</th>
|
<th className="text-left px-3 py-2">Organisation</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -279,10 +354,10 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="font-medium">{fullName}</span>
|
||||||
<span className="font-medium">{fullName}</span>
|
</td>
|
||||||
<span className="text-xs text-slate-500">({matricule})</span>
|
<td className="px-3 py-2">
|
||||||
</div>
|
<span className="text-slate-700 font-mono text-xs">{matricule}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">{r.adresse_mail || "—"}</td>
|
<td className="px-3 py-2">{r.adresse_mail || "—"}</td>
|
||||||
<td className="px-3 py-2">{r.organization_name || "—"}</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})` : ""}
|
Sélectionné: {selectedRow.salarie || [selectedRow.nom, selectedRow.prenom].filter(Boolean).join(" ") || "Salarié"}{selectedRow.code_salarie ? ` (${selectedRow.code_salarie})` : ""}
|
||||||
</div>
|
</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 */}
|
{/* Card Informations personnelles */}
|
||||||
<div className="rounded-xl border bg-white p-4 shadow-sm">
|
<div className="rounded-xl border bg-white p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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" />
|
<StickyNote className="size-5 text-yellow-600" />
|
||||||
<h3 className="font-medium text-slate-800">Note interne</h3>
|
<h3 className="font-medium text-slate-800">Note interne</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-600 mb-3">
|
|
||||||
Aucune note enregistrée pour ce salarié.
|
{isEditingNote ? (
|
||||||
</p>
|
<div className="space-y-3">
|
||||||
<button className="w-full px-3 py-2 text-sm bg-yellow-50 hover:bg-yellow-100 text-yellow-700 rounded-lg transition-colors">
|
<textarea
|
||||||
+ Ajouter une note
|
value={noteValue}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Card Documents */}
|
{/* Card Documents */}
|
||||||
|
|
@ -375,17 +552,70 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
|
||||||
<FileText className="size-5 text-blue-600" />
|
<FileText className="size-5 text-blue-600" />
|
||||||
<h3 className="font-medium text-slate-800">Documents</h3>
|
<h3 className="font-medium text-slate-800">Documents</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-600 mb-3">
|
|
||||||
Aucun document uploadé.
|
{loadingDocuments ? (
|
||||||
</p>
|
<div className="flex items-center justify-center py-4">
|
||||||
<div className="space-y-2">
|
<Loader2 className="size-6 text-blue-600 animate-spin" />
|
||||||
<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...
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Card Contrats */}
|
{/* Card Contrats */}
|
||||||
|
|
@ -522,6 +752,51 @@ export default function SalariesGrid({ initialData, activeOrgId }: { initialData
|
||||||
setRows(prev => prev.map(r => r.id === updatedSalarie.id ? updatedSalarie : r));
|
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>
|
||||||
</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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
token,
|
token,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue