Pagination + staff signature électronique menu

This commit is contained in:
odentas 2025-10-13 18:24:47 +02:00
parent 4ff40ed3e5
commit 9a130dbeef
19 changed files with 2522 additions and 139 deletions

View file

@ -0,0 +1,91 @@
# Amélioration de la Pagination - Page Contrats
## Date
13 octobre 2025
## Résumé
Amélioration de la pagination de la page `/contrats` pour offrir une meilleure expérience utilisateur avec :
- Pagination affichée en haut ET en bas du tableau
- Affichage du nombre total de contrats (pas seulement le nombre affiché)
- Affichage du nombre total de pages
- Possibilité de choisir le nombre d'éléments par page (10, 50, 100)
## Modifications apportées
### 1. API Backend (`app/api/contrats/route.ts`)
- ✅ Ajout de `total` et `totalPages` dans la réponse JSON
- ✅ Calcul correct : `total: filtered.length` et `totalPages: Math.ceil(filtered.length / limit)`
- ✅ Mise à jour pour le mode démo également
### 2. Frontend (`app/(app)/contrats/page.tsx`)
#### a) Type de données
- ✅ Mise à jour du type de retour de l'API pour inclure `total?: number` et `totalPages?: number`
- ✅ Extraction des valeurs : `const total = data?.total ?? 0;` et `const totalPages = data?.totalPages ?? 0;`
#### b) Nouveau composant Pagination
Création d'un composant réutilisable avec :
```typescript
type PaginationProps = {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
isFetching?: boolean;
itemsCount: number;
};
```
**Fonctionnalités :**
- Navigation avec boutons Précédent/Suivant (désactivés aux extrémités)
- Affichage "Page X sur Y"
- Sélecteur pour changer le nombre d'éléments affichés (10, 50, 100)
- Indicateur de chargement pendant les requêtes
- Affichage du nombre total de contrats et du nombre affiché
#### c) Intégration
- ✅ **Pagination supérieure** : Ajoutée avant le tableau (uniquement si des contrats sont affichés)
- ✅ **Pagination inférieure** : Remplace l'ancienne pagination simple sous le tableau
## Comportement
### Affichage
- La pagination apparaît en haut du tableau uniquement s'il y a des résultats
- La pagination en bas est toujours présente (même sans résultats)
- Format responsive adapté mobile/desktop
### Informations affichées
```
Page 1 sur 5 | Afficher : [10 ▼] | 47 contrats au total • 10 affichés
```
### Changement de limite
- Quand l'utilisateur change le nombre d'éléments par page, la page est automatiquement réinitialisée à 1
- Les options disponibles : 10, 50, 100 contrats par page
### Navigation
- Boutons Précédent/Suivant avec icônes chevron
- Désactivation automatique aux extrémités (page 1 ou dernière page)
- Effet hover sur les boutons actifs
## Styles
- Design cohérent avec le reste de l'application
- Support du mode sombre (dark mode)
- Bordures et espacements harmonieux
- Icônes Lucide React
## Tests recommandés
1. ✅ Vérifier l'affichage avec différents nombres de contrats (0, 1-10, 11-50, 50+)
2. ✅ Tester la navigation entre les pages
3. ✅ Tester le changement de limite (10/50/100)
4. ✅ Vérifier que le total affiché est correct
5. ✅ Tester en mode CDDU et Régime Général
6. ✅ Tester avec les filtres (recherche, période, organisation pour staff)
7. ✅ Vérifier le responsive mobile
8. ✅ Tester en mode démo
## Fichiers modifiés
- `/Users/renaud/Projet Nouvel Espace Paie/app/api/contrats/route.ts`
- `/Users/renaud/Projet Nouvel Espace Paie/app/(app)/contrats/page.tsx`

View file

@ -0,0 +1,115 @@
# Amélioration de la Pagination - Page Salariés
## Date
13 octobre 2025
## Résumé
Amélioration de la pagination de la page `/salaries` pour offrir une meilleure expérience utilisateur avec :
- Pagination affichée en haut ET en bas du tableau
- Affichage du nombre total de salariés (pas seulement le nombre affiché)
- Affichage du nombre total de pages
- Possibilité de choisir le nombre d'éléments par page (10, 50, 100)
- Suppression de l'ancienne pagination dans la barre d'en-tête
## Modifications apportées
### 1. API Backend (`app/api/salaries/route.ts`)
- ✅ Ajout de `totalPages` dans le type `SalariesResponse`
- ✅ Calcul de `totalPages` : `Math.ceil(total / limit)`
- ✅ Ajout de `totalPages` dans la réponse JSON
### 2. Frontend (`app/(app)/salaries/page.tsx`)
#### a) Type de données
- ✅ Mise à jour du type `SalariesResponse` pour inclure `totalPages?: number`
- ✅ Extraction des valeurs : `const total = data?.total ?? 0;` et `const totalPages = data?.totalPages ?? 0;`
- ✅ Mise à jour des données démo pour inclure `totalPages`
#### b) Nouveau composant Pagination
Création d'un composant réutilisable identique à celui de la page contrats avec :
```typescript
type PaginationProps = {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
isFetching?: boolean;
itemsCount: number;
position?: 'top' | 'bottom';
};
```
**Fonctionnalités :**
- Navigation avec boutons Précédent/Suivant (désactivés aux extrémités)
- Affichage "Page X sur Y"
- Sélecteur pour changer le nombre d'éléments affichés (10, 50, 100)
- Indicateur de chargement pendant les requêtes
- Affichage du nombre total de salariés et du nombre affiché
- Support des positions 'top' et 'bottom' pour contrôler l'affichage de la bordure
#### c) Changement de limite
- ✅ Modification de `const [limit] = useState(25)` vers `const [limit, setLimit] = useState(10)`
- ✅ Possibilité de changer dynamiquement entre 10, 50 et 100 salariés par page
#### d) Refactoring de la structure
- ✅ Suppression du code `headerRight` (pagination dans la barre de titre)
- ✅ Remplacement du composant `Section` par une structure `<section>` directe pour mieux contrôler le layout
- ✅ Ajout de la pagination supérieure avant le tableau (uniquement si des résultats sont affichés)
- ✅ Ajout de la pagination inférieure dans la section du tableau
## Comportement
### Affichage
- La pagination apparaît en haut du tableau uniquement s'il y a des résultats
- La pagination en bas est toujours présente (intégrée dans la section du tableau)
- Format responsive adapté mobile/desktop
### Informations affichées
```
Page 1 sur 5 | Afficher : [10 ▼] | 47 salariés au total • 10 affichés
```
### Changement de limite
- Quand l'utilisateur change le nombre d'éléments par page, la page est automatiquement réinitialisée à 1
- Les options disponibles : 10, 50, 100 salariés par page
- Par défaut : 10 salariés (au lieu de 25 précédemment)
### Navigation
- Boutons Précédent/Suivant avec icônes chevron
- Désactivation automatique aux extrémités (page 1 ou dernière page)
- Effet hover sur les boutons actifs
## Styles
- Design cohérent avec la page contrats
- Bordures et espacements harmonieux
- Icônes Lucide React
- Pas de bordure supérieure pour la pagination du haut (grâce à `position="top"`)
## Améliorations par rapport à l'ancienne version
1. ✅ Suppression de la pagination dans la barre d'en-tête (encombrait l'interface)
2. ✅ Pagination cohérente en haut et en bas du tableau
3. ✅ Information claire sur le nombre total de salariés
4. ✅ Possibilité de changer le nombre d'éléments affichés
5. ✅ Affichage du nombre de pages pour mieux se repérer
## Tests recommandés
1. ✅ Vérifier l'affichage avec différents nombres de salariés (0, 1-10, 11-50, 50+)
2. ✅ Tester la navigation entre les pages
3. ✅ Tester le changement de limite (10/50/100)
4. ✅ Vérifier que le total affiché est correct
5. ✅ Tester avec les filtres (recherche, organisation pour staff)
6. ✅ Vérifier le responsive mobile
7. ✅ Tester en mode démo
## Fichiers modifiés
- `/Users/renaud/Projet Nouvel Espace Paie/app/api/salaries/route.ts`
- `/Users/renaud/Projet Nouvel Espace Paie/app/(app)/salaries/page.tsx`
## Cohérence avec la page Contrats
Les deux pages (contrats et salariés) ont maintenant exactement la même pagination :
- Même composant `Pagination` avec les mêmes props
- Même comportement et mêmes fonctionnalités
- Même design et même expérience utilisateur
- Code réutilisable et maintenable

View file

@ -0,0 +1,481 @@
# Gestion des Productions - Documentation Staff
## 📋 Vue d'ensemble
La page **Gestion des Productions** (`/staff/gestion-productions`) est une interface complète permettant aux membres du staff de gérer l'ensemble des productions et spectacles de toutes les organisations.
## ✨ Fonctionnalités
### 🎬 CRUD Complet
- **Créer** : Ajouter de nouvelles productions avec nom, référence, date de déclaration et organisation
- **Lire** : Visualiser toutes les productions dans un tableau moderne avec filtres
- **Mettre à jour** : Modifier les informations d'une production existante
- **Supprimer** : Supprimer une production (avec vérification d'utilisation dans les contrats)
### 🔍 Recherche et Filtrage
- **Recherche textuelle** : Recherche en temps réel par nom ou référence
- **Filtre par organisation** : Filtrer les productions d'une organisation spécifique
- **Effacement rapide** : Bouton pour réinitialiser tous les filtres
### 📊 Interface Moderne
- **Design ultra-moderne** : Interface avec gradients, ombres et animations fluides
- **Modales élégantes** : Modales avec backdrop blur et animations d'entrée
- **Badges et icônes** : Affichage visuel clair avec icônes Lucide
- **Responsive** : Adaptation automatique mobile/tablette/desktop
- **États de chargement** : Spinners et messages informatifs
## 🎨 Interface Utilisateur
### Header
```
┌────────────────────────────────────────────────────┐
│ Gestion des productions [+ Nouvelle] │
│ Gérez les productions et spectacles... │
└────────────────────────────────────────────────────┘
```
### Barre de Filtres
- **Recherche** : Input avec icône de recherche et bouton de réinitialisation
- **Filtres** : Bouton avec badge indiquant le nombre de filtres actifs
- **Organisation** : Dropdown pour filtrer par organisation
### Tableau des Productions
| Colonne | Description | Type |
|---------|-------------|------|
| Nom | Nom de la production | Texte |
| Référence | Numéro d'objet/référence | Badge avec icône Hash |
| Date déclaration | Date de déclaration | Date avec icône Calendar |
| Organisation | Organisation liée | Texte |
| Actions | Éditer / Supprimer | Boutons icônes |
### Modales
#### Modal Création/Édition
```
┌──────────────────────────────────────┐
│ 🏢 Nouvelle production ✕ │
├──────────────────────────────────────┤
│ │
│ 📄 Nom de la production * │
│ [Ex: Festival d'Avignon 2025] │
│ │
│ # Numéro d'objet / Référence │
│ [Ex: PROD-2025-001] │
│ │
│ 📅 Date de déclaration │
│ [Date picker] │
│ │
│ 🏢 Organisation * │
│ [Dropdown] │
│ │
│ [Annuler] [Créer ⟳] │
└──────────────────────────────────────┘
```
#### Modal Suppression
```
┌──────────────────────────────────────┐
│ 🗑️ Confirmer la suppression │
│ Cette action est irréversible │
├──────────────────────────────────────┤
│ │
│ Voulez-vous vraiment supprimer │
│ la production "Festival 2025" ? │
│ Référence : PROD-001 │
│ │
│ [Annuler] [Supprimer ⟳] │
└──────────────────────────────────────┘
```
## 🔧 Implémentation Technique
### Architecture
```
app/(app)/staff/gestion-productions/
└── page.tsx (Client Component)
app/api/staff/productions/
├── route.ts (GET, POST)
└── [id]/
└── route.ts (GET, PATCH, DELETE)
components/
└── Sidebar.tsx (+ lien navigation)
```
### Hooks React Query
#### `useStaffCheck()`
Vérifie si l'utilisateur connecté est staff.
```typescript
const { data: staffCheck, isLoading } = useStaffCheck();
// Returns: { isStaff: boolean }
```
#### `useOrganizations()`
Récupère la liste de toutes les organisations.
```typescript
const { data: organizations } = useOrganizations();
// Returns: Organization[]
```
#### `useProductions(orgId?, searchQuery?)`
Récupère les productions avec filtres optionnels.
```typescript
const { data: productions, isLoading, error } = useProductions(orgId, searchQuery);
// Returns: Production[]
```
### Types TypeScript
```typescript
type Production = {
id: string;
name: string;
reference: string | null;
declaration_date: string | null;
org_id: string;
created_at?: string;
updated_at?: string;
};
type Organization = {
id: string;
name: string;
structure_api: string;
};
```
## 🌐 Routes API
### GET `/api/staff/productions`
**Description** : Liste toutes les productions avec filtres optionnels
**Query Parameters** :
- `org_id` (optional) : Filtrer par organisation
- `q` (optional) : Recherche textuelle (nom ou référence)
**Response** :
```json
{
"productions": [
{
"id": "uuid",
"name": "Festival d'Avignon 2025",
"reference": "PROD-2025-001",
"declaration_date": "2025-01-15",
"org_id": "org-uuid",
"created_at": "2025-01-10T10:00:00Z",
"updated_at": "2025-01-10T10:00:00Z"
}
]
}
```
**Codes d'erreur** :
- `401` : Non authentifié
- `403` : Accès refusé (non-staff)
- `500` : Erreur serveur
### POST `/api/staff/productions`
**Description** : Créer une nouvelle production
**Body** :
```json
{
"name": "Festival d'Avignon 2025",
"reference": "PROD-2025-001",
"declaration_date": "2025-01-15",
"org_id": "org-uuid"
}
```
**Validation** :
- `name` : Requis, non vide
- `org_id` : Requis, doit exister dans `organizations`
- `reference` : Optionnel
- `declaration_date` : Optionnel
**Response** :
```json
{
"production": { /* Production créée */ }
}
```
**Codes d'erreur** :
- `400` : Validation échouée
- `401` : Non authentifié
- `403` : Accès refusé (non-staff)
- `500` : Erreur serveur
### GET `/api/staff/productions/[id]`
**Description** : Récupérer une production spécifique
**Response** :
```json
{
"production": { /* Production */ }
}
```
**Codes d'erreur** :
- `401` : Non authentifié
- `403` : Accès refusé (non-staff)
- `404` : Production introuvable
- `500` : Erreur serveur
### PATCH `/api/staff/productions/[id]`
**Description** : Mettre à jour une production
**Body** (tous les champs optionnels) :
```json
{
"name": "Nouveau nom",
"reference": "NEW-REF",
"declaration_date": "2025-02-01",
"org_id": "autre-org-uuid"
}
```
**Response** :
```json
{
"production": { /* Production mise à jour */ }
}
```
**Codes d'erreur** :
- `400` : Validation échouée
- `401` : Non authentifié
- `403` : Accès refusé (non-staff)
- `404` : Production introuvable
- `500` : Erreur serveur
### DELETE `/api/staff/productions/[id]`
**Description** : Supprimer une production
**Vérifications** :
- La production existe
- La production n'est pas utilisée dans des contrats CDDU
**Response** :
```json
{
"message": "Production supprimée avec succès",
"deleted_id": "uuid"
}
```
**Codes d'erreur** :
- `400` : Production utilisée dans des contrats
- `401` : Non authentifié
- `403` : Accès refusé (non-staff)
- `404` : Production introuvable
- `500` : Erreur serveur
## 🔐 Sécurité
### Vérifications
- **Authentification** : Session Supabase requise
- **Autorisation** : Vérification `staff_users.is_staff = true`
- **Validation** : Toutes les entrées utilisateur sont validées
- **Protection suppression** : Vérification des dépendances (contrats)
### Permissions
- **Staff uniquement** : Toutes les opérations réservées au staff
- **Aucune restriction par organisation** : Le staff peut gérer toutes les organisations
## 📱 Responsive Design
### Desktop (≥1024px)
- Tableau pleine largeur
- Filtres sur une ligne
- Modales centrées (max-width: 640px)
### Tablet (768px - 1023px)
- Tableau scrollable horizontal
- Filtres sur deux lignes
- Modales adaptées
### Mobile (<768px)
- Tableau scrollable
- Filtres empilés verticalement
- Modales plein écran avec padding
## 🎨 Design System
### Couleurs
- **Primary Gradient** : `from-indigo-600 to-purple-600`
- **Hover Gradient** : `from-indigo-700 to-purple-700`
- **Background** : `bg-white`, `bg-slate-50`
- **Border** : `border-slate-200`
- **Text** : `text-slate-900`, `text-slate-600`
### Animations
- **Modal entrée** : `animate-in fade-in zoom-in-95 duration-200`
- **Backdrop** : `backdrop-blur-sm`
- **Transitions** : `transition-all`, `transition-colors`
### Icônes (Lucide React)
- `Building2` : Organisations
- `FileText` : Nom de production
- `Hash` : Référence
- `Calendar` : Date de déclaration
- `Plus` : Créer
- `Edit2` : Éditer
- `Trash2` : Supprimer
- `Filter` : Filtres
- `Search` : Recherche
- `Loader2` : Chargement
- `Clapperboard` : Icône sidebar
## 🚀 Utilisation
### Créer une production
1. Cliquer sur **"+ Nouvelle production"**
2. Remplir le formulaire :
- Nom (requis)
- Référence (optionnel)
- Date de déclaration (optionnel)
- Organisation (requis)
3. Cliquer sur **"Créer"**
### Modifier une production
1. Cliquer sur l'icône ✏️ dans la colonne Actions
2. Modifier les champs souhaités
3. Cliquer sur **"Enregistrer"**
### Supprimer une production
1. Cliquer sur l'icône 🗑️ dans la colonne Actions
2. Confirmer la suppression dans le modal
3. La production est supprimée si elle n'est pas utilisée
### Filtrer les productions
1. Cliquer sur **"Filtres"**
2. Sélectionner une organisation
3. Les résultats sont filtrés automatiquement
### Rechercher une production
1. Saisir du texte dans la barre de recherche
2. Les résultats sont filtrés en temps réel
3. La recherche porte sur le nom et la référence
## 🐛 Gestion des Erreurs
### Messages utilisateur
- **Champ requis** : "Le nom est requis"
- **Organisation requise** : "L'organisation est requise"
- **Production utilisée** : "Cette production est utilisée dans des contrats..."
- **Erreur réseau** : "Erreur lors du chargement des productions"
### États
- **Loading** : Spinner avec message "Chargement..."
- **Empty** : Message "Aucune production trouvée"
- **Error** : Message d'erreur en rouge avec détails
## 📊 Performances
### Optimisations
- **React Query** : Cache des requêtes API (15s)
- **useMemo** : Mémoïsation du filtrage local
- **Invalidation sélective** : Rafraîchissement uniquement des données modifiées
- **Debouncing** : Pas de debounce nécessaire (filtrage instant)
### Stale Time
- `useStaffCheck()` : 30 secondes
- `useOrganizations()` : 60 secondes
- `useProductions()` : 15 secondes
## 🔗 Intégration
### Sidebar
La page est accessible via la sidebar staff :
```tsx
<Link href="/staff/gestion-productions">
<Clapperboard className="w-4 h-4" />
Gestion des productions
</Link>
```
### Navigation
- URL : `/staff/gestion-productions`
- Position : Entre "Virements salaires" et "Gestion des utilisateurs"
- Icône : 🎬 Clapperboard
## 🧪 Tests
### Scénarios de test
1. **Accès non-staff** : Vérifier refus d'accès
2. **Création** : Créer une production valide
3. **Création invalide** : Vérifier validation (nom vide, org manquante)
4. **Modification** : Éditer une production existante
5. **Suppression** : Supprimer une production non utilisée
6. **Suppression protection** : Tenter de supprimer une production utilisée
7. **Filtres** : Vérifier filtrage par organisation
8. **Recherche** : Vérifier recherche textuelle
9. **Responsive** : Tester sur mobile/tablet/desktop
### Console logs
```javascript
// Vérifier les requêtes API
// Réseau > Fetch/XHR
// GET /api/staff/productions
// POST /api/staff/productions
// PATCH /api/staff/productions/[id]
// DELETE /api/staff/productions/[id]
```
## 📝 Todo / Améliorations futures
- [ ] Export CSV/Excel des productions
- [ ] Tri des colonnes du tableau
- [ ] Pagination (si + de 100 productions)
- [ ] Filtre par date de déclaration (plage)
- [ ] Import CSV de productions en masse
- [ ] Historique des modifications
- [ ] Duplication de production
- [ ] Statistiques (nb productions par org, par période)
- [ ] Champ description/notes
- [ ] Tags/catégories de productions
- [ ] Lien direct vers les contrats associés
## 📚 Références
- [Lucide Icons](https://lucide.dev/)
- [TanStack Query (React Query)](https://tanstack.com/query/latest)
- [Tailwind CSS](https://tailwindcss.com/)
- [Next.js App Router](https://nextjs.org/docs/app)
- [Supabase Client](https://supabase.com/docs/reference/javascript)

View file

@ -0,0 +1,205 @@
# Signatures électroniques sur la page Virements Salaires (Staff)
## 📋 Vue d'ensemble
Cette fonctionnalité permet aux utilisateurs **staff** de visualiser les signatures électroniques en attente pour une organisation spécifique directement depuis la page **Virements salaires**.
## ✨ Fonctionnalités
### Pour les utilisateurs Staff uniquement
1. **Menu déroulant des organisations**
- Affiché uniquement pour les utilisateurs staff
- Liste toutes les organisations disponibles
- Permet de sélectionner une organisation pour voir ses données
2. **Section Signatures électroniques**
- Affichée uniquement quand un staff a sélectionné une organisation
- Liste tous les contrats en attente de signature pour l'organisation
- Informations affichées :
- Référence du contrat
- Nom du salarié
- Profession
- Date de début
- Statut signature employeur (✓ Signé / ⏳ En attente)
- Statut signature salarié (✓ Signé / ⏳ En attente)
- Bouton "Voir" pour accéder au lien de signature DocuSeal
## 🔧 Implémentation technique
### Fichiers modifiés
#### 1. `/app/(app)/virements-salaires/page.tsx`
**Nouveaux hooks ajoutés :**
```typescript
// Hook pour récupérer les signatures électroniques d'une organisation
function useSignatures(selectedOrgId?: string)
```
**Nouvelles fonctionnalités :**
- Menu déroulant de sélection d'organisation (visible uniquement pour les staff)
- Section d'affichage des signatures électroniques avec tableau détaillé
- Icônes ajoutées : `FileSignature`, `Eye`
**État local :**
```typescript
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
```
#### 2. `/app/api/signatures-electroniques/contrats/route.ts`
**Modifications :**
- Ajout du support du paramètre `org_id` en query string
- Les utilisateurs staff peuvent passer un `org_id` pour filtrer les signatures d'une organisation spécifique
- Champs supplémentaires retournés :
- `employee_first_name`
- `employee_last_name`
- `role` (profession)
- `start_date`
- `signature_employeur` (booléen)
- `signature_salarie` (booléen)
**Exemple d'appel API :**
```typescript
GET /api/signatures-electroniques/contrats?org_id=<organization_id>
```
### API utilisées
#### 1. GET `/api/staff/organizations`
- Liste toutes les organisations
- Accessible uniquement aux staff
- Retourne : `{ organizations: [{ id, name, structure_api }] }`
#### 2. GET `/api/signatures-electroniques/contrats?org_id=<org_id>`
- Liste les signatures électroniques en attente
- Filtre par organisation si `org_id` fourni
- Accessible aux staff et membres d'organisation
- Retourne : `{ records: [...] }`
## 🎨 Interface utilisateur
### Menu déroulant des organisations
```tsx
<select value={selectedOrgId} onChange={(e) => setSelectedOrgId(e.target.value)}>
<option value="">Toutes les organisations</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
```
### Tableau des signatures
| Colonne | Description |
|---------|-------------|
| Référence | Référence du contrat |
| Salarié·e | Nom complet du salarié |
| Profession | Fonction/rôle du salarié |
| Date début | Date de début du contrat |
| Statut employeur | Badge ✓ Signé ou ⏳ En attente |
| Statut salarié | Badge ✓ Signé ou ⏳ En attente |
| Actions | Bouton "Voir" avec icône Eye |
### États des badges
- **✓ Signé** : Badge vert (`bg-emerald-100 text-emerald-800`)
- **⏳ En attente** : Badge orange (`bg-amber-100 text-amber-800`)
## 🔐 Sécurité et permissions
### Vérifications côté client
- Le menu déroulant et la section signatures ne s'affichent que si `userInfo?.isStaff === true`
- La section signatures ne s'affiche que si une organisation est sélectionnée
### Vérifications côté serveur (API)
- Vérification du statut staff via la table `staff_users`
- Les utilisateurs non-staff ne peuvent voir que les signatures de leur propre organisation
- Les staff peuvent voir les signatures de n'importe quelle organisation
## 📊 Filtrage des signatures
L'API filtre automatiquement les contrats selon les critères suivants :
- `etat_de_la_demande` = "Traitée" (ou variantes)
- Contrats en attente de signature employeur : `contrat_signe_par_employeur` = "non"
- Contrats en attente de signature salarié : `contrat_signe` = "non" + `contrat_signe_par_employeur` = "oui"
## 🚀 Utilisation
### Pour un utilisateur Staff
1. Se connecter avec un compte staff
2. Accéder à la page `/virements-salaires`
3. Sélectionner une organisation dans le menu déroulant en haut de la page
4. La section "Signatures électroniques" apparaît automatiquement sous les virements
5. Consulter les signatures en attente
6. Cliquer sur "Voir" pour accéder au lien DocuSeal
### Cas d'usage
- **Suivi global** : Les staff peuvent surveiller l'état des signatures pour toutes les organisations
- **Support client** : Identifier rapidement les contrats bloqués en attente de signature
- **Vérification** : S'assurer que les signatures sont en cours avant d'effectuer les virements
## 🔄 Synchronisation des données
- Les données des signatures sont chargées automatiquement lors de la sélection d'une organisation
- Le hook `useSignatures` utilise React Query avec un `staleTime` de 15 secondes
- Les données sont mises en cache pour éviter les appels API inutiles
## 📝 Types TypeScript
```typescript
type SignatureRecord = {
id: string;
fields: {
reference: string;
employee_first_name: string;
employee_last_name: string;
role: string; // Profession
start_date: string;
signature_employeur: boolean;
signature_salarie: boolean;
signature_link?: string;
};
};
```
## ⚠️ Points d'attention
1. **Performance** : La liste des organisations est chargée uniquement pour les staff
2. **Cache** : Les signatures sont mises en cache avec React Query
3. **Liens DocuSeal** : Le lien peut être absent si la signature n'a pas encore été initiée
4. **Format des données** : L'API retourne un format compatible avec l'ancien système Airtable
## 🐛 Débogage
### Console navigateur
```javascript
// Vérifier les données chargées
// Dans React Query Devtools, chercher les clés :
// - ["organizations"]
// - ["signatures-electroniques", selectedOrgId]
```
### Logs serveur
```
🔍 [signatures-electroniques] Requête pour scope: employeur, orgId: <org_id>
📊 [signatures-electroniques] Contrats trouvés: X
📋 [signatures-electroniques] Échantillon: [...]
```
## 🔮 Améliorations futures
- [ ] Ajouter un filtre par statut (tout / en attente employeur / en attente salarié)
- [ ] Ajouter une recherche par nom de salarié ou référence
- [ ] Afficher le nombre de signatures en attente dans le titre de section
- [ ] Ajouter un bouton de rafraîchissement manuel
- [ ] Permettre l'envoi de relances depuis cette page
- [ ] Afficher la date de création du contrat
- [ ] Ajouter un tri par date/référence/salarié
## 📚 Documentation associée
- [SIGNATURE_SALARIE_FEATURE.md](./SIGNATURE_SALARIE_FEATURE.md) - Documentation des signatures salarié
- [DOCUSEAL_ENV_VARIABLES.md](./DOCUSEAL_ENV_VARIABLES.md) - Variables d'environnement DocuSeal
- [STAFF_MAINTENANCE_ACCESS_GUIDE.md](./STAFF_MAINTENANCE_ACCESS_GUIDE.md) - Guide d'accès staff

View file

@ -93,7 +93,7 @@ function useContrats(params: { regime: "CDDU" | "RG"; status: "en_cours" | "term
// Build final clientInfo to pass to api(): if UI provided explicit org filter, override id
const finalClientInfo = clientInfo ? { ...clientInfo, id: org ?? clientInfo.id } : (org ? { id: org, name: "Organisation", api_name: undefined } as ClientInfo : null);
return api<{ items: Contrat[]; page: number; limit: number; hasMore: boolean }>(base + qs, {}, finalClientInfo);
return api<{ items: Contrat[]; page: number; limit: number; hasMore: boolean; total?: number; totalPages?: number }>(base + qs, {}, finalClientInfo);
},
staleTime: 15_000,
placeholderData: (prev) => prev,
@ -125,6 +125,81 @@ function humanizeEtat(raw?: string){
return undefined as unknown as string;
}
// --- Composant de pagination
type PaginationProps = {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
isFetching?: boolean;
itemsCount: number;
position?: 'top' | 'bottom';
};
function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChange, isFetching, itemsCount, position = 'bottom' }: PaginationProps) {
return (
<div className={`p-3 flex flex-col sm:flex-row items-center gap-3 ${position === 'bottom' ? 'border-t dark:border-slate-800' : ''}`}>
{/* Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40 hover:bg-slate-50 dark:hover:bg-slate-800 disabled:hover:bg-transparent"
>
<ChevronLeft className="w-4 h-4"/>
</button>
<div className="text-sm">
Page <strong>{page}</strong> sur <strong>{totalPages || 1}</strong>
</div>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40 hover:bg-slate-50 dark:hover:bg-slate-800 disabled:hover:bg-transparent"
>
<ChevronRight className="w-4 h-4"/>
</button>
</div>
{/* Sélecteur de limite */}
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-600 dark:text-slate-400">Afficher :</span>
<select
value={limit}
onChange={(e) => onLimitChange(parseInt(e.target.value, 10))}
className="px-2 py-1 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
>
<option value={10}>10</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* Informations */}
<div className="sm:ml-auto text-sm text-slate-600 dark:text-slate-400">
{isFetching ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Mise à jour
</span>
) : (
<span>
{total > 0 ? (
<>
<strong>{total}</strong> contrat{total > 1 ? 's' : ''} au total
{itemsCount > 0 && `${itemsCount} affiché${itemsCount > 1 ? 's' : ''}`}
</>
) : (
'Aucun contrat'
)}
</span>
)}
</div>
</div>
);
}
function safeEtat(etat?: string){
const key = humanizeEtat(etat) as keyof typeof ETATS | undefined;
if (key && ETATS[key]) return ETATS[key];
@ -202,6 +277,8 @@ export default function PageContrats(){
});
const items = data?.items ?? [];
const hasMore = data?.hasMore ?? false;
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 0;
useEffect(() => {
// Load organizations for the selector
@ -351,6 +428,23 @@ export default function PageContrats(){
)}
</section>
{/* Pagination supérieure */}
{items.length > 0 && (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={items.length}
position="top"
/>
</section>
)}
{/* Tableau */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="overflow-x-auto overflow-visible pb-6">
@ -488,13 +582,18 @@ export default function PageContrats(){
</table>
</div>
{/* Pagination */}
<div className="p-3 flex items-center gap-3 border-t dark:border-slate-800">
<button onClick={()=> setPage(p=> Math.max(1,p-1))} disabled={page===1} className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"><ChevronLeft className="w-4 h-4"/></button>
<div className="text-sm">Page <strong>{page}</strong></div>
<button onClick={()=> setPage(p=> p + 1)} disabled={!hasMore} className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"><ChevronRight className="w-4 h-4"/></button>
<div className="ml-auto text-sm text-slate-500">{isFetching ? 'Mise à jour…' : `${items.length} élément${items.length>1?'s':''}${hasMore ? ' (plus disponibles)' : ''}`}</div>
</div>
{/* Pagination inférieure */}
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={items.length}
position="bottom"
/>
</section>
</div>
);

View file

@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Loader2, CheckCircle2, XCircle, FileDown, Edit } from "lucide-react";
import { Loader2, CheckCircle2, XCircle, FileDown, Edit, ChevronLeft, ChevronRight } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------------- Types ----------------
@ -33,6 +33,8 @@ type BillingResponse = {
page: number;
limit: number;
hasMore: boolean;
total?: number;
totalPages?: number;
};
};
@ -73,6 +75,81 @@ function Section({ title, children }: { title: string; children: React.ReactNode
);
}
// -------------- Composant Pagination --------------
type PaginationProps = {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
isFetching?: boolean;
itemsCount: number;
position?: 'top' | 'bottom';
};
function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChange, isFetching, itemsCount, position = 'bottom' }: PaginationProps) {
return (
<div className={`p-3 flex flex-col sm:flex-row items-center gap-3 ${position === 'bottom' ? 'border-t' : ''}`}>
{/* Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronLeft className="w-4 h-4"/>
</button>
<div className="text-sm">
Page <strong>{page}</strong> sur <strong>{totalPages || 1}</strong>
</div>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronRight className="w-4 h-4"/>
</button>
</div>
{/* Sélecteur de limite */}
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-600">Afficher :</span>
<select
value={limit}
onChange={(e) => onLimitChange(parseInt(e.target.value, 10))}
className="px-2 py-1 rounded-lg border bg-white text-sm"
>
<option value={10}>10</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* Informations */}
<div className="sm:ml-auto text-sm text-slate-600">
{isFetching ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Mise à jour
</span>
) : (
<span>
{total > 0 ? (
<>
<strong>{total}</strong> facture{total > 1 ? 's' : ''} au total
{itemsCount > 0 && `${itemsCount} affichée${itemsCount > 1 ? 's' : ''}`}
</>
) : (
'Aucune facture'
)}
</span>
)}
</div>
</div>
);
}
// -------------- Data hook --------------
function useBilling(page: number, limit: number) {
// Récupération dynamique des infos client via /api/me
@ -113,11 +190,13 @@ export default function FacturationPage() {
usePageTitle("Facturation");
const [page, setPage] = useState(1);
const limit = 10;
const { data, isLoading, isError, error } = useBilling(page, limit);
const [limit, setLimit] = useState(10);
const { data, isLoading, isError, error, isFetching } = useBilling(page, limit);
const items = data?.factures.items ?? [];
const hasMore = data?.factures.hasMore ?? false;
const total = data?.factures.total ?? 0;
const totalPages = data?.factures.totalPages ?? 0;
return (
<main className="space-y-5">
@ -176,6 +255,23 @@ export default function FacturationPage() {
)}
</Section>
{/* Pagination supérieure */}
{items.length > 0 && (
<section className="rounded-2xl border bg-white">
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={items.length}
position="top"
/>
</section>
)}
{/* Factures */}
<Section title="Vos factures">
<div className="text-xs text-slate-500 mb-3 flex items-center gap-4">
@ -243,28 +339,20 @@ export default function FacturationPage() {
</div>
)}
{/* Pagination */}
{/* Pagination inférieure */}
{data && (
<div className="mt-4 flex items-center justify-between text-sm">
<div>
Page {data.factures.page} {items.length} élément{items.length > 1 ? "s" : ""}
</div>
<div className="flex items-center gap-2">
<button
className="px-3 py-1 rounded-md border disabled:opacity-40"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Précédent
</button>
<button
className="px-3 py-1 rounded-md border disabled:opacity-40"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
Suivant
</button>
</div>
<div className="mt-4">
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={items.length}
position="bottom"
/>
</div>
)}
</Section>

View file

@ -35,6 +35,7 @@ export default function Dashboard() {
const items = data?.items ?? [];
const hasMore = data?.hasMore ?? false;
const total = data?.total ?? 0;
// Récupérer les infos utilisateur pour personnaliser l'accueil
const { data: clientInfo } = useQuery({
@ -68,11 +69,13 @@ export default function Dashboard() {
: null;
const count = items.length;
const countLabel = count === 0
const countLabel = total === 0
? "Vous n'avez aucun contrat en cours"
: count === 1
? `Vous avez 1 contrat en cours (page ${currentPage})`
: `Vous avez ${count} contrats en cours (page ${currentPage})`;
: total === 1
? "Vous avez 1 contrat en cours"
: count === total
? `Vous avez ${total} contrat${total > 1 ? 's' : ''} en cours`
: `Vous avez ${total} contrats en cours • ${count} affiché${count > 1 ? 's' : ''} sur cette page`;
const handlePrevPage = () => {
if (currentPage > 1) {

View file

@ -29,6 +29,7 @@ type SalariesResponse = {
page: number;
limit: number;
total?: number;
totalPages?: number;
hasMore: boolean;
};
@ -56,6 +57,81 @@ function lastContractHref(c?: SalarieRow["dernier_contrat"]) {
return isMulti ? `/contrats-multi/${c.id}` : `/contrats/${c.id}`;
}
/* ===== Composant de pagination ===== */
type PaginationProps = {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
isFetching?: boolean;
itemsCount: number;
position?: 'top' | 'bottom';
};
function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChange, isFetching, itemsCount, position = 'bottom' }: PaginationProps) {
return (
<div className={`p-3 flex flex-col sm:flex-row items-center gap-3 ${position === 'bottom' ? 'border-t' : ''}`}>
{/* Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronLeft className="w-4 h-4"/>
</button>
<div className="text-sm">
Page <strong>{page}</strong> sur <strong>{totalPages || 1}</strong>
</div>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronRight className="w-4 h-4"/>
</button>
</div>
{/* Sélecteur de limite */}
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-600">Afficher :</span>
<select
value={limit}
onChange={(e) => onLimitChange(parseInt(e.target.value, 10))}
className="px-2 py-1 rounded-lg border bg-white text-sm"
>
<option value={10}>10</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* Informations */}
<div className="sm:ml-auto text-sm text-slate-600">
{isFetching ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Mise à jour
</span>
) : (
<span>
{total > 0 ? (
<>
<strong>{total}</strong> salarié{total > 1 ? 's' : ''} au total
{itemsCount > 0 && `${itemsCount} affiché${itemsCount > 1 ? 's' : ''}`}
</>
) : (
'Aucun salarié'
)}
</span>
)}
</div>
</div>
);
}
/* ===== Data hook ===== */
function useSalaries(page: number, limit: number, search: string, org?: string | null) {
// 🎭 Détection directe du mode démo
@ -166,6 +242,7 @@ function useSalaries(page: number, limit: number, search: string, org?: string |
page,
limit,
total: filteredSalaries.length,
totalPages: Math.ceil(filteredSalaries.length / limit),
hasMore: endIndex < filteredSalaries.length,
},
isLoading: false,
@ -232,7 +309,7 @@ export default function SalariesPage() {
const initialPage = Number(searchParams.get("page") || 1);
const initialSearch = searchParams.get("q") || "";
const [page, setPage] = useState(initialPage);
const [limit] = useState(25);
const [limit, setLimit] = useState(10);
const [query, setQuery] = useState(initialSearch);
const debouncedSearch = useDebouncedCallback((v: string) => {
@ -263,6 +340,8 @@ export default function SalariesPage() {
const { data, isLoading, isFetching } = useSalaries(page, limit, query, selectedOrg);
const rows: SalarieRow[] = data?.items ?? [];
const hasMore: boolean = data?.hasMore ?? false;
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 0;
useEffect(() => {
let mounted = true;
@ -284,50 +363,6 @@ export default function SalariesPage() {
const [selectedMatricule, setSelectedMatricule] = useState<string | null>(null);
const [selectedNom, setSelectedNom] = useState<string | null>(null);
const headerRight = useMemo(
() => (
<div className="flex items-center gap-2 text-sm">
<button
onClick={() => {
setPage((p) => {
const next = Math.max(1, p - 1);
const sp = new URLSearchParams(searchParams.toString());
sp.set("page", String(next));
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
router.replace(`/salaries?${sp.toString()}`);
return next;
});
}}
disabled={page === 1 || isFetching}
className="px-2 py-1 rounded-lg border disabled:opacity-40"
title="Page précédente"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div>Page <strong>{page}</strong></div>
<button
onClick={() => {
if (!hasMore || isFetching) return;
setPage((p) => {
const next = p + 1;
const sp = new URLSearchParams(searchParams.toString());
sp.set("page", String(next));
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
router.replace(`/salaries?${sp.toString()}`);
return next;
});
}}
disabled={!hasMore || isFetching}
className="px-2 py-1 rounded-lg border disabled:opacity-40"
title="Page suivante"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
),
[page, hasMore, isFetching, query, router, searchParams]
);
return (
<div className="space-y-5">
{/* Barre de titre + actions */}
@ -375,20 +410,40 @@ export default function SalariesPage() {
>
<Plus className="w-4 h-4" /> Nouveau salarié
</Link>
{headerRight}
</div>
</div>
{/* Pagination supérieure */}
{rows.length > 0 && (
<section className="rounded-2xl border bg-white">
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={rows.length}
position="top"
/>
</section>
)}
{/* Tableau */}
<Section title="Liste">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
Liste
</div>
{isLoading ? (
<div className="py-10 text-center text-slate-500">
<div className="p-4 py-10 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement des salarié·e·s
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<>
<div className="p-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<th className="text-left font-medium px-3 py-2">Salarié</th>
@ -459,46 +514,23 @@ export default function SalariesPage() {
</tbody>
</table>
</div>
)}
</Section>
{/* Pagination bas de page */}
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => {
setPage((p) => {
const next = Math.max(1, p - 1);
const sp = new URLSearchParams(searchParams.toString());
sp.set("page", String(next));
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
router.replace(`/salaries?${sp.toString()}`);
return next;
});
}}
disabled={page === 1 || isFetching}
className="px-3 py-2 rounded-lg border disabled:opacity-40"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="text-sm">Page <strong>{page}</strong></div>
<button
onClick={() => {
if (!hasMore || isFetching) return;
setPage((p) => {
const next = p + 1;
const sp = new URLSearchParams(searchParams.toString());
sp.set("page", String(next));
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
router.replace(`/salaries?${sp.toString()}`);
return next;
});
}}
disabled={!hasMore || isFetching}
className="px-3 py-2 rounded-lg border disabled:opacity-40"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Pagination inférieure dans la Section */}
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={rows.length}
position="bottom"
/>
</>
)}
</section>
{newContratOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { FileSignature, BellRing, XCircle } from 'lucide-react';
import Script from 'next/script';
import { usePageTitle } from '@/hooks/usePageTitle';
import { useQuery } from '@tanstack/react-query';
type AirtableRecord = {
id: string;
@ -27,6 +28,58 @@ function classNames(...arr: Array<string | false | null | undefined>) {
return arr.filter(Boolean).join(' ');
}
// Hook pour récupérer les infos utilisateur
function useUserInfo() {
return useQuery({
queryKey: ["user-info"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return null;
const me = await res.json();
return {
isStaff: Boolean(me.is_staff || me.isStaff),
orgId: me.orgId || me.active_org_id || null,
orgName: me.orgName || me.active_org_name || "Organisation",
};
} catch {
return null;
}
},
staleTime: 30_000,
});
}
// Hook pour récupérer les organisations (staff uniquement)
function useOrganizations() {
const { data: userInfo, isSuccess: userInfoLoaded } = useUserInfo();
return useQuery({
queryKey: ["organizations"],
queryFn: async () => {
try {
const res = await fetch("/api/staff/organizations", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return [];
const data = await res.json();
return data.organizations || [];
} catch {
return [];
}
},
enabled: userInfoLoaded && !!userInfo?.isStaff,
staleTime: 60_000,
});
}
export default function SignaturesElectroniques() {
usePageTitle("Signatures électroniques");
@ -50,15 +103,26 @@ export default function SignaturesElectroniques() {
// État pour les relances
const [loadingRelance, setLoadingRelance] = useState<Record<string, boolean>>({});
// État pour le sélecteur d'organisation (staff uniquement)
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
// Charger les infos utilisateur et organisations
const { data: userInfo } = useUserInfo();
const { data: organizations } = useOrganizations();
// Suppression de pollActive et pollTimer car le polling a été retiré
// Load current contracts to sign (server-side API fetches Airtable)
async function load() {
try {
setError(null);
// Ajouter le paramètre org_id si sélectionné (staff uniquement)
const orgParam = selectedOrgId ? `&org_id=${selectedOrgId}` : '';
const [rEmp, rSal] = await Promise.all([
fetch('/api/signatures-electroniques/contrats?scope=employeur', { cache: 'no-store' }),
fetch('/api/signatures-electroniques/contrats?scope=salarie', { cache: 'no-store' }),
fetch(`/api/signatures-electroniques/contrats?scope=employeur${orgParam}`, { cache: 'no-store' }),
fetch(`/api/signatures-electroniques/contrats?scope=salarie${orgParam}`, { cache: 'no-store' }),
]);
if (!rEmp.ok) throw new Error(`HTTP employeur ${rEmp.status}`);
if (!rSal.ok) throw new Error(`HTTP salarie ${rSal.status}`);
@ -76,7 +140,7 @@ export default function SignaturesElectroniques() {
useEffect(() => {
load();
}, []);
}, [selectedOrgId]); // Recharger quand l'organisation change
// Ajouter des écouteurs pour recharger les données quand un modal se ferme
useEffect(() => {
@ -287,9 +351,36 @@ export default function SignaturesElectroniques() {
{/* DocuSeal web component script */}
<Script src="https://cdn.docuseal.com/js/form.js" strategy="lazyOnload" />
<h1 className="text-2xl font-semibold">Signatures électroniques</h1>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<h1 className="text-2xl font-semibold">Signatures électroniques</h1>
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-3">
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
{userInfo?.isStaff && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">Organisation :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
disabled={!organizations || organizations.length === 0}
>
<option value="">
{!organizations || organizations.length === 0
? "Chargement..."
: "Toutes les organisations"}
</option>
{organizations && organizations.map((org: any) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
{organizations && organizations.length > 0 && (
<span className="text-xs text-slate-500">({organizations.length})</span>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="rounded-lg border p-3">
<div className="text-xs text-slate-500">Contrats dans la liste</div>
<div className="text-xl font-bold">{stats.total}</div>

View file

@ -0,0 +1,719 @@
// app/(app)/staff/gestion-productions/page.tsx
"use client";
import React, { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
Search,
Filter,
X,
Plus,
Edit2,
Trash2,
Calendar,
Building2,
Hash,
FileText
} from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// Types
type Production = {
id: string;
name: string;
reference: string | null;
declaration_date: string | null;
org_id: string;
created_at?: string;
updated_at?: string;
};
type Organization = {
id: string;
name: string;
structure_api: string;
};
// Hook pour vérifier si l'utilisateur est staff
function useStaffCheck() {
return useQuery({
queryKey: ["staff-check"],
queryFn: async () => {
const res = await fetch("/api/me");
if (!res.ok) return { isStaff: false };
const data = await res.json();
return { isStaff: Boolean(data.is_staff || data.isStaff) };
},
staleTime: 30_000,
});
}
// Hook pour récupérer les organisations
function useOrganizations() {
return useQuery({
queryKey: ["organizations"],
queryFn: async () => {
const res = await fetch("/api/staff/organizations");
if (!res.ok) throw new Error("Erreur chargement organisations");
const data = await res.json();
return data.organizations as Organization[];
},
staleTime: 60_000,
});
}
// Hook pour récupérer les productions
function useProductions(orgId?: string, searchQuery?: string) {
return useQuery({
queryKey: ["staff-productions", orgId, searchQuery],
queryFn: async () => {
const params = new URLSearchParams();
if (orgId) params.set("org_id", orgId);
if (searchQuery) params.set("q", searchQuery);
const res = await fetch(`/api/staff/productions?${params.toString()}`);
if (!res.ok) throw new Error("Erreur chargement productions");
const data = await res.json();
return data.productions as Production[];
},
enabled: true,
staleTime: 15_000,
});
}
// Composant Modal pour Créer/Éditer
function ProductionModal({
isOpen,
onClose,
production,
organizations,
}: {
isOpen: boolean;
onClose: () => void;
production?: Production | null;
organizations: Organization[];
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(production?.name || "");
const [reference, setReference] = useState(production?.reference || "");
const [declarationDate, setDeclarationDate] = useState(production?.declaration_date || "");
const [orgId, setOrgId] = useState(production?.org_id || "");
const [error, setError] = useState("");
React.useEffect(() => {
if (isOpen) {
setName(production?.name || "");
setReference(production?.reference || "");
setDeclarationDate(production?.declaration_date || "");
setOrgId(production?.org_id || "");
setError("");
}
}, [isOpen, production]);
const mutation = useMutation({
mutationFn: async (data: any) => {
const url = production
? `/api/staff/productions/${production.id}`
: "/api/staff/productions";
const method = production ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || "Erreur lors de l'opération");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-productions"] });
onClose();
},
onError: (err: any) => {
setError(err.message || "Une erreur est survenue");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!name.trim()) {
setError("Le nom est requis");
return;
}
if (!orgId) {
setError("L'organisation est requise");
return;
}
mutation.mutate({
name: name.trim(),
reference: reference.trim() || null,
declaration_date: declarationDate || null,
org_id: orgId,
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-4 border-b bg-gradient-to-r from-indigo-50 to-purple-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-lg shadow-sm">
<Building2 className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">
{production ? "Modifier la production" : "Nouvelle production"}
</h2>
<p className="text-sm text-slate-600">
{production ? "Modifiez les informations ci-dessous" : "Remplissez les informations ci-dessous"}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3">
<div className="flex-shrink-0 w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
<span className="text-red-600 text-xs font-bold">!</span>
</div>
<div className="flex-1">
<p className="text-sm text-red-800 font-medium">Erreur</p>
<p className="text-sm text-red-600">{error}</p>
</div>
</div>
)}
{/* Nom */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<FileText className="w-4 h-4 text-slate-500" />
Nom de la production *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: Festival d'Avignon 2025"
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
required
/>
</div>
{/* Référence */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Hash className="w-4 h-4 text-slate-500" />
Numéro d'objet / Référence
</label>
<input
type="text"
value={reference}
onChange={(e) => setReference(e.target.value)}
placeholder="Ex: PROD-2025-001"
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
</div>
{/* Date de déclaration */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Calendar className="w-4 h-4 text-slate-500" />
Date de déclaration
</label>
<input
type="date"
value={declarationDate}
onChange={(e) => setDeclarationDate(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
</div>
{/* Organisation */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Building2 className="w-4 h-4 text-slate-500" />
Organisation *
</label>
<select
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
required
>
<option value="">Sélectionnez une organisation</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
disabled={mutation.isPending}
>
Annuler
</button>
<button
type="submit"
disabled={mutation.isPending}
className="px-4 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 rounded-lg transition-all shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{production ? "Enregistrer" : "Créer"}
</button>
</div>
</form>
</div>
</div>
);
}
// Composant Modal de suppression
function DeleteConfirmModal({
isOpen,
onClose,
production,
}: {
isOpen: boolean;
onClose: () => void;
production: Production | null;
}) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
if (!production) return;
const res = await fetch(`/api/staff/productions/${production.id}`, {
method: "DELETE",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erreur lors de la suppression");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-productions"] });
onClose();
},
});
if (!isOpen || !production) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="p-6">
<div className="flex items-center gap-4 mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Trash2 className="w-6 h-6 text-red-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-900">
Confirmer la suppression
</h3>
<p className="text-sm text-slate-600 mt-1">
Cette action est irréversible
</p>
</div>
</div>
<div className="p-4 bg-slate-50 rounded-xl mb-6">
<p className="text-sm text-slate-700">
Voulez-vous vraiment supprimer la production{" "}
<span className="font-semibold">{production.name}</span> ?
</p>
{production.reference && (
<p className="text-xs text-slate-500 mt-1">
Référence : {production.reference}
</p>
)}
</div>
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
disabled={deleteMutation.isPending}
>
Annuler
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="px-4 py-2.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{deleteMutation.isPending && (
<Loader2 className="w-4 h-4 animate-spin" />
)}
Supprimer
</button>
</div>
</div>
</div>
</div>
);
}
// Page principale
export default function GestionProductionsPage() {
usePageTitle("Gestion des productions");
const [searchQuery, setSearchQuery] = useState("");
const [selectedOrgId, setSelectedOrgId] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [editingProduction, setEditingProduction] = useState<Production | null>(null);
const [deletingProduction, setDeletingProduction] = useState<Production | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data: staffCheck, isLoading: isLoadingStaff } = useStaffCheck();
const { data: organizations = [], isLoading: isLoadingOrgs } = useOrganizations();
const { data: productions = [], isLoading: isLoadingProductions, error } = useProductions(
selectedOrgId,
searchQuery
);
// Filtrage local supplémentaire si nécessaire
const filteredProductions = useMemo(() => {
let result = productions;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
(p.reference && p.reference.toLowerCase().includes(query))
);
}
return result;
}, [productions, searchQuery]);
// Vérification staff
if (isLoadingStaff) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
</div>
);
}
if (!staffCheck?.isStaff) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">Cette page est réservée au Staff.</p>
</main>
);
}
const hasActiveFilters = selectedOrgId || searchQuery;
return (
<div className="space-y-5 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Gestion des productions</h1>
<p className="text-sm text-slate-600 mt-1">
Gérez les productions et spectacles de toutes les organisations
</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-xl hover:from-indigo-700 hover:to-purple-700 transition-all shadow-sm hover:shadow-md"
>
<Plus className="w-5 h-5" />
Nouvelle production
</button>
</div>
{/* Filtres et recherche */}
<section className="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div className="p-4 border-b bg-slate-50/50">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher par nom ou référence..."
className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-slate-100 rounded-md transition-colors"
>
<X className="w-4 h-4 text-slate-400" />
</button>
)}
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border transition-all ${
hasActiveFilters
? "bg-indigo-50 border-indigo-200 text-indigo-700"
: "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
}`}
>
<Filter className="w-4 h-4" />
Filtres
{hasActiveFilters && (
<span className="px-2 py-0.5 bg-indigo-600 text-white text-xs rounded-full">
{selectedOrgId ? 1 : 0}
</span>
)}
</button>
</div>
{hasActiveFilters && (
<button
onClick={() => {
setSelectedOrgId("");
setSearchQuery("");
}}
className="inline-flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<X className="w-4 h-4" />
Réinitialiser
</button>
)}
</div>
{showFilters && (
<div className="mt-4 pt-4 border-t">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
Organisation
</label>
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="">Toutes les organisations</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
</div>
</div>
)}
</div>
{/* Stats */}
<div className="px-4 py-3 bg-gradient-to-r from-slate-50 to-indigo-50/30 border-b">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">
{isLoadingProductions ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Chargement...
</span>
) : (
<>
<span className="font-semibold text-slate-900">
{filteredProductions.length}
</span>{" "}
production{filteredProductions.length > 1 ? "s" : ""}
{hasActiveFilters && " (filtrée" + (filteredProductions.length > 1 ? "s" : "") + ")"}
</>
)}
</span>
{selectedOrgId && (
<span className="text-slate-600">
Organisation :{" "}
<span className="font-medium text-slate-900">
{organizations.find((o) => o.id === selectedOrgId)?.name}
</span>
</span>
)}
</div>
</div>
{/* Tableau */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Nom
</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Référence
</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Date déclaration
</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Organisation
</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{isLoadingProductions ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-indigo-600" />
<p className="text-sm text-slate-600 mt-2">
Chargement des productions...
</p>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<p className="text-sm text-red-600">
Erreur lors du chargement des productions
</p>
</td>
</tr>
) : filteredProductions.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<Building2 className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<p className="text-sm font-medium text-slate-600">
Aucune production trouvée
</p>
<p className="text-xs text-slate-500 mt-1">
{hasActiveFilters
? "Essayez de modifier vos filtres"
: "Créez votre première production"}
</p>
</td>
</tr>
) : (
filteredProductions.map((production) => {
const org = organizations.find((o) => o.id === production.org_id);
return (
<tr
key={production.id}
className="hover:bg-slate-50/50 transition-colors"
>
<td className="px-6 py-4">
<div className="font-medium text-slate-900">
{production.name}
</div>
</td>
<td className="px-6 py-4">
{production.reference ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium">
<Hash className="w-3 h-3" />
{production.reference}
</span>
) : (
<span className="text-slate-400 text-sm"></span>
)}
</td>
<td className="px-6 py-4">
{production.declaration_date ? (
<div className="flex items-center gap-2 text-sm text-slate-600">
<Calendar className="w-4 h-4 text-slate-400" />
{new Date(production.declaration_date).toLocaleDateString("fr-FR")}
</div>
) : (
<span className="text-slate-400 text-sm"></span>
)}
</td>
<td className="px-6 py-4">
<span className="text-sm text-slate-600">
{org?.name || "—"}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setEditingProduction(production)}
className="p-2 hover:bg-indigo-50 rounded-lg transition-colors group"
title="Modifier"
>
<Edit2 className="w-4 h-4 text-slate-400 group-hover:text-indigo-600" />
</button>
<button
onClick={() => setDeletingProduction(production)}
className="p-2 hover:bg-red-50 rounded-lg transition-colors group"
title="Supprimer"
>
<Trash2 className="w-4 h-4 text-slate-400 group-hover:text-red-600" />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
{/* Modals */}
<ProductionModal
isOpen={isCreateModalOpen || !!editingProduction}
onClose={() => {
setIsCreateModalOpen(false);
setEditingProduction(null);
}}
production={editingProduction}
organizations={organizations}
/>
<DeleteConfirmModal
isOpen={!!deletingProduction}
onClose={() => setDeletingProduction(null)}
production={deletingProduction}
/>
</div>
);
}

View file

@ -66,11 +66,12 @@ export async function GET(req: Request) {
const paginatedContracts = filteredContracts.slice(startIndex, endIndex);
return NextResponse.json({
contrats: paginatedContracts,
items: paginatedContracts,
total: filteredContracts.length,
page,
limit,
totalPages: Math.ceil(filteredContracts.length / limit)
totalPages: Math.ceil(filteredContracts.length / limit),
hasMore: endIndex < filteredContracts.length
});
}
@ -289,6 +290,8 @@ export async function GET(req: Request) {
page,
limit,
hasMore: filtered.length > offset + limit,
total: filtered.length,
totalPages: Math.ceil(filtered.length / limit),
debug: {
orgId,
isStaff,

View file

@ -171,10 +171,11 @@ export async function GET(req: Request) {
const total = typeof count === 'number' ? count : items.length;
const hasMore = from + items.length < total;
const totalPages = Math.ceil(total / limit);
return NextResponse.json({
sepa,
factures: { items, page, limit, hasMore },
factures: { items, page, limit, hasMore, total, totalPages },
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View file

@ -25,6 +25,7 @@ type SalariesResponse = {
page: number;
limit: number;
total?: number | null;
totalPages?: number;
hasMore: boolean;
};
@ -131,12 +132,14 @@ export async function GET(req: NextRequest) {
const total = count ?? null;
const hasMore = typeof total === "number" ? offset + items.length < total : items.length === limit;
const totalPages = typeof total === "number" ? Math.ceil(total / limit) : 0;
const payload: SalariesResponse = {
items,
page,
limit,
total,
totalPages,
hasMore,
};

View file

@ -7,6 +7,7 @@ import { cookies } from 'next/headers';
export async function GET(req: NextRequest) {
const reqUrl = new URL(req.url);
const scope = (reqUrl.searchParams.get('scope') || 'employeur').toLowerCase();
const orgIdParam = reqUrl.searchParams.get('org_id'); // Paramètre org_id pour les staff
// Récupération de l'organisation active
const sb = createRouteHandlerClient({ cookies });
@ -15,7 +16,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
// Vérifier staff pour lire la cible via cookie active_org_id
// Vérifier staff pour lire la cible via cookie active_org_id ou paramètre org_id
let isStaff = false;
try {
const { data } = await sb.from('staff_users').select('is_staff').eq('user_id', user.id).maybeSingle();
@ -25,8 +26,14 @@ export async function GET(req: NextRequest) {
let orgId: string | null = null;
try {
if (isStaff) {
const c = cookies();
orgId = c.get('active_org_id')?.value || null;
// Si un org_id est fourni en paramètre, l'utiliser (priorité pour les staff)
if (orgIdParam) {
orgId = orgIdParam;
} else {
// Sinon utiliser le cookie active_org_id
const c = cookies();
orgId = c.get('active_org_id')?.value || null;
}
} else {
const { data, error } = await sb
.from('organization_members')
@ -58,6 +65,8 @@ export async function GET(req: NextRequest) {
employee_name,
employee_matricule,
production_name,
role,
start_date,
etat_de_la_demande,
contrat_signe_par_employeur,
contrat_signe,
@ -114,14 +123,27 @@ export async function GET(req: NextRequest) {
id: contract.id,
fields: {
'Reference': contract.reference || contract.contract_number,
'reference': contract.reference || contract.contract_number,
'Nom salarié': contract.employee_name,
'employee_name': contract.employee_name,
'Prénom Salarié': contract.employee_name, // Full name (pas de séparation prénom/nom)
'Nom Salarié': contract.employee_name,
'Matricule API': contract.employee_matricule,
'Production': contract.production_name,
'role': contract.role,
'Profession': contract.role,
'start_date': contract.start_date,
'Date de début': contract.start_date,
'État de la demande': contract.etat_de_la_demande || 'Traitée',
'Contrat signé par employeur': ['oui', 'Oui', 'OUI', 'true', true].includes(contract.contrat_signe_par_employeur) ? 'Oui' : 'Non',
'Contrat signé': ['oui', 'Oui', 'OUI', 'true', true].includes(contract.contrat_signe) ? 'Oui' : 'Non',
'signature_employeur': ['oui', 'Oui', 'OUI', 'true', true].includes(contract.contrat_signe_par_employeur),
'employer_signed': ['oui', 'Oui', 'OUI', 'true', true].includes(contract.contrat_signe_par_employeur),
'signature_salarie': ['oui', 'Oui', 'OUI', 'true', true].includes(contract.contrat_signe),
'employee_signed': ['oui', 'Oui', 'OUI', 'true', true].includes(contract.contrat_signe),
'docuseal_template_id': contract.docuseal_template_id,
'docuseal_submission_id': contract.docuseal_submission_id,
'signature_link': contract.signature_link,
'embed_src_employeur': contract.signature_link
}
}));

View file

@ -0,0 +1,252 @@
// app/api/staff/productions/[id]/route.ts
import { NextResponse, NextRequest } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const runtime = "nodejs";
// Fonction helper pour vérifier si l'utilisateur est staff
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
// GET - Récupérer une production spécifique
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
const { data: production, error } = await supabase
.from("productions")
.select("*")
.eq("id", params.id)
.single();
if (error || !production) {
return NextResponse.json(
{ error: "Production introuvable" },
{ status: 404 }
);
}
return NextResponse.json({ production });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}
// PATCH - Mettre à jour une production
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
// Vérifier que la production existe
const { data: existing, error: existingError } = await supabase
.from("productions")
.select("id")
.eq("id", params.id)
.single();
if (existingError || !existing) {
return NextResponse.json(
{ error: "Production introuvable" },
{ status: 404 }
);
}
// Récupérer les données du body
const body = await req.json();
const { name, reference, declaration_date, org_id } = body;
// Validation
if (name !== undefined && (!name || !name.trim())) {
return NextResponse.json(
{ error: "Le nom ne peut pas être vide" },
{ status: 400 }
);
}
if (org_id !== undefined && !org_id) {
return NextResponse.json(
{ error: "L'organisation est requise" },
{ status: 400 }
);
}
// Si org_id est modifié, vérifier qu'il existe
if (org_id !== undefined) {
const { data: orgExists, error: orgError } = await supabase
.from("organizations")
.select("id")
.eq("id", org_id)
.single();
if (orgError || !orgExists) {
return NextResponse.json(
{ error: "Organisation introuvable" },
{ status: 400 }
);
}
}
// Préparer les données à mettre à jour
const updateData: any = {};
if (name !== undefined) updateData.name = name.trim();
if (reference !== undefined) updateData.reference = reference?.trim() || null;
if (declaration_date !== undefined) updateData.declaration_date = declaration_date || null;
if (org_id !== undefined) updateData.org_id = org_id;
// Mettre à jour la production
const { data: production, error } = await supabase
.from("productions")
.update(updateData)
.eq("id", params.id)
.select()
.single();
if (error) {
console.error("[api/staff/productions] Update error:", error.message);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({ production });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}
// DELETE - Supprimer une production
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
// Vérifier que la production existe
const { data: existing, error: existingError } = await supabase
.from("productions")
.select("id, name")
.eq("id", params.id)
.single();
if (existingError || !existing) {
return NextResponse.json(
{ error: "Production introuvable" },
{ status: 404 }
);
}
// Vérifier si la production est utilisée dans des contrats
const { data: contractsUsing, error: contractsError } = await supabase
.from("cddu_contracts")
.select("id")
.eq("production_id", params.id)
.limit(1);
if (contractsError) {
console.error("[api/staff/productions] Check contracts error:", contractsError.message);
}
if (contractsUsing && contractsUsing.length > 0) {
return NextResponse.json(
{ error: "Cette production est utilisée dans des contrats et ne peut pas être supprimée" },
{ status: 400 }
);
}
// Supprimer la production
const { error } = await supabase
.from("productions")
.delete()
.eq("id", params.id);
if (error) {
console.error("[api/staff/productions] Delete error:", error.message);
return NextResponse.json(
{ error: "Erreur lors de la suppression", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({
message: "Production supprimée avec succès",
deleted_id: params.id
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,166 @@
// app/api/staff/productions/route.ts
import { NextResponse, NextRequest } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const runtime = "nodejs";
// Fonction helper pour vérifier si l'utilisateur est staff
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
// GET - Lister toutes les productions (avec filtres optionnels)
export async function GET(req: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
// Paramètres de filtrage
const url = new URL(req.url);
const orgId = url.searchParams.get("org_id");
const searchQuery = url.searchParams.get("q");
// Construire la requête
let query = supabase
.from("productions")
.select("*")
.order("name", { ascending: true });
// Filtrer par organisation si spécifié
if (orgId) {
query = query.eq("org_id", orgId);
}
// Filtrer par recherche si spécifié
if (searchQuery) {
query = query.or(
`name.ilike.%${searchQuery}%,reference.ilike.%${searchQuery}%`
);
}
const { data: productions, error } = await query;
if (error) {
console.error("[api/staff/productions] Supabase error:", error.message);
return NextResponse.json(
{ error: "supabase_error", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({ productions: productions || [] });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}
// POST - Créer une nouvelle production
export async function POST(req: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
// Récupérer les données du body
const body = await req.json();
const { name, reference, declaration_date, org_id } = body;
// Validation
if (!name || !name.trim()) {
return NextResponse.json(
{ error: "Le nom est requis" },
{ status: 400 }
);
}
if (!org_id) {
return NextResponse.json(
{ error: "L'organisation est requise" },
{ status: 400 }
);
}
// Vérifier que l'organisation existe
const { data: orgExists, error: orgError } = await supabase
.from("organizations")
.select("id")
.eq("id", org_id)
.single();
if (orgError || !orgExists) {
return NextResponse.json(
{ error: "Organisation introuvable" },
{ status: 400 }
);
}
// Créer la production
const { data: production, error } = await supabase
.from("productions")
.insert({
name: name.trim(),
reference: reference?.trim() || null,
declaration_date: declaration_date || null,
org_id,
})
.select()
.single();
if (error) {
console.error("[api/staff/productions] Insert error:", error.message);
return NextResponse.json(
{ error: "Erreur lors de la création", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({ production }, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}

View file

@ -149,7 +149,7 @@ export default function Header({ clientInfo, isStaff }: {
}, []);
return (
<header className="sticky top-0 z-40 h-[var(--header-h)] border-b bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div className="max-w-6xl mx-auto h-full px-4 flex items-center gap-3">
<div className="h-full px-4 flex items-center gap-3">
<div className="flex items-center gap-3">
{/* Bouton hamburger mobile */}
<button
@ -163,9 +163,9 @@ export default function Header({ clientInfo, isStaff }: {
<span className="block w-5 h-0.5 bg-slate-700 rounded mt-1" />
</button>
<img
src="https://paie.odentas.fr/wp-content/uploads/2024/02/Odentas-Favicon-1-1-e1709147839260.png"
src="/odentas-logo.png"
alt="Odentas"
className="w-8 h-8 rounded-lg object-contain"
className="w-20 h-20 rounded-lg object-contain"
/>
<div className="h-6 w-px bg-slate-200"/>
<span className="font-semibold">Espace Paie Odentas</span>

View file

@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database } from "lucide-react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard } from "lucide-react";
// import { api } from "@/lib/fetcher";
import { createPortal } from "react-dom";
import LogoutButton from "@/components/LogoutButton";
@ -445,6 +445,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<span>Virements salaires</span>
</span>
</Link>
<Link href="/staff/gestion-productions" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/gestion-productions") ? "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 productions">
<span className="inline-flex items-center gap-2">
<Clapperboard className="w-4 h-4" aria-hidden />
<span>Gestion des productions</span>
</span>
</Link>
<Link href="/staff/utilisateurs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/utilisateurs") ? "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 utilisateurs">

View file

@ -32,6 +32,8 @@ export type ContratsResponse = {
page: number;
limit: number;
hasMore: boolean;
total?: number;
totalPages?: number;
};
type ClientInfo = {
@ -150,6 +152,8 @@ export function useContrats(params: ContratsQueryParams){
page,
limit,
hasMore: false, // Pas de pagination pour les données de démo
total: filteredContracts.length,
totalPages: Math.ceil(filteredContracts.length / limit),
} as ContratsResponse,
isLoading: false,
error: null,