espace-paie-odentas/components/staff/NouvelAvenantPageClient.tsx
odentas 90d9f6b56f feat: Ajouter support des avenants d'annulation avec envoi à PDFMonkey
- Modifier NouvelAvenantPageClient pour gérer type_avenant annulation
- Désactiver la sélection d'éléments pour les annulations
- Ajouter message d'information pour les avenants d'annulation
- Adapter l'API generate-pdf pour envoyer annulation: Oui à PDFMonkey
- Modifier l'API create pour accepter les annulations sans éléments requis
- Ne pas mettre à jour le contrat pour les annulations
2025-10-24 19:50:30 +02:00

608 lines
25 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Search, X, Loader2, CheckCircle2, Calendar, ArrowLeft, FileText } from "lucide-react";
import {
Amendment,
AmendmentElementType,
ContractSearchResult,
OriginalContractData
} from "@/types/amendments";
import AmendmentObjetForm from "@/components/staff/amendments/AmendmentObjetForm";
import AmendmentDureeForm from "@/components/staff/amendments/AmendmentDureeForm";
import AmendmentRemunerationForm from "@/components/staff/amendments/AmendmentRemunerationForm";
import AvenantSuccessModal from "@/components/staff/amendments/AvenantSuccessModal";
export default function NouvelAvenantPageClient() {
const router = useRouter();
// États
const [step, setStep] = useState<"search" | "form">("search");
const [searchTerm, setSearchTerm] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<ContractSearchResult[]>([]);
const [selectedContract, setSelectedContract] = useState<OriginalContractData | null>(null);
// Données du formulaire
const [dateEffet, setDateEffet] = useState("");
const [dateSignature, setDateSignature] = useState("");
const [typeAvenant, setTypeAvenant] = useState<"modification" | "annulation">("modification");
const [motifAvenant, setMotifAvenant] = useState("");
const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]);
// Données spécifiques selon les éléments
const [objetData, setObjetData] = useState<any>({});
const [dureeData, setDureeData] = useState<any>({});
const [remunerationData, setRemunerationData] = useState<any>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Modale de succès
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [createdAvenantId, setCreatedAvenantId] = useState("");
const [createdNumeroAvenant, setCreatedNumeroAvenant] = useState("");
// PDF generation
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [pdfPresignedUrl, setPdfPresignedUrl] = useState<string | null>(null);
const [pdfS3Key, setPdfS3Key] = useState<string | null>(null);
// Recherche de contrats (debounced)
useEffect(() => {
if (searchTerm.length < 2) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
setIsSearching(true);
try {
const response = await fetch(
`/api/staff/contracts/search?q=${encodeURIComponent(searchTerm)}`
);
if (response.ok) {
const data = await response.json();
setSearchResults(data.rows || data.contracts || []);
}
} catch (error) {
console.error("Erreur recherche contrats:", error);
} finally {
setIsSearching(false);
}
}, 400);
return () => clearTimeout(timer);
}, [searchTerm]);
// Sélection d'un contrat
const handleSelectContract = async (contractId: string) => {
try {
const response = await fetch(`/api/staff/contracts/${contractId}`);
if (response.ok) {
const data = await response.json();
const contract = data.contract || data;
console.log("Données du contrat reçues:", contract);
// Mapper les données du contrat vers OriginalContractData
const contractData: OriginalContractData = {
id: contract.id,
contract_number: contract.contract_number,
employee_id: contract.employee_id,
employee_name: contract.employee_name,
employee_matricule: contract.employee_matricule,
org_id: contract.org_id,
organization_name: contract.structure,
type_de_contrat: contract.type_de_contrat,
categorie_pro: contract.categorie_pro,
profession: contract.profession,
production_name: contract.production_name,
numero_objet: contract.n_objet,
start_date: contract.start_date,
end_date: contract.end_date,
nb_representations: contract.cachets_representations || contract.nb_representations,
nb_repetitions: contract.services_repetitions || contract.nb_services_repetition,
nb_heures: contract.nombre_d_heures || contract.heures_total,
dates_representations: contract.jours_representations || contract.dates_representations,
dates_repetitions: contract.jours_repetitions || contract.dates_repetitions,
jours_travail: contract.jours_travail_non_artiste || contract.jours_travail,
lieu_travail: contract.lieu_travail,
gross_pay: contract.gross_pay || contract.brut,
precisions_salaire: contract.precisions_salaire,
type_salaire: contract.type_salaire,
};
setSelectedContract(contractData);
// Pré-remplir les données avec les valeurs du contrat
setObjetData({
profession_code: contractData.profession?.split(" - ")[0] || "",
profession_label: contractData.profession?.split(" - ")[1] || contractData.profession || "",
production_name: contractData.production_name || "",
production_numero_objet: contractData.numero_objet || "",
});
setDureeData({
date_debut: contractData.start_date || "",
date_fin: contractData.end_date || "",
nb_representations: contractData.nb_representations || "",
nb_repetitions: contractData.nb_repetitions || "",
nb_heures: contractData.nb_heures || "",
dates_representations: contractData.dates_representations || "",
dates_repetitions: contractData.dates_repetitions || "",
jours_travail: contractData.jours_travail || "",
});
setRemunerationData({
gross_pay: contractData.gross_pay || "",
precisions_salaire: contractData.precisions_salaire || "",
type_salaire: contractData.type_salaire || "Brut",
});
setStep("form");
}
} catch (error) {
console.error("Erreur chargement contrat:", error);
}
};
// Toggle élément à avenanter
const toggleElement = (element: AmendmentElementType) => {
if (selectedElements.includes(element)) {
setSelectedElements(selectedElements.filter((e) => e !== element));
} else {
setSelectedElements([...selectedElements, element]);
}
};
// Validation
const canSubmit = useMemo(() => {
// Pour une annulation, pas besoin de sélectionner des éléments
if (typeAvenant === "annulation") {
return !!(selectedContract && dateEffet);
}
// Pour une modification, il faut au moins un élément
if (!selectedContract || !dateEffet || selectedElements.length === 0) return false;
return true;
}, [selectedContract, dateEffet, selectedElements, typeAvenant]);
// Génération du PDF
const handleGeneratePdf = async () => {
if (!canSubmit || !selectedContract) return;
setIsGeneratingPdf(true);
try {
const amendmentData = {
contract_id: selectedContract.id,
date_effet: dateEffet,
date_signature: dateSignature || undefined,
type_avenant: typeAvenant,
elements: selectedElements,
objet_data: selectedElements.includes("objet") ? objetData : undefined,
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
};
const response = await fetch("/api/staff/amendments/generate-pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contractId: selectedContract.id,
amendmentData,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de la génération du PDF");
}
const data = await response.json();
setPdfPresignedUrl(data.presignedUrl);
setPdfS3Key(data.s3Key);
} catch (error: any) {
console.error("Erreur génération PDF:", error);
alert("Erreur lors de la génération du PDF: " + error.message);
} finally {
setIsGeneratingPdf(false);
}
};
// Soumission
const handleSubmit = async () => {
if (!canSubmit) return;
setIsSubmitting(true);
try {
const amendmentData = {
contract_id: selectedContract!.id,
date_effet: dateEffet,
date_signature: dateSignature || undefined,
type_avenant: typeAvenant,
motif_avenant: motifAvenant || undefined,
elements_avenantes: selectedElements,
objet_data: selectedElements.includes("objet") ? objetData : undefined,
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
lieu_horaire_data: selectedElements.includes("lieu_horaire") ? {} : undefined,
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
pdf_url: pdfPresignedUrl || undefined,
pdf_s3_key: pdfS3Key || undefined,
};
// Appeler l'API pour créer l'avenant et mettre à jour le contrat
const response = await fetch('/api/staff/amendments/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(amendmentData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de la création de l'avenant");
}
const data = await response.json();
setCreatedAvenantId(data.avenant.id);
setCreatedNumeroAvenant(data.avenant.numero_avenant);
setShowSuccessModal(true);
} catch (error) {
console.error("Erreur création avenant:", error);
alert("Erreur lors de la création de l'avenant");
} finally {
setIsSubmitting(false);
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-";
const [y, m, d] = dateStr.split("-");
return `${d}/${m}/${y}`;
};
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* Header avec bouton retour */}
<div className="flex items-center gap-4">
<button
onClick={() => router.push("/staff/avenants")}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-slate-600" />
</button>
<div className="flex-1">
<h1 className="text-2xl font-bold text-slate-900">
{step === "search" ? "Rechercher un contrat" : "Nouvel avenant"}
</h1>
<p className="text-sm text-slate-600 mt-1">
{step === "search"
? "Sélectionnez le contrat à avenanter"
: "Définissez les modifications à apporter au contrat"}
</p>
</div>
</div>
{/* Étape 1: Recherche du contrat */}
{step === "search" && (
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
<input
type="text"
placeholder="Rechercher par n° contrat, salarié, organisation, référence..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-11 pr-4 py-3 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
autoFocus
/>
</div>
{isSearching && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-indigo-600" />
</div>
)}
{!isSearching && searchResults.length > 0 && (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{searchResults.map((contract) => (
<button
key={contract.id}
onClick={() => handleSelectContract(contract.id)}
className="w-full p-4 border rounded-lg hover:bg-slate-50 hover:border-indigo-300 transition-all text-left"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="font-medium text-slate-900">
{contract.contract_number}
</div>
<div className="text-sm text-slate-600 mt-1">
{contract.employee_name}
{contract.employee_matricule && ` (${contract.employee_matricule})`}
</div>
<div className="text-sm text-slate-600">
{contract.organization_name || contract.structure}
</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-500">
{formatDate(contract.start_date)} - {formatDate(contract.end_date)}
</div>
{contract.profession && (
<div className="text-xs text-slate-500 mt-1">{contract.profession}</div>
)}
</div>
</div>
</button>
))}
</div>
)}
{!isSearching && searchTerm.length >= 2 && searchResults.length === 0 && (
<div className="py-12 text-center">
<FileText className="h-12 w-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-600">Aucun contrat trouvé pour "{searchTerm}"</p>
</div>
)}
{searchTerm.length < 2 && (
<div className="py-12 text-center">
<Search className="h-12 w-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500 text-sm">
Tapez au moins 2 caractères pour rechercher
</p>
</div>
)}
</div>
)}
{/* Étape 2: Formulaire de l'avenant */}
{step === "form" && selectedContract && (
<div className="space-y-6">
{/* Infos du contrat */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-slate-900 text-lg">
Contrat {selectedContract.contract_number}
</div>
<div className="text-sm text-slate-600 mt-1">
{selectedContract.employee_name} {selectedContract.organization_name}
</div>
<div className="text-xs text-slate-500 mt-1">
{formatDate(selectedContract.start_date)} - {formatDate(selectedContract.end_date)}
</div>
</div>
<button
onClick={() => setStep("search")}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Dates */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4">Dates de l'avenant</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Date d'effet de l'avenant <span className="text-red-500">*</span>
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="date"
value={dateEffet}
onChange={(e) => setDateEffet(e.target.value)}
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>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Date de signature
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="date"
value={dateSignature}
onChange={(e) => setDateSignature(e.target.value)}
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>
</div>
</div>
</div>
{/* Type et Motif */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4">Informations complémentaires</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Type d'avenant <span className="text-red-500">*</span>
</label>
<select
value={typeAvenant}
onChange={(e) => setTypeAvenant(e.target.value as "modification" | "annulation")}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="modification">Modification</option>
<option value="annulation">Annulation</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Motif de l'avenant
</label>
<input
type="text"
value={motifAvenant}
onChange={(e) => setMotifAvenant(e.target.value)}
placeholder="Ex: Changement de dates, modification du salaire..."
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
</div>
{/* Éléments à avenanter - Seulement pour les modifications */}
{typeAvenant === "modification" && (
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4">
Éléments à avenanter <span className="text-red-500">*</span>
</h2>
<div className="grid grid-cols-2 gap-3">
{[
{ value: "objet" as const, label: "Objet (profession, production)" },
{ value: "duree" as const, label: "Durée de l'engagement" },
{ value: "lieu_horaire" as const, label: "Lieu et horaires" },
{ value: "remuneration" as const, label: "Rémunération" },
].map((element) => (
<button
key={element.value}
onClick={() => toggleElement(element.value)}
className={`p-4 border rounded-lg text-left transition-all ${
selectedElements.includes(element.value)
? "bg-indigo-50 border-indigo-500 text-indigo-700"
: "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
}`}
>
<div className="flex items-center gap-2">
<div
className={`w-5 h-5 rounded border flex items-center justify-center ${
selectedElements.includes(element.value)
? "bg-indigo-600 border-indigo-600"
: "border-slate-300"
}`}
>
{selectedElements.includes(element.value) && (
<CheckCircle2 className="h-3 w-3 text-white" />
)}
</div>
<span className="text-sm font-medium">{element.label}</span>
</div>
</button>
))}
</div>
</div>
)}
{/* Message pour les annulations */}
{typeAvenant === "annulation" && (
<div className="bg-orange-50 border border-orange-200 rounded-xl p-6">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<FileText className="h-5 w-5 text-orange-600" />
</div>
<div>
<div className="font-medium text-orange-900">Avenant d'annulation</div>
<div className="text-sm text-orange-700 mt-1">
Cet avenant annulera le contrat à partir de la date d'effet. Aucune modification spécifique n'est requise.
</div>
</div>
</div>
</div>
)}
{/* Formulaires conditionnels - Seulement pour les modifications */}
{typeAvenant === "modification" && selectedElements.includes("objet") && (
<div className="bg-white rounded-xl border shadow-sm p-6">
<AmendmentObjetForm
originalData={selectedContract}
data={objetData}
onChange={setObjetData}
/>
</div>
)}
{typeAvenant === "modification" && selectedElements.includes("duree") && (
<div className="bg-white rounded-xl border shadow-sm p-6">
<AmendmentDureeForm
originalData={selectedContract}
data={dureeData}
onChange={setDureeData}
/>
</div>
)}
{typeAvenant === "modification" && selectedElements.includes("remuneration") && (
<div className="bg-white rounded-xl border shadow-sm p-6">
<AmendmentRemunerationForm
originalData={selectedContract}
data={remunerationData}
onChange={setRemunerationData}
/>
</div>
)}
{/* Lien PDF si généré */}
{pdfPresignedUrl && (
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="font-medium text-green-900">PDF généré avec succès</div>
<div className="text-sm text-green-700">Le PDF de l'avenant est prêt</div>
</div>
</div>
<a
href={pdfPresignedUrl}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
>
Voir le PDF
</a>
</div>
</div>
)}
{/* Actions */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<div className="flex items-center justify-between">
<button
onClick={handleGeneratePdf}
disabled={!canSubmit || isGeneratingPdf}
className="px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 font-medium"
>
{isGeneratingPdf && <Loader2 className="h-4 w-4 animate-spin" />}
Créer le PDF
</button>
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/staff/avenants")}
className="px-6 py-3 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors font-medium"
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 font-medium"
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Créer l'avenant
</button>
</div>
</div>
</div>
</div>
)}
{/* Modale de succès */}
<AvenantSuccessModal
isOpen={showSuccessModal}
numeroAvenant={createdNumeroAvenant}
avenantId={createdAvenantId}
/>
</div>
);
}