Pagination + staff signature électronique menu
This commit is contained in:
parent
4ff40ed3e5
commit
9a130dbeef
19 changed files with 2522 additions and 139 deletions
91
PAGINATION_CONTRATS_IMPROVEMENT.md
Normal file
91
PAGINATION_CONTRATS_IMPROVEMENT.md
Normal 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`
|
||||
115
PAGINATION_SALARIES_IMPROVEMENT.md
Normal file
115
PAGINATION_SALARIES_IMPROVEMENT.md
Normal 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
|
||||
481
STAFF_GESTION_PRODUCTIONS.md
Normal file
481
STAFF_GESTION_PRODUCTIONS.md
Normal 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)
|
||||
205
VIREMENTS_SALAIRES_SIGNATURES_STAFF.md
Normal file
205
VIREMENTS_SALAIRES_SIGNATURES_STAFF.md
Normal 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
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
719
app/(app)/staff/gestion-productions/page.tsx
Normal file
719
app/(app)/staff/gestion-productions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
252
app/api/staff/productions/[id]/route.ts
Normal file
252
app/api/staff/productions/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
166
app/api/staff/productions/route.ts
Normal file
166
app/api/staff/productions/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue