- Nouvelle modale UploadSignedPdfModal avec drag & drop
- API route /api/staff/amendments/[id]/upload-signed-pdf
- Upload vers S3 avec pattern avenants/{ref}_avenant_signed_{timestamp}.pdf
- Mise à jour automatique statut → 'signed' et signature_status → 'signed'
- Validation du fichier (PDF uniquement, max 10MB)
- Bouton 'Ajouter PDF signé' sur page détail avenant
859 lines
34 KiB
TypeScript
859 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
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";
|
|
|
|
interface StaffAvenantsPageClientProps {
|
|
initialData: Amendment[];
|
|
}
|
|
|
|
export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPageClientProps) {
|
|
const router = useRouter();
|
|
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[]>([]);
|
|
const [smartReminderProgress, setSmartReminderProgress] = useState<SmartReminderAvenant[]>([]);
|
|
const [isLoadingReminders, setIsLoadingReminders] = useState(false);
|
|
|
|
const handleRefresh = () => {
|
|
setIsRefreshing(true);
|
|
router.refresh();
|
|
setTimeout(() => setIsRefreshing(false), 1000);
|
|
};
|
|
|
|
// 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();
|
|
const matchesSearch =
|
|
amendment.contract_number?.toLowerCase().includes(term) ||
|
|
amendment.employee_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("-");
|
|
return `${d}/${m}/${y}`;
|
|
};
|
|
|
|
const getStatusBadge = (status: Amendment["status"]) => {
|
|
const badges = {
|
|
draft: "bg-slate-100 text-slate-700",
|
|
pending: "bg-orange-100 text-orange-700",
|
|
signed: "bg-green-100 text-green-700",
|
|
cancelled: "bg-red-100 text-red-700",
|
|
};
|
|
const labels = {
|
|
draft: "Brouillon",
|
|
pending: "En attente",
|
|
signed: "Signé",
|
|
cancelled: "Annulé",
|
|
};
|
|
return (
|
|
<span className={`px-2 py-1 text-xs font-medium rounded ${badges[status]}`}>
|
|
{labels[status]}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
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';
|
|
// Déterminer si salarié a signé
|
|
const employeeSigned = signatureStatus === 'signed';
|
|
// Si pas encore envoyé
|
|
const notSent = !signatureStatus || signatureStatus === 'not_sent';
|
|
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex flex-col items-center gap-1">
|
|
<div className="text-xs font-semibold text-slate-600">E</div>
|
|
{notSent ? (
|
|
<span className="text-xs text-slate-400">—</span>
|
|
) : employerSigned ? (
|
|
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
|
) : (
|
|
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col items-center gap-1">
|
|
<div className="text-xs font-semibold text-slate-600">S</div>
|
|
{notSent ? (
|
|
<span className="text-xs text-slate-400">—</span>
|
|
) : employeeSigned ? (
|
|
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
|
) : (
|
|
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getElementsLabel = (elements: Amendment["elements"]) => {
|
|
const labels = {
|
|
objet: "Objet",
|
|
duree: "Durée",
|
|
lieu_horaire: "Lieu/Horaire",
|
|
remuneration: "Rémunération",
|
|
};
|
|
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 === paginatedAmendments.length) {
|
|
setSelectedAvenantIds(new Set());
|
|
} else {
|
|
setSelectedAvenantIds(new Set(paginatedAmendments.map(a => a.id).filter(Boolean) as string[]));
|
|
}
|
|
};
|
|
|
|
// Fonction pour toggle la sélection d'un avenant
|
|
const toggleSelectAvenant = (avenantId: string) => {
|
|
setSelectedAvenantIds(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(avenantId)) {
|
|
newSet.delete(avenantId);
|
|
} else {
|
|
newSet.add(avenantId);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// 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) {
|
|
alert("Aucun avenant sélectionné");
|
|
return;
|
|
}
|
|
|
|
const selectedAvenants = amendments.filter(a => a.id && selectedAvenantIds.has(a.id));
|
|
|
|
// Analyser les avenants pour déterminer les actions
|
|
const analyzedAvenants: SmartReminderAvenant[] = selectedAvenants.map(avenant => {
|
|
// Déterminer qui a signé
|
|
const employerSigned = avenant.signature_status === 'pending_employee' || avenant.signature_status === 'signed';
|
|
const employeeSigned = avenant.signature_status === 'signed';
|
|
|
|
let action: ReminderAction;
|
|
let reason = '';
|
|
|
|
// Si déjà complètement signé
|
|
if (employerSigned && employeeSigned) {
|
|
action = 'already-signed';
|
|
reason = 'Avenant déjà signé par les deux parties';
|
|
}
|
|
// Si employeur n'a pas signé
|
|
else if (!employerSigned) {
|
|
// Vérifier cooldown (24h)
|
|
if (avenant.last_employer_notification_at) {
|
|
const lastNotif = new Date(avenant.last_employer_notification_at);
|
|
const now = new Date();
|
|
const hoursSince = (now.getTime() - lastNotif.getTime()) / (1000 * 60 * 60);
|
|
if (hoursSince < 24) {
|
|
action = 'skip';
|
|
reason = `Dernière relance envoyée il y a ${Math.round(hoursSince)}h (moins de 24h)`;
|
|
} else {
|
|
action = 'employer';
|
|
reason = 'En attente de signature employeur';
|
|
}
|
|
} else {
|
|
action = 'employer';
|
|
reason = 'En attente de signature employeur';
|
|
}
|
|
}
|
|
// Si employeur a signé mais pas le salarié
|
|
else if (employerSigned && !employeeSigned) {
|
|
// Vérifier cooldown (24h)
|
|
if (avenant.last_employee_notification_at) {
|
|
const lastNotif = new Date(avenant.last_employee_notification_at);
|
|
const now = new Date();
|
|
const hoursSince = (now.getTime() - lastNotif.getTime()) / (1000 * 60 * 60);
|
|
if (hoursSince < 24) {
|
|
action = 'skip';
|
|
reason = `Dernière relance envoyée il y a ${Math.round(hoursSince)}h (moins de 24h)`;
|
|
} else {
|
|
action = 'employee';
|
|
reason = 'En attente de signature salarié';
|
|
}
|
|
} else {
|
|
action = 'employee';
|
|
reason = 'En attente de signature salarié';
|
|
}
|
|
}
|
|
else {
|
|
action = 'skip';
|
|
reason = 'Statut de signature inconnu';
|
|
}
|
|
|
|
return {
|
|
id: avenant.id!,
|
|
numero_avenant: avenant.id,
|
|
contract_number: avenant.contract_number,
|
|
employee_name: avenant.employee_name,
|
|
employer_signed: employerSigned,
|
|
employee_signed: employeeSigned,
|
|
action,
|
|
reason
|
|
};
|
|
});
|
|
|
|
setSmartReminderAvenants(analyzedAvenants);
|
|
setShowSmartReminderModal(true);
|
|
};
|
|
|
|
// Fonction pour confirmer et envoyer les relances
|
|
const confirmSmartReminders = async (forceResend: boolean) => {
|
|
setIsLoadingReminders(true);
|
|
setSmartReminderProgress([...smartReminderAvenants].map(a => ({ ...a, status: 'pending' })));
|
|
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
for (const avenant of smartReminderAvenants) {
|
|
// Déterminer l'action finale
|
|
const extended = avenant as SmartReminderAvenant & { forcedAction?: ReminderAction };
|
|
let finalAction = avenant.action;
|
|
|
|
// Si forceResend et que l'avenant était skip pour cooldown, envoyer quand même
|
|
if (forceResend && avenant.action === 'skip' && avenant.reason?.includes('moins de 24h')) {
|
|
if (!avenant.employer_signed) {
|
|
finalAction = 'employer';
|
|
extended.forcedAction = 'employer';
|
|
} else if (!avenant.employee_signed) {
|
|
finalAction = 'employee';
|
|
extended.forcedAction = 'employee';
|
|
}
|
|
}
|
|
|
|
// Skip si déjà signé ou action skip sans forceResend
|
|
if (finalAction === 'already-signed' || (finalAction === 'skip' && !forceResend)) {
|
|
continue;
|
|
}
|
|
|
|
// Marquer comme en cours d'envoi
|
|
setSmartReminderProgress(prev =>
|
|
prev.map(a => a.id === avenant.id ? { ...a, status: 'sending' } : a)
|
|
);
|
|
|
|
try {
|
|
// Envoyer la relance selon l'action
|
|
let response;
|
|
if (finalAction === 'employer') {
|
|
response = await fetch(`/api/staff/avenants/${avenant.id}/remind-employer`, {
|
|
method: 'POST',
|
|
});
|
|
} else if (finalAction === 'employee') {
|
|
response = await fetch(`/api/staff/avenants/relance-salarie`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ avenantId: avenant.id }),
|
|
});
|
|
}
|
|
|
|
if (response && response.ok) {
|
|
successCount++;
|
|
setSmartReminderProgress(prev =>
|
|
prev.map(a => a.id === avenant.id ? { ...a, status: 'success' } : a)
|
|
);
|
|
} else {
|
|
errorCount++;
|
|
setSmartReminderProgress(prev =>
|
|
prev.map(a => a.id === avenant.id ? { ...a, status: 'error' } : a)
|
|
);
|
|
}
|
|
} catch (error) {
|
|
errorCount++;
|
|
setSmartReminderProgress(prev =>
|
|
prev.map(a => a.id === avenant.id ? { ...a, status: 'error' } : a)
|
|
);
|
|
}
|
|
|
|
// Délai entre chaque envoi
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
}
|
|
|
|
// Message final
|
|
setIsLoadingReminders(false);
|
|
if (successCount > 0) {
|
|
alert(`${successCount} relance(s) envoyée(s) avec succès`);
|
|
}
|
|
if (errorCount > 0) {
|
|
alert(`${errorCount} erreur(s) lors de l'envoi`);
|
|
}
|
|
|
|
// Déselectionner et rafraîchir
|
|
setTimeout(() => {
|
|
setSelectedAvenantIds(new Set());
|
|
setShowSmartReminderModal(false);
|
|
router.refresh();
|
|
}, 2000);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">Avenants aux contrats</h1>
|
|
<p className="text-sm text-slate-600 mt-1">
|
|
{filteredAndSortedAmendments.length} avenant{filteredAndSortedAmendments.length > 1 ? "s" : ""}
|
|
{activeFiltersCount > 0 && ` (${activeFiltersCount} filtre${activeFiltersCount > 1 ? "s" : ""})`}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{selectedAvenantIds.size > 0 && (
|
|
<button
|
|
onClick={handleSmartReminders}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
|
>
|
|
<Mail className="h-4 w-4" />
|
|
Relances ({selectedAvenantIds.size})
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing}
|
|
className="flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
|
|
title="Rafraîchir la liste"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
|
Rafraîchir
|
|
</button>
|
|
<button
|
|
onClick={() => router.push("/staff/avenants/nouveau")}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors shadow-sm"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Nouvel avenant
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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é, matricule, organisation, production, n° avenant..."
|
|
value={searchTerm}
|
|
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 */}
|
|
{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">
|
|
{activeFiltersCount > 0 ? "Aucun résultat" : "Aucun avenant"}
|
|
</h3>
|
|
<p className="text-sm text-slate-600 mb-6">
|
|
{activeFiltersCount > 0
|
|
? "Aucun avenant ne correspond à vos critères de recherche."
|
|
: "Commencez par créer un nouvel avenant."}
|
|
</p>
|
|
{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"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Créer le premier avenant
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-slate-50 border-b">
|
|
<tr>
|
|
<th className="px-4 py-3">
|
|
<input
|
|
type="checkbox"
|
|
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 cursor-pointer hover:bg-slate-100 whitespace-nowrap"
|
|
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 whitespace-nowrap"
|
|
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 whitespace-nowrap">
|
|
Salarié
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600 whitespace-nowrap">
|
|
Organisation
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600 whitespace-nowrap">
|
|
Type
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600 whitespace-nowrap">
|
|
Éléments
|
|
</th>
|
|
<th
|
|
className="px-4 py-3 text-left text-xs font-medium text-slate-600 cursor-pointer hover:bg-slate-100 whitespace-nowrap"
|
|
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 whitespace-nowrap">
|
|
Signatures
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600 whitespace-nowrap">
|
|
Statut
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{paginatedAmendments.map((amendment) => (
|
|
<tr
|
|
key={amendment.id}
|
|
className="hover:bg-slate-50 transition-colors"
|
|
>
|
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={amendment.id ? selectedAvenantIds.has(amendment.id) : false}
|
|
onChange={() => amendment.id && toggleSelectAvenant(amendment.id)}
|
|
className="w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500"
|
|
/>
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 text-xs font-medium text-slate-900 cursor-pointer whitespace-nowrap"
|
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
|
>
|
|
{amendment.numero_avenant || "-"}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 text-sm text-slate-700 cursor-pointer whitespace-nowrap"
|
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
|
>
|
|
{amendment.contract_number || "-"}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 text-sm text-slate-700 cursor-pointer"
|
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
|
>
|
|
<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"
|
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
|
>
|
|
{amendment.organization_name || "-"}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 cursor-pointer whitespace-nowrap"
|
|
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}`)}
|
|
>
|
|
{getElementsLabel(amendment.elements)}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 text-sm text-slate-700 cursor-pointer whitespace-nowrap"
|
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
|
>
|
|
{formatDate(amendment.date_effet)}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 cursor-pointer whitespace-nowrap"
|
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
|
>
|
|
{getSignatureIcons(amendment.signature_status)}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 cursor-pointer whitespace-nowrap"
|
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
|
>
|
|
{getStatusBadge(amendment.status)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
router.push(`/staff/avenants/${amendment.id}`);
|
|
}}
|
|
className="text-indigo-600 hover:text-indigo-700 font-medium"
|
|
>
|
|
Voir
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</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 */}
|
|
<SmartReminderAvenantModal
|
|
isOpen={showSmartReminderModal}
|
|
onClose={() => setShowSmartReminderModal(false)}
|
|
onConfirm={confirmSmartReminders}
|
|
isLoading={isLoadingReminders}
|
|
avenants={smartReminderAvenants}
|
|
progressAvenants={smartReminderProgress.length > 0 ? smartReminderProgress : undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|