feat: Amélioration de la page staff/avenants avec pagination et filtres

- Ajout de filtres sophistiqués : organisation, statut, type, signature, élément, dates
- Tri par colonne : date d'effet, date d'avenant, n° avenant, n° contrat
- Pagination avec 25/50/100 éléments par page
- Ordre par défaut : date d'effet décroissant (plus récent en premier)
- Compteur de filtres actifs avec bouton de réinitialisation
- Affichage du matricule salarié, n° avenant et type d'avenant dans le tableau
- Recherche étendue : inclut matricule, production et n° avenant
- Interface cohérente avec les pages staff/contrats et staff/payslips
This commit is contained in:
odentas 2025-11-05 18:28:40 +01:00
parent 480d9fb243
commit da17ca6ef2
5 changed files with 1243 additions and 135 deletions

View file

@ -0,0 +1,231 @@
# Migration des Avenants depuis Airtable vers Supabase
## 📋 Contexte
Ce document décrit la procédure de migration des avenants historiques depuis Airtable vers la base de données Supabase. Cette migration permet de centraliser toutes les données d'avenants dans le système Espace Paie.
## 🎯 Objectif
Migrer **103 avenants** depuis Airtable vers la table `avenants` de Supabase, en préservant les informations essentielles (dates, éléments modifiés, organisation).
## 📊 Structure des Données
### Données Source (Airtable CSV)
Le fichier CSV contient les colonnes suivantes :
| Colonne | Description | Exemple |
|---------|-------------|---------|
| `Reference` | Référence du contrat concerné | `DK1M4LEQ`, `RG4168801` |
| `Structure API` | Nom de l'organisation | `Association Atelier Moz` |
| `Date avenant` | Date de signature de l'avenant | `3/7/2025` |
| `Effet avenant` | Date d'effet de l'avenant | `3/7/2025` |
| `Élément(s) à avenanter` | Éléments modifiés | `"Durée de l'engagement,Rémunération"` |
### Mapping vers Supabase
Le script effectue le mapping suivant :
| Source Airtable | Destination Supabase | Transformation |
|-----------------|---------------------|----------------|
| `Reference` | Recherche dans `cddu_contracts.contract_number` ou `cddu_contracts.reference` | Lookup du `contract_id` |
| `Date avenant` | `avenants.date_avenant` | Format: `D/M/YYYY``YYYY-MM-DD` |
| `Effet avenant` | `avenants.date_effet` | Format: `D/M/YYYY``YYYY-MM-DD` |
| `Élément(s) à avenanter` | `avenants.elements_avenantes` | Parser les éléments (voir ci-dessous) |
#### Parsing des Éléments
Les éléments textuels sont convertis vers les codes du système :
| Texte Airtable | Code Supabase |
|----------------|---------------|
| "Durée de l'engagement" | `duree` |
| "Rémunération" | `remuneration` |
| "Objet" | `objet` |
| "Lieu" ou "horaire" | `lieu_horaire` |
### Champs Générés Automatiquement
- **`numero_avenant`** : Généré automatiquement par organisation (`AVE-001`, `AVE-002`, etc.)
- **`type_avenant`** : Fixé à `"modification"` (tous les avenants Airtable sont des modifications)
- **`statut`** : Fixé à `"signed"` (les avenants historiques sont considérés signés)
- **`signature_status`** : Fixé à `"signed"`
- **`motif_avenant`** : Texte auto-généré: `"Migration depuis Airtable - Éléments: ..."`
### Champs Non Migrés (NULL)
Les champs suivants restent à `NULL` pour l'instant :
- `objet_data`, `duree_data`, `lieu_horaire_data`, `remuneration_data` (détails des modifications)
- `pdf_url`, `pdf_s3_key` (PDF non disponibles)
- `signed_pdf_s3_key`
- `docuseal_template_id`, `docuseal_submission_id`
- `employer_docuseal_slug`, `employee_docuseal_slug`
## 🚀 Procédure de Migration
### Prérequis
1. **Fichier CSV** : Placer le fichier `Contrats de travail-Tous les CDDU.csv` à la racine du projet
2. **Variables d'environnement** :
- `NEXT_PUBLIC_SUPABASE_URL`
- `SUPABASE_SERVICE_ROLE_KEY`
### Étapes
1. **Vérifier les contrats existants**
Avant de lancer la migration, assurez-vous que tous les contrats référencés dans le CSV existent dans Supabase. Le script vous indiquera les contrats manquants.
2. **Placer le fichier CSV**
```bash
cp "/Users/renaud/Downloads/Contrats de travail-Tous les CDDU.csv" ./
```
3. **Exécuter le script de migration**
```bash
npx ts-node scripts/migrate-avenants-from-airtable.ts
```
4. **Analyser les résultats**
Le script affiche un résumé détaillé :
- Nombre d'avenants créés
- Lignes ignorées (sans date d'avenant)
- Contrats non trouvés
- Erreurs éventuelles
### Exemple de Sortie
```
🚀 Début de la migration des avenants depuis Airtable
📄 Lecture du fichier: /Users/renaud/Projet Nouvel Espace Paie/Contrats de travail-Tous les CDDU.csv
📊 Nombre total de lignes: 103
[1/103] Traitement de DK1M4LEQ
✅ Contrat trouvé: DK1M4LEQ (Association Atelier Moz)
✅ Avenant créé: AVE-001 (Date: 2025-07-03)
[2/103] Traitement de 14SJX34R
✅ Contrat trouvé: 14SJX34R (La Petite Légèreté)
✅ Avenant créé: AVE-001 (Date: 2025-06-14)
...
============================================================
📊 RÉSUMÉ DE LA MIGRATION
============================================================
Total de lignes: 103
Avenants créés: 95 ✅
Lignes ignorées: 5 ⏭️
Contrats non trouvés: 3 ❌
Erreurs: 0 ❌
============================================================
⚠️ Contrats non trouvés dans Supabase:
- RG4168801
- RG8698859
- RG88658839
💡 Ces contrats doivent être migrés avant de créer leurs avenants
✅ Migration terminée!
```
## 📝 Notes Importantes
### Contrats RG (Régime Général)
Les contrats commençant par `RG` sont des contrats en Régime Général. Assurez-vous qu'ils sont bien présents dans `cddu_contracts` avant la migration.
### Lignes sans Date
Certaines lignes du CSV n'ont pas de date d'avenant. Ces lignes sont automatiquement ignorées par le script (comptabilisées dans "Lignes ignorées").
### Numérotation des Avenants
La numérotation des avenants (`AVE-001`, `AVE-002`, etc.) est **par organisation**. Chaque organisation a sa propre séquence de numéros d'avenants.
### Statut des Avenants
Tous les avenants migrés sont créés avec :
- **Statut** : `signed` (signés)
- **Signature Status** : `signed`
Cela reflète le fait que ces avenants historiques ont déjà été traités et signés dans Airtable.
## 🔧 Dépannage
### Erreur : "Contrat non trouvé"
Si le script ne trouve pas un contrat, vérifiez :
1. La référence du contrat dans le CSV
2. La colonne `contract_number` ou `reference` dans `cddu_contracts`
3. Que le contrat n'a pas été supprimé
### Erreur : "Dates invalides"
Vérifiez le format des dates dans le CSV. Le format attendu est `D/M/YYYY` ou `DD/MM/YYYY`.
### Erreur : "Variables d'environnement manquantes"
Assurez-vous que votre fichier `.env.local` contient :
```env
NEXT_PUBLIC_SUPABASE_URL=https://votre-projet.supabase.co
SUPABASE_SERVICE_ROLE_KEY=votre-clé-service-role
```
## ✅ Vérification Post-Migration
Après la migration, vérifiez les données dans Supabase :
```sql
-- Compter les avenants par organisation
SELECT
o.name,
COUNT(a.id) as nb_avenants
FROM avenants a
JOIN cddu_contracts c ON a.contract_id = c.id
JOIN organizations o ON c.org_id = o.id
GROUP BY o.name
ORDER BY nb_avenants DESC;
-- Vérifier les avenants migrés depuis Airtable
SELECT
a.numero_avenant,
c.contract_number,
a.date_avenant,
a.date_effet,
a.elements_avenantes,
a.statut
FROM avenants a
JOIN cddu_contracts c ON a.contract_id = c.id
WHERE a.motif_avenant LIKE 'Migration depuis Airtable%'
ORDER BY a.date_avenant DESC
LIMIT 20;
```
## 🎯 Prochaines Étapes
Après la migration des données :
1. **Récupération des PDF** : Les PDF des avenants signés doivent être récupérés depuis Airtable et uploadés sur S3
2. **Mise à jour des données détaillées** : Si possible, compléter les champs `objet_data`, `duree_data`, etc. avec les vraies valeurs
3. **Vérification manuelle** : Passer en revue quelques avenants pour s'assurer de la cohérence des données
## 📚 Références
- **Table Supabase** : `avenants`
- **Script de migration** : `scripts/migrate-avenants-from-airtable.ts`
- **Fichier source** : `Contrats de travail-Tous les CDDU.csv`
- **Type TypeScript** : `types/amendments.ts`
---
**Date de création** : 5 novembre 2025
**Auteur** : Migration automatisée Airtable → Supabase

View file

@ -39,6 +39,7 @@ export default async function StaffAvenantsPage() {
}
// Récupérer les avenants depuis la base de données
// Ordre par défaut: date d'effet décroissant (plus récent d'abord)
const { data: avenants, error } = await sb
.from("avenants")
.select(`
@ -58,10 +59,13 @@ export default async function StaffAvenantsPage() {
cddu_contracts!inner(
contract_number,
employee_name,
employee_matricule,
structure,
org_id
org_id,
production_name
)
`)
.order("date_effet", { ascending: false })
.order("created_at", { ascending: false });
if (error) {
@ -71,12 +75,17 @@ export default async function StaffAvenantsPage() {
// Transformer les données pour le format attendu
const formattedAvenants = (avenants || []).map((a: any) => ({
id: a.id,
numero_avenant: a.numero_avenant,
contract_id: a.contract_id,
contract_number: a.cddu_contracts?.contract_number,
employee_name: a.cddu_contracts?.employee_name,
employee_matricule: a.cddu_contracts?.employee_matricule,
organization_name: a.cddu_contracts?.structure,
production_name: a.cddu_contracts?.production_name,
date_effet: a.date_effet,
date_signature: a.date_avenant,
date_avenant: a.date_avenant,
type_avenant: a.type_avenant,
motif_avenant: a.motif_avenant,
status: a.statut,
elements: a.elements_avenantes || [],
created_at: a.created_at,

View file

@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { FileText, Plus, Search, Check, X, RefreshCw, Mail } from "lucide-react";
import { FileText, Plus, Search, Check, X, RefreshCw, Mail, Filter, ChevronLeft, ChevronRight } from "lucide-react";
import { Amendment } from "@/types/amendments";
import { SmartReminderAvenantModal, SmartReminderAvenant, ReminderAction } from "./avenants/SmartReminderAvenantModal";
@ -12,11 +12,27 @@ interface StaffAvenantsPageClientProps {
export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPageClientProps) {
const router = useRouter();
const [amendments, setAmendments] = useState<Amendment[]>(initialData);
const [amendments] = useState<Amendment[]>(initialData);
const [searchTerm, setSearchTerm] = useState("");
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedAvenantIds, setSelectedAvenantIds] = useState<Set<string>>(new Set());
// Filtres
const [showFilters, setShowFilters] = useState(false);
const [organizationFilter, setOrganizationFilter] = useState<string | null>(null);
const [statutFilter, setStatutFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [signatureFilter, setSignatureFilter] = useState<string | null>(null);
const [elementFilter, setElementFilter] = useState<string | null>(null);
const [dateEffetFrom, setDateEffetFrom] = useState<string>("");
const [dateEffetTo, setDateEffetTo] = useState<string>("");
// Tri et pagination
const [sortField, setSortField] = useState<"date_effet" | "date_avenant" | "numero_avenant" | "contract_number">("date_effet");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(50);
// Smart reminder modal state
const [showSmartReminderModal, setShowSmartReminderModal] = useState(false);
const [smartReminderAvenants, setSmartReminderAvenants] = useState<SmartReminderAvenant[]>([]);
@ -29,15 +45,101 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
setTimeout(() => setIsRefreshing(false), 1000);
};
const filteredAmendments = amendments.filter((amendment) => {
// Extraire les organisations uniques
const organizations = useMemo(() => {
const orgs = new Set<string>();
amendments.forEach(a => {
if (a.organization_name) orgs.add(a.organization_name);
});
return Array.from(orgs).sort();
}, [amendments]);
// Filtrage et tri
const filteredAndSortedAmendments = useMemo(() => {
let filtered = amendments.filter((amendment) => {
const term = searchTerm.toLowerCase();
return (
const matchesSearch =
amendment.contract_number?.toLowerCase().includes(term) ||
amendment.employee_name?.toLowerCase().includes(term) ||
amendment.organization_name?.toLowerCase().includes(term)
);
amendment.employee_matricule?.toLowerCase().includes(term) ||
amendment.organization_name?.toLowerCase().includes(term) ||
amendment.production_name?.toLowerCase().includes(term) ||
amendment.numero_avenant?.toLowerCase().includes(term);
if (!matchesSearch) return false;
// Filtre organisation
if (organizationFilter && amendment.organization_name !== organizationFilter) return false;
// Filtre statut
if (statutFilter && amendment.status !== statutFilter) return false;
// Filtre type
if (typeFilter && amendment.type_avenant !== typeFilter) return false;
// Filtre signature
if (signatureFilter) {
if (signatureFilter === "signed" && amendment.signature_status !== "signed") return false;
if (signatureFilter === "pending" && !["pending_employer", "pending_employee"].includes(amendment.signature_status || "")) return false;
if (signatureFilter === "not_sent" && amendment.signature_status !== "not_sent") return false;
}
// Filtre élément
if (elementFilter) {
if (!amendment.elements || !amendment.elements.includes(elementFilter as any)) return false;
}
// Filtre date d'effet
if (dateEffetFrom && amendment.date_effet && amendment.date_effet < dateEffetFrom) return false;
if (dateEffetTo && amendment.date_effet && amendment.date_effet > dateEffetTo) return false;
return true;
});
// Tri
filtered.sort((a, b) => {
let aVal: any;
let bVal: any;
switch (sortField) {
case "date_effet":
aVal = a.date_effet || "";
bVal = b.date_effet || "";
break;
case "date_avenant":
aVal = a.date_avenant || "";
bVal = b.date_avenant || "";
break;
case "numero_avenant":
aVal = a.numero_avenant || "";
bVal = b.numero_avenant || "";
break;
case "contract_number":
aVal = a.contract_number || "";
bVal = b.contract_number || "";
break;
default:
return 0;
}
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [amendments, searchTerm, organizationFilter, statutFilter, typeFilter, signatureFilter, elementFilter, dateEffetFrom, dateEffetTo, sortField, sortOrder]);
// Pagination
const totalPages = Math.ceil(filteredAndSortedAmendments.length / itemsPerPage);
const paginatedAmendments = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return filteredAndSortedAmendments.slice(start, start + itemsPerPage);
}, [filteredAndSortedAmendments, currentPage, itemsPerPage]);
// Reset à la page 1 quand les filtres changent
const resetPage = () => setCurrentPage(1);
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const [y, m, d] = dateString.split("-");
@ -64,6 +166,13 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
);
};
const getTypeBadge = (type?: string) => {
if (type === "annulation") {
return <span className="px-2 py-1 text-xs font-medium rounded bg-red-100 text-red-700">Annulation</span>;
}
return <span className="px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700">Modification</span>;
};
const getSignatureIcons = (signatureStatus?: string) => {
// Déterminer si employeur a signé
const employerSigned = signatureStatus === 'pending_employee' || signatureStatus === 'signed';
@ -108,12 +217,26 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
return elements.map((el) => labels[el]).join(", ");
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder("desc");
}
};
const getSortIcon = (field: typeof sortField) => {
if (sortField !== field) return null;
return sortOrder === "asc" ? "↑" : "↓";
};
// Fonction pour toggle la sélection de tous les avenants
const toggleSelectAll = () => {
if (selectedAvenantIds.size === filteredAmendments.length) {
if (selectedAvenantIds.size === paginatedAmendments.length) {
setSelectedAvenantIds(new Set());
} else {
setSelectedAvenantIds(new Set(filteredAmendments.map(a => a.id).filter(Boolean) as string[]));
setSelectedAvenantIds(new Set(paginatedAmendments.map(a => a.id).filter(Boolean) as string[]));
}
};
@ -130,6 +253,30 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
});
};
// Clear all filters
const clearAllFilters = () => {
setSearchTerm("");
setOrganizationFilter(null);
setStatutFilter(null);
setTypeFilter(null);
setSignatureFilter(null);
setElementFilter(null);
setDateEffetFrom("");
setDateEffetTo("");
resetPage();
};
const activeFiltersCount = [
searchTerm,
organizationFilter,
statutFilter,
typeFilter,
signatureFilter,
elementFilter,
dateEffetFrom,
dateEffetTo
].filter(Boolean).length;
// Fonction pour envoyer les relances intelligentes
const handleSmartReminders = async () => {
if (selectedAvenantIds.size === 0) {
@ -307,7 +454,8 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
<div>
<h1 className="text-2xl font-bold text-slate-900">Avenants aux contrats</h1>
<p className="text-sm text-slate-600 mt-1">
Gérez les avenants aux contrats de travail
{filteredAndSortedAmendments.length} avenant{filteredAndSortedAmendments.length > 1 ? "s" : ""}
{activeFiltersCount > 0 && ` (${activeFiltersCount} filtre${activeFiltersCount > 1 ? "s" : ""})`}
</p>
</div>
<div className="flex items-center gap-3">
@ -339,33 +487,167 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
</div>
</div>
{/* Search bar */}
<div className="bg-white rounded-xl border shadow-sm p-4">
<div className="relative">
{/* Search and filters bar */}
<div className="bg-white rounded-xl border shadow-sm p-4 space-y-4">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Rechercher par n° contrat, salarié, organisation..."
placeholder="Rechercher par n° contrat, salarié, matricule, organisation, production, n° avenant..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => { setSearchTerm(e.target.value); resetPage(); }}
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${
showFilters ? "bg-indigo-50 border-indigo-300 text-indigo-700" : "hover:bg-slate-50"
}`}
>
<Filter className="h-4 w-4" />
Filtres
{activeFiltersCount > 0 && (
<span className="bg-indigo-600 text-white text-xs font-medium px-2 py-0.5 rounded-full">
{activeFiltersCount}
</span>
)}
</button>
{activeFiltersCount > 0 && (
<button
onClick={clearAllFilters}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-900"
>
Réinitialiser
</button>
)}
</div>
{/* Filters panel */}
{showFilters && (
<div className="border-t pt-4 grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Organisation */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Organisation</label>
<select
value={organizationFilter || ""}
onChange={(e) => { setOrganizationFilter(e.target.value || null); resetPage(); }}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Toutes</option>
{organizations.map(org => (
<option key={org} value={org}>{org}</option>
))}
</select>
</div>
{/* Statut */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Statut</label>
<select
value={statutFilter || ""}
onChange={(e) => { setStatutFilter(e.target.value || null); resetPage(); }}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Tous</option>
<option value="draft">Brouillon</option>
<option value="pending">En attente</option>
<option value="signed">Signé</option>
<option value="cancelled">Annulé</option>
</select>
</div>
{/* Type */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Type</label>
<select
value={typeFilter || ""}
onChange={(e) => { setTypeFilter(e.target.value || null); resetPage(); }}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Tous</option>
<option value="modification">Modification</option>
<option value="annulation">Annulation</option>
</select>
</div>
{/* Signature */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Signature</label>
<select
value={signatureFilter || ""}
onChange={(e) => { setSignatureFilter(e.target.value || null); resetPage(); }}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Tous</option>
<option value="signed">Signé</option>
<option value="pending">En attente</option>
<option value="not_sent">Non envoyé</option>
</select>
</div>
{/* Élément avenant */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Élément</label>
<select
value={elementFilter || ""}
onChange={(e) => { setElementFilter(e.target.value || null); resetPage(); }}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Tous</option>
<option value="objet">Objet</option>
<option value="duree">Durée</option>
<option value="lieu_horaire">Lieu/Horaire</option>
<option value="remuneration">Rémunération</option>
</select>
</div>
{/* Date effet from */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Date effet min</label>
<input
type="date"
value={dateEffetFrom}
onChange={(e) => { setDateEffetFrom(e.target.value); resetPage(); }}
className="w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
{/* Date effet to */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Date effet max</label>
<input
type="date"
value={dateEffetTo}
onChange={(e) => { setDateEffetTo(e.target.value); resetPage(); }}
className="w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
</div>
)}
</div>
{/* Table */}
{filteredAmendments.length === 0 ? (
{filteredAndSortedAmendments.length === 0 ? (
<div className="bg-white rounded-xl border shadow-sm p-12 text-center">
<FileText className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-900 mb-2">
{searchTerm ? "Aucun résultat" : "Aucun avenant"}
{activeFiltersCount > 0 ? "Aucun résultat" : "Aucun avenant"}
</h3>
<p className="text-sm text-slate-600 mb-6">
{searchTerm
? "Aucun avenant ne correspond à votre recherche."
{activeFiltersCount > 0
? "Aucun avenant ne correspond à vos critères de recherche."
: "Commencez par créer un nouvel avenant."}
</p>
{!searchTerm && (
{activeFiltersCount > 0 ? (
<button
onClick={clearAllFilters}
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-600 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
Réinitialiser les filtres
</button>
) : (
<button
onClick={() => router.push("/staff/avenants/nouveau")}
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
@ -376,6 +658,7 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
)}
</div>
) : (
<>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
@ -384,13 +667,22 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
<th className="px-4 py-3">
<input
type="checkbox"
checked={selectedAvenantIds.size === filteredAmendments.length && filteredAmendments.length > 0}
checked={selectedAvenantIds.size === paginatedAmendments.length && paginatedAmendments.length > 0}
onChange={toggleSelectAll}
className="w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500"
/>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
N° Contrat
<th
className="px-4 py-3 text-left text-xs font-medium text-slate-600 cursor-pointer hover:bg-slate-100"
onClick={() => handleSort("numero_avenant")}
>
N° Avenant {getSortIcon("numero_avenant")}
</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-slate-600 cursor-pointer hover:bg-slate-100"
onClick={() => handleSort("contract_number")}
>
N° Contrat {getSortIcon("contract_number")}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Salarié
@ -399,13 +691,19 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
Organisation
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Éléments avenantés
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Date d'effet
Éléments
</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-slate-600 cursor-pointer hover:bg-slate-100"
onClick={() => handleSort("date_effet")}
>
Date d'effet {getSortIcon("date_effet")}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Signé
Signatures
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Statut
@ -416,7 +714,7 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
</tr>
</thead>
<tbody className="divide-y">
{filteredAmendments.map((amendment) => (
{paginatedAmendments.map((amendment) => (
<tr
key={amendment.id}
className="hover:bg-slate-50 transition-colors"
@ -432,6 +730,12 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
<td
className="px-4 py-3 text-sm font-medium text-slate-900 cursor-pointer"
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
>
{amendment.numero_avenant || "-"}
</td>
<td
className="px-4 py-3 text-sm text-slate-700 cursor-pointer"
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
>
{amendment.contract_number || "-"}
</td>
@ -439,7 +743,10 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
className="px-4 py-3 text-sm text-slate-700 cursor-pointer"
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
>
{amendment.employee_name || "-"}
<div>{amendment.employee_name || "-"}</div>
{amendment.employee_matricule && (
<div className="text-xs text-slate-500">({amendment.employee_matricule})</div>
)}
</td>
<td
className="px-4 py-3 text-sm text-slate-700 cursor-pointer"
@ -447,6 +754,12 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
>
{amendment.organization_name || "-"}
</td>
<td
className="px-4 py-3 cursor-pointer"
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
>
{getTypeBadge(amendment.type_avenant)}
</td>
<td
className="px-4 py-3 text-sm text-slate-700 cursor-pointer"
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
@ -488,6 +801,48 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-white rounded-xl border shadow-sm p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm text-slate-600">
Affichage de {(currentPage - 1) * itemsPerPage + 1} à {Math.min(currentPage * itemsPerPage, filteredAndSortedAmendments.length)} sur {filteredAndSortedAmendments.length} avenant{filteredAndSortedAmendments.length > 1 ? "s" : ""}
</span>
<select
value={itemsPerPage}
onChange={(e) => { setItemsPerPage(Number(e.target.value)); setCurrentPage(1); }}
className="px-3 py-1 border rounded-lg text-sm"
>
<option value={25}>25 par page</option>
<option value={50}>50 par page</option>
<option value={100}>100 par page</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="p-2 border rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm text-slate-600 px-3">
Page {currentPage} sur {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="p-2 border rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
</>
)}
{/* Smart Reminder Modal */}

View file

@ -0,0 +1,307 @@
/**
* Script de migration des avenants depuis Airtable vers Supabase
*
* Ce script lit le fichier CSV exporté depuis Airtable et crée les entrées
* dans la table `avenants` de Supabase.
*
* Utilisation:
* npx tsx scripts/migrate-avenants-from-airtable.ts
*/
import { createClient } from '@supabase/supabase-js';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { config } from 'dotenv';
// Charger les variables d'environnement
config({ path: path.join(process.cwd(), '.env.local') });
// Configuration
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const CSV_FILE_PATH = path.join(process.cwd(), 'Contrats de travail-Tous les CDDU.csv');
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
console.error('❌ Variables d\'environnement manquantes');
console.error('Assurez-vous que NEXT_PUBLIC_SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY sont définies');
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
interface AirtableAvenantRow {
reference: string; // Référence du contrat (ex: DK1M4LEQ, RG4168801)
structure_api: string; // Nom de l'organisation (ex: Association Atelier Moz)
date_avenant: string; // Date de l'avenant (format: D/M/YYYY)
effet_avenant: string; // Date d'effet (format: D/M/YYYY)
elements_avenantes: string; // Éléments modifiés (ex: "Durée de l'engagement,Rémunération")
}
interface ContractMatch {
id: string;
contract_number: string;
org_id: string;
organization_name: string;
}
/**
* Parse une date au format D/M/YYYY ou DD/MM/YYYY vers YYYY-MM-DD
*/
function parseDate(dateStr: string): string | null {
if (!dateStr || dateStr.trim() === '') return null;
const parts = dateStr.split('/');
if (parts.length !== 3) return null;
const day = parts[0].padStart(2, '0');
const month = parts[1].padStart(2, '0');
const year = parts[2];
return `${year}-${month}-${day}`;
}
/**
* Parse les éléments avenantés depuis le CSV
* Ex: "Durée de l'engagement,Rémunération" => ["duree", "remuneration"]
*/
function parseElements(elementsStr: string): string[] {
if (!elementsStr || elementsStr.trim() === '') return [];
const elements: string[] = [];
const parts = elementsStr.split(',').map(s => s.trim());
for (const part of parts) {
if (part.includes('Durée')) {
elements.push('duree');
} else if (part.includes('Rémunération')) {
elements.push('remuneration');
} else if (part.includes('Objet')) {
elements.push('objet');
} else if (part.includes('Lieu') || part.includes('horaire')) {
elements.push('lieu_horaire');
}
}
return elements;
}
/**
* Recherche un contrat par sa référence
*/
async function findContract(reference: string): Promise<ContractMatch | null> {
// Chercher dans la table cddu_contracts
const { data, error } = await supabase
.from('cddu_contracts')
.select(`
id,
contract_number,
org_id,
organizations!inner(name)
`)
.or(`contract_number.eq.${reference},reference.eq.${reference}`)
.maybeSingle();
if (error) {
console.error(` ⚠️ Erreur recherche contrat ${reference}:`, error.message);
return null;
}
if (!data) {
return null;
}
return {
id: data.id,
contract_number: data.contract_number,
org_id: data.org_id,
organization_name: (data.organizations as any)?.name || 'Unknown'
};
}
/**
* Génère un numéro d'avenant pour une organisation
*/
async function generateNumeroAvenant(orgId: string): Promise<string> {
// Compter les avenants existants pour cette organisation
const { count } = await supabase
.from('avenants')
.select('contract_id, cddu_contracts!inner(org_id)', { count: 'exact', head: true })
.eq('cddu_contracts.org_id', orgId);
const numero = (count || 0) + 1;
return `AVE-${String(numero).padStart(3, '0')}`;
}
/**
* Lit et parse le fichier CSV
*/
async function readCSV(): Promise<AirtableAvenantRow[]> {
return new Promise((resolve, reject) => {
const rows: AirtableAvenantRow[] = [];
const fileStream = fs.createReadStream(CSV_FILE_PATH);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
let isFirstLine = true;
rl.on('line', (line) => {
// Ignorer la première ligne (header)
if (isFirstLine) {
isFirstLine = false;
return;
}
// Parser le CSV (attention aux virgules dans les guillemets)
const matches = line.match(/(?:^|,)("(?:[^"]|"")*"|[^,]*)/g);
if (!matches || matches.length < 5) return;
const cols = matches.map(m => {
let val = m.replace(/^,/, ''); // Supprimer virgule de début
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1).replace(/""/g, '"');
}
return val.trim();
});
rows.push({
reference: cols[0] || '',
structure_api: cols[1] || '',
date_avenant: cols[2] || '',
effet_avenant: cols[3] || '',
elements_avenantes: cols[4] || ''
});
});
rl.on('close', () => resolve(rows));
rl.on('error', reject);
});
}
/**
* Fonction principale de migration
*/
async function migrate() {
console.log('🚀 Début de la migration des avenants depuis Airtable\n');
// Vérifier que le fichier CSV existe
if (!fs.existsSync(CSV_FILE_PATH)) {
console.error(`❌ Fichier CSV non trouvé: ${CSV_FILE_PATH}`);
console.error('📝 Placez le fichier "Contrats de travail-Tous les CDDU.csv" à la racine du projet');
process.exit(1);
}
console.log(`📄 Lecture du fichier: ${CSV_FILE_PATH}\n`);
const rows = await readCSV();
console.log(`📊 Nombre total de lignes: ${rows.length}\n`);
const stats = {
total: rows.length,
skipped: 0,
contractNotFound: 0,
success: 0,
errors: 0
};
const contractsNotFound: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const index = i + 1;
console.log(`\n[${index}/${rows.length}] Traitement de ${row.reference}`);
// Ignorer les lignes sans date d'avenant
if (!row.date_avenant || row.date_avenant.trim() === '') {
console.log(` ⏭️ Aucune date d'avenant - Ignoré`);
stats.skipped++;
continue;
}
// Rechercher le contrat
const contract = await findContract(row.reference);
if (!contract) {
console.log(` ❌ Contrat non trouvé: ${row.reference}`);
contractsNotFound.push(row.reference);
stats.contractNotFound++;
continue;
}
console.log(` ✅ Contrat trouvé: ${contract.contract_number} (${contract.organization_name})`);
// Parser les dates
const dateAvenant = parseDate(row.date_avenant);
const dateEffet = parseDate(row.effet_avenant);
if (!dateAvenant || !dateEffet) {
console.log(` ⚠️ Dates invalides - Ignoré`);
stats.skipped++;
continue;
}
// Parser les éléments
const elements = parseElements(row.elements_avenantes);
// Générer le numéro d'avenant
const numeroAvenant = await generateNumeroAvenant(contract.org_id);
// Créer l'avenant
const avenantData = {
contract_id: contract.id,
numero_avenant: numeroAvenant,
date_avenant: dateAvenant,
date_effet: dateEffet,
type_avenant: 'modification',
motif_avenant: `Migration depuis Airtable - Éléments: ${row.elements_avenantes}`,
elements_avenantes: elements,
statut: 'signed', // On considère les avenants Airtable comme signés
signature_status: 'signed',
// Pas de PDF ni de données détaillées pour l'instant
objet_data: null,
duree_data: null,
lieu_horaire_data: null,
remuneration_data: null,
pdf_url: null,
pdf_s3_key: null
};
const { data, error } = await supabase
.from('avenants')
.insert(avenantData)
.select()
.single();
if (error) {
console.log(` ❌ Erreur création: ${error.message}`);
stats.errors++;
} else {
console.log(` ✅ Avenant créé: ${numeroAvenant} (Date: ${dateAvenant})`);
stats.success++;
}
}
// Résumé
console.log('\n\n' + '='.repeat(60));
console.log('📊 RÉSUMÉ DE LA MIGRATION');
console.log('='.repeat(60));
console.log(`Total de lignes: ${stats.total}`);
console.log(`Avenants créés: ${stats.success}`);
console.log(`Lignes ignorées: ${stats.skipped} ⏭️`);
console.log(`Contrats non trouvés: ${stats.contractNotFound}`);
console.log(`Erreurs: ${stats.errors}`);
console.log('='.repeat(60));
if (contractsNotFound.length > 0) {
console.log('\n⚠ Contrats non trouvés dans Supabase:');
contractsNotFound.forEach(ref => console.log(` - ${ref}`));
console.log('\n💡 Ces contrats doivent être migrés avant de créer leurs avenants');
}
console.log('\n✅ Migration terminée!\n');
}
// Exécuter la migration
migrate().catch(console.error);

View file

@ -0,0 +1,206 @@
/**
* Script de vérification des contrats avant migration des avenants
*
* Ce script vérifie que tous les contrats référencés dans le CSV Airtable
* existent bien dans la base Supabase.
*
* Utilisation:
* npx tsx scripts/verify-contracts-before-migration.ts
*/
import { createClient } from '@supabase/supabase-js';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { config } from 'dotenv';
// Charger les variables d'environnement
config({ path: path.join(process.cwd(), '.env.local') });
// Configuration
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const CSV_FILE_PATH = path.join(process.cwd(), 'Contrats de travail-Tous les CDDU.csv');
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
console.error('❌ Variables d\'environnement manquantes');
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
interface ContractReference {
reference: string;
structure_api: string;
hasAvenant: boolean;
}
/**
* Lit le CSV et extrait les références uniques
*/
async function readContractReferences(): Promise<ContractReference[]> {
return new Promise((resolve, reject) => {
const refs = new Map<string, ContractReference>();
const fileStream = fs.createReadStream(CSV_FILE_PATH);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
let isFirstLine = true;
rl.on('line', (line) => {
if (isFirstLine) {
isFirstLine = false;
return;
}
const matches = line.match(/(?:^|,)("(?:[^"]|"")*"|[^,]*)/g);
if (!matches || matches.length < 5) return;
const cols = matches.map(m => {
let val = m.replace(/^,/, '');
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1).replace(/""/g, '"');
}
return val.trim();
});
const reference = cols[0] || '';
const structure = cols[1] || '';
const dateAvenant = cols[2] || '';
if (reference) {
refs.set(reference, {
reference,
structure_api: structure,
hasAvenant: dateAvenant !== ''
});
}
});
rl.on('close', () => resolve(Array.from(refs.values())));
rl.on('error', reject);
});
}
/**
* Vérifie l'existence d'un contrat dans Supabase
*/
async function checkContract(reference: string) {
const { data, error } = await supabase
.from('cddu_contracts')
.select('id, contract_number, reference, org_id, organizations(name)')
.or(`contract_number.eq.${reference},reference.eq.${reference}`)
.maybeSingle();
if (error) {
return { found: false, error: error.message };
}
if (!data) {
return { found: false };
}
return {
found: true,
data: {
id: data.id,
contract_number: data.contract_number,
org_name: (data.organizations as any)?.name || 'Unknown'
}
};
}
/**
* Fonction principale
*/
async function verify() {
console.log('🔍 Vérification des contrats avant migration des avenants\n');
if (!fs.existsSync(CSV_FILE_PATH)) {
console.error(`❌ Fichier CSV non trouvé: ${CSV_FILE_PATH}`);
process.exit(1);
}
console.log(`📄 Lecture du fichier: ${CSV_FILE_PATH}\n`);
const references = await readContractReferences();
console.log(`📊 Nombre de contrats uniques: ${references.length}\n`);
const stats = {
total: references.length,
found: 0,
notFound: 0,
withAvenants: 0,
withoutAvenants: 0,
errors: 0
};
const notFound: string[] = [];
const foundWithAvenants: Array<{ ref: string; org: string }> = [];
for (const ref of references) {
process.stdout.write(` Vérification de ${ref.reference}...`);
const result = await checkContract(ref.reference);
if (result.found && result.data) {
console.log(` ✅ Trouvé (${result.data.org_name})`);
stats.found++;
if (ref.hasAvenant) {
stats.withAvenants++;
foundWithAvenants.push({
ref: ref.reference,
org: result.data.org_name
});
} else {
stats.withoutAvenants++;
}
} else if (result.error) {
console.log(` ⚠️ Erreur: ${result.error}`);
stats.errors++;
} else {
console.log(` ❌ Non trouvé`);
stats.notFound++;
notFound.push(ref.reference);
}
}
// Résumé
console.log('\n' + '='.repeat(70));
console.log('📊 RÉSUMÉ DE LA VÉRIFICATION');
console.log('='.repeat(70));
console.log(`Total de contrats uniques: ${stats.total}`);
console.log(`Contrats trouvés: ${stats.found}`);
console.log(` └─ Avec avenants à migrer: ${stats.withAvenants}`);
console.log(` └─ Sans avenants: ${stats.withoutAvenants}`);
console.log(`Contrats non trouvés: ${stats.notFound}`);
console.log(`Erreurs: ${stats.errors} ⚠️`);
console.log('='.repeat(70));
if (notFound.length > 0) {
console.log('\n⚠ CONTRATS MANQUANTS DANS SUPABASE:');
console.log('─'.repeat(70));
notFound.forEach(ref => {
const refData = references.find(r => r.reference === ref);
console.log(` ${ref.padEnd(20)} (${refData?.structure_api || 'N/A'})`);
});
console.log('\n💡 Ces contrats doivent être migrés avant de lancer la migration des avenants');
}
if (stats.withAvenants > 0) {
console.log(`\n✅ ${stats.withAvenants} contrats sont prêts pour la migration des avenants`);
}
if (stats.notFound === 0 && stats.errors === 0) {
console.log('\n🎉 Tous les contrats sont présents dans Supabase !');
console.log(' Vous pouvez lancer la migration avec :');
console.log(' npx ts-node scripts/migrate-avenants-from-airtable.ts\n');
} else {
console.log('\n⚠ Résolvez les contrats manquants avant de continuer\n');
}
}
verify().catch(console.error);